Compare commits

..

11 Commits

Author SHA1 Message Date
langbot-dev e82918faef fix(wizard): prevent duplicate models at API level
Before adding models, fetch existing provider models and skip
names that already exist. Show error toast for manual add duplicates.
2026-08-12 02:38:28 +08:00
langbot-dev 8b0f2f58b1 fix(wizard): prevent duplicate model additions in scan page
- Track added model names in state
- Disable already-added models with 'Added' badge
- Clear selection after successful add
- Add Next button at bottom when models have been added
2026-08-12 02:34:30 +08:00
langbot-dev 72c03276ff feat(wizard): improve model scan with search and manual add
- Provider form submit button changed to 'Save & Next'
- Remove extra Next button from provider form
- Add search input to filter scanned models
- Add manual model name input for APIs that don't support scanning
- Add divider between scanned list and manual add section
- Add i18n translations for all locales
2026-08-12 02:29:36 +08:00
langbot-dev d7a034a562 feat(wizard): add next-step button on provider form when returning
When navigating back to the provider form after already creating a
provider, show a Next button that skips to the model scan or config
layer without requiring re-submission.
2026-08-12 02:22:28 +08:00
langbot-dev c4de3c5168 feat(wizard): add next-step navigation to jump back to deepest layer
Track maxReachedLayer so clicking Next from an earlier layer jumps
directly to the deepest previously visited layer, preserving state.
Reset tracking when the user changes their AI engine mode choice.
2026-08-12 02:18:27 +08:00
langbot-dev 259e127696 fix(wizard): preserve form data and model selection on back nav
- Add initialValues and onValuesChange props to ProviderForm
- Save provider form data and selected models in wizard state
- Fix canProceed missing modelsAdded dependency
- Restore form data and selections when navigating back
2026-08-12 02:11:59 +08:00
langbot-dev 1f0371e177 fix(wizard): preserve state when navigating back between layers
Use a dedicated step2Layer state for sub-layer navigation within
Step 2, instead of resetting data flags. This preserves provider
form data, scan results, and model selections when going back.
2026-08-12 02:05:39 +08:00
langbot-dev 62d352ce03 fix(wizard): use correct reasoning_config format for model creation
Use { level: 'provider_default' } instead of { enabled: false }.
Also use model's scanned abilities instead of hardcoded ['llm'].
2026-08-12 01:58:43 +08:00
langbot-dev d06c4287bf feat(wizard): add model scan step after provider creation
After creating a custom provider, scan for available LLM models
and let the user select which ones to add before proceeding.
Support back navigation from config form to model scan to provider form.
Pass provider UUID from ProviderForm callback.
2026-08-12 01:51:28 +08:00
langbot-dev af80425627 fix(wizard): auto-select local-agent after provider creation
After creating a custom provider, automatically select local-agent as
the runner so the config form (with model selector) is displayed.
Require runner selection for canProceed in LLM+custom path.
2026-08-12 01:42:43 +08:00
langbot-dev 1f3aad6baa feat(wizard): restructure AI engine selection flow
Restructure the wizard's Step 2 (AI Engine) into a two-layer choice:
- Orchestrated Agent apps (Dify, n8n, Coze, etc.)
- Direct LLM usage (Space service or custom provider)

For direct LLM mode, users choose between LangBot Space (OAuth)
or adding their own model provider via a dedicated ProviderForm page.

Support intra-step back navigation across all layers.
Add Vite dev server proxy config for API requests.
Add i18n translations for all 8 locales.
2026-08-12 01:29:07 +08:00
33 changed files with 1257 additions and 1846 deletions
@@ -103,11 +103,11 @@ This log records implementation choices made while delivering the Workspace arch
- Decision: New Core JWTs require `iss=langbot-core`, an audience derived from the immutable instance UUID, and an expiry. Legacy community tokens are accepted only when they have the historical issuer, carry no audience, and the active policy is the OSS singleton policy.
- Reason: A token issued by one instance must not authenticate against another instance that happens to share a secret, and a compatibility decoder must not become an alternate path around the SaaS trust boundary.
### Runtime control transports support opt-in shared-secret authentication
### Runtime control transports authenticate before protocol dispatch
- Decision: OSS external Plugin Runtime and Box WebSocket control channels preserve tokenless standalone compatibility when the corresponding control token is unset. When a Runtime configures a token, it validates the independent shared secret in the handshake before protocol dispatch. Locally managed child processes still receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally pins the first control channel to one declared instance identity. Plugin Runtime debug and control credentials remain separate.
- Reason: Local OSS development must remain backward compatible, while exposed or shared Runtime endpoints can opt into transport authentication. Separating control and debug credentials also limits accidental privilege reuse.
- Deployment consequence: Docker Compose and Kubernetes should wire one strong shared secret to each host/runtime pair. Both sides must use the same value for protection to be effective; a Runtime configured with a token rejects clients that omit it or send a different value.
- Decision: External Plugin Runtime and Box WebSocket control channels require independent strong shared secrets in handshake headers. Locally managed child processes receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally binds the first authenticated control channel to one trusted instance. Plugin Runtime debug and control credentials remain separate.
- Reason: Workspace context inside an RPC payload is not trustworthy until the transport peer itself is authenticated. Separating control and debug credentials also limits accidental privilege reuse.
- Deployment consequence: Docker Compose and Kubernetes wire one shared secret to each host/runtime pair. An empty external-runtime secret fails startup instead of silently exposing an unauthenticated socket.
### Dashboard WebSocket sessions are tenant runtime objects
+1 -2
View File
@@ -22,7 +22,6 @@ dependencies = [
"discord-py>=2.5.2",
"pynacl>=1.5.0", # Required for Discord voice support
"gewechat-client>=0.1.5",
"itchat-uos>=1.5.0.dev",
"lark-oapi>=1.5.5",
"mcp>=1.25.0,<2.0.0",
"nakuru-project-idk>=0.0.2.1",
@@ -72,7 +71,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@7b559da430a50f80a7d30c9d3d66f088503ddbb3",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@9d216208cdfb41f0cb7fcb64632e2a46816d6dc6",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
+4 -5
View File
@@ -27,11 +27,10 @@ The `all` / `box` profile starts three services:
- `langbot_box` — Box sandbox runtime (`:5410`). Uses the host Docker socket to
spawn sandbox containers, so the **Box root host path and in-container path
must be identical** (`BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}`).
OSS allows its RPC and managed-process relay to run without a token when both
sides leave `LANGBOT_BOX_CONTROL_TOKEN` unset. For an exposed endpoint, set
the same value of at least 32 non-whitespace characters in both the LangBot
and Box containers. Generate it once with `openssl rand -hex 32`; never put
it in `box.runtime.endpoint` or commit it to config.
Its RPC and managed-process relay require a shared
`LANGBOT_BOX_CONTROL_TOKEN` (at least 32 non-whitespace characters) in both
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
never put it in `box.runtime.endpoint` or commit it to config.
A Compose deployment may optionally set
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
+3 -10
View File
@@ -6,8 +6,7 @@ description: Browse and search the LangBot Space marketplaces (plugins, MCP serv
# LangBot Space MCP Operations
LangBot Space (space.langbot.app) exposes an **MCP server** so user-facing AI
agents can browse and search the marketplaces (plugins, MCP servers, skills) and
rank live models for automated setup.
agents can browse and search the marketplaces (plugins, MCP servers, skills).
## Endpoint
@@ -47,12 +46,10 @@ Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
| `list_plugins` / `search_plugins` / `get_plugin` | Plugin marketplace |
| `list_mcp_servers` / `search_mcp_servers` / `get_mcp_server` | MCP-server marketplace |
| `list_skills` / `search_skills` / `get_skill` | Skill marketplace |
| `select_models` | Live best-first model list for setup wizards; optional `category` filter |
`list_*` and `search_*` are paged (`page`, `page_size`). `get_*` takes
`author` + `name`. The tool surface mirrors the REST endpoints under
`/api/v1/marketplace/*`; `select_models` mirrors `/api/v1/models/selection`.
All tools are read-only.
`/api/v1/marketplace/*` and is read/browse only.
## How to use
@@ -61,16 +58,12 @@ All tools are read-only.
3. Use `search_plugins` / `search_mcp_servers` / `search_skills` to find items,
then `get_*` for details (e.g. to obtain author/name for installation in
LangBot itself).
4. For automatic local-agent setup, call `select_models` (optionally with
`category`) and choose the first compatible item. Ordering is latest probe
state (available, unprobed, unavailable), then Space recommendation. Each
item includes `availability.up`, `last_probed_at`, latency, and HTTP status.
## Implementation & maintenance (for Space developers)
- Server: `internal/controller/mcp/server.go` (official Go MCP SDK
`github.com/modelcontextprotocol/go-sdk`). Tools call the service layer
(`PluginService`, `MCPService`, `SkillService`, `ModelStatusService`) directly.
(`PluginService`, `MCPService`, `SkillService`) directly.
- Mount: `internal/controller/api.go` at `/mcp` and `/mcp/*any`.
- Auth: PAT via `AccountService.ValidatePersonalAccessToken`.
- Docs: `docs/MCP_SERVER.md`.
@@ -1,7 +1,6 @@
import asyncio
import dataclasses
import mimetypes
import os
import quart
@@ -1134,224 +1133,3 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
# -----------------------------------------------------------------------
# Itchat WeChat QR Code Login
# -----------------------------------------------------------------------
_itchat_login_sessions: dict = {}
_ITCHAT_SESSION_TTL = 600 # 10 minutes (allows multiple QR regenerations)
def _cleanup_expired_itchat_sessions():
import time
now = time.time()
expired = [
sid for sid, s in _itchat_login_sessions.items() if now - s.get('created_at', 0) > _ITCHAT_SESSION_TTL
]
for sid in expired:
session = _itchat_login_sessions.pop(sid, None)
if session:
core = session.get('core')
if core:
try:
core.alive = False
core.isLogging = False
except Exception:
pass
@self.route('/itchat/login', methods=['POST'])
async def _() -> str:
"""Start itchat WeChat QR code login. Returns session_id + QR code data URL."""
import uuid
import time
import base64
import threading
_cleanup_expired_itchat_sessions()
session_id = str(uuid.uuid4())
loop = asyncio.get_running_loop()
status_dir = os.path.join('data', 'itchat')
os.makedirs(status_dir, exist_ok=True)
qr_path = os.path.join(status_dir, f'{session_id}-QR.png')
session = {
'status': 'pending',
'qr_data_url': None,
'expire_at': None,
'nickname': None,
'error': None,
'created_at': time.time(),
'thread': None,
'logged_in': threading.Event(),
'core': None,
}
_itchat_login_sessions[session_id] = session
def _run_itchat_login():
try:
from itchat.core import Core
from itchat.content import TEXT as _TEXT
from langbot.pkg.platform.sources.itchat import ItchatAdapter
for f in (qr_path,):
try:
os.remove(f)
except OSError:
pass
_core = Core()
session['core'] = _core
def on_login():
try:
_core.get_friends(update=True)
user_info = _core.loginInfo.get('User', {})
nick = ItchatAdapter._get_obj_value(user_info, 'NickName', 'unknown')
wxid = ItchatAdapter._get_obj_value(user_info, 'UserName')
except Exception:
nick = 'unknown'
wxid = ''
session['nickname'] = nick
session['wxid'] = wxid
print(f'[itchat-login] Login success: {nick}', flush=True)
# Dump login status so the adapter can hot-reload it
try:
if not wxid:
raise ValueError('Unable to detect WeChat wxid after login')
account_status_path = ItchatAdapter.login_status_path_for_account(wxid)
_core.dump_login_status(account_status_path)
session['login_status_path'] = account_status_path
session['status'] = 'success'
print(f'[itchat-login] Session saved to {account_status_path}', flush=True)
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
print(f'[itchat-login] Failed to save session: {e}', flush=True)
finally:
session['logged_in'].set()
# Stop the message loop - we only needed the session for QR login
_core.alive = False
def on_qr(**kwargs):
qr_bytes = kwargs.get('qrcode', b'')
status = kwargs.get('status', '')
print(f'[itchat-login] QR callback: status={status}, bytes={len(qr_bytes)}', flush=True)
if status == '200':
return
# Only update QR image on new QR generation (status='0')
# or when status changes to '408' (timeout, QR may refresh)
if qr_bytes and status == '0':
b64 = base64.b64encode(qr_bytes).decode('utf-8')
def _update():
session['qr_data_url'] = f'data:image/png;base64,{b64}'
session['expire_at'] = time.time() + 120
session['status'] = 'waiting'
loop.call_soon_threadsafe(_update)
# Register a dummy text handler
@_core.msg_register([_TEXT])
def _dummy(msg):
pass
print('[itchat-login] Step 3: Calling auto_login...', flush=True)
_core.auto_login(
hotReload=False,
loginCallback=on_login,
qrCallback=on_qr,
)
print('[itchat-login] Step 4: auto_login returned, starting run...', flush=True)
_core.run(blockThread=True)
print('[itchat-login] Step 5: run() returned', flush=True)
except SystemExit as e:
print(f'[itchat-login] SystemExit: {e}', flush=True)
session['status'] = 'error'
session['error'] = f'itchat exited: {e}'
session['logged_in'].set()
except Exception as e:
import traceback
print(f'[itchat-login] Exception: {traceback.format_exc()}', flush=True)
session['status'] = 'error'
session['error'] = str(e)
session['logged_in'].set()
t = threading.Thread(target=_run_itchat_login, daemon=True)
t.start()
session['thread'] = t
# Wait for QR code to be ready (max 15 seconds)
for _ in range(30):
if session['qr_data_url'] or session['error'] or session['status'] == 'success':
break
await asyncio.sleep(0.5)
if session['error']:
return self.http_status(502, -1, session['error'])
if session['status'] == 'success':
return self.success(
data={
'session_id': session_id,
'status': 'success',
'nickname': session['nickname'],
'wxid': session.get('wxid', ''),
}
)
if not session['qr_data_url']:
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_data_url': session['qr_data_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/itchat/login/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll itchat login status."""
session = _itchat_login_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {
'status': session['status'],
'qr_data_url': session['qr_data_url'],
'expire_at': session['expire_at'],
}
if session['status'] == 'success':
data['nickname'] = session.get('nickname', '')
data['wxid'] = session.get('wxid', '')
_itchat_login_sessions.pop(session_id, None)
elif session['status'] == 'error':
data['error'] = session['error']
_itchat_login_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/itchat/login/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up an itchat login session."""
session = _itchat_login_sessions.pop(session_id, None)
if session:
core = session.get('core')
if core:
try:
core.alive = False
core.isLogging = False
except Exception:
pass
thread = session.get('thread')
if thread and thread.is_alive():
# Thread is daemon, will die with the process
pass
return self.success(data={})
@@ -398,16 +398,7 @@ class PluginsRouterGroup(group.RouterGroup):
# Get debug URL from config
plugin_config = self.ap.instance_config.data.get('plugin', {})
debug_url = plugin_config.get(
'display_plugin_debug_url',
'ws://localhost:5401/plugin/debug/ws',
)
parsed_debug_url = urlparse(debug_url)
if parsed_debug_url.scheme in {'http', 'https'}:
debug_url = parsed_debug_url._replace(
scheme='wss' if parsed_debug_url.scheme == 'https' else 'ws',
path=parsed_debug_url.path or '/plugin/debug/ws',
).geturl()
debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401')
return self.success(
data={
-2
View File
@@ -69,8 +69,6 @@ class BotService:
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is not None:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
if hasattr(runtime_bot.adapter, 'get_runtime_status'):
adapter_runtime_values['runtime_status'] = runtime_bot.adapter.get_runtime_status()
# Webhook URL for unified webhook adapters (independent of bot running state)
if persistence_bot['adapter'] in [
+6 -8
View File
@@ -367,8 +367,6 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
def _ensure_control_token(self, *, allow_generate: bool) -> str:
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
if not self._control_token:
return ''
try:
self._control_token = validate_control_token(self._control_token)
except ValueError as exc:
@@ -378,19 +376,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
return self._control_token
def get_control_headers(self) -> dict[str, str]:
"""Return instance-scoped RPC headers and the optional shared secret."""
"""Headers for the instance-authenticated RPC control handshake."""
self._ensure_control_token(allow_generate=False)
headers = {BOX_INSTANCE_HEADER: self._trusted_instance_uuid}
if self._control_token:
headers[BOX_CONTROL_TOKEN_HEADER] = self._control_token
return headers
return {
BOX_CONTROL_TOKEN_HEADER: self._control_token,
BOX_INSTANCE_HEADER: self._trusted_instance_uuid,
}
def get_relay_headers(
self,
action_context: ActionContext,
) -> dict[str, str]:
"""Return instance- and placement-scoped relay handshake headers."""
"""Return authenticated, placement-scoped relay handshake headers."""
context = ActionContext.model_validate(action_context).without_installation()
if context.instance_uuid != self._trusted_instance_uuid:
-771
View File
@@ -1,771 +0,0 @@
"""itchat-uos adapter for LangBot.
Uses the itchat-uos WeChat Web library to integrate personal WeChat accounts
with LangBot via QR code login.
Reference: https://github.com/littlecodersh/ItChat
UOS fork: https://github.com/why2lyj/ItChat-uos
"""
from __future__ import annotations
import asyncio
import base64
import os
import re
import tempfile
import threading
import time
import traceback
import typing
from itchat.content import TEXT, PICTURE, RECORDING, VIDEO, SHARING
import pydantic
from itchat.core import Core as ItchatCore
try:
import queue
except ImportError:
import Queue as queue
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.logger import EventLogger
class ItchatMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Converts between LangBot MessageChain and itchat message dicts."""
@staticmethod
async def yiri2target(
message_chain: platform_message.MessageChain,
) -> list[dict]:
"""LangBot MessageChain -> list of itchat-sendable items.
Each item is a dict with 'type' and the relevant content field.
The adapter's send_message() will call itchat.send() accordingly.
"""
items: list[dict] = []
for component in message_chain:
if isinstance(component, platform_message.Plain):
if component.text:
items.append({'type': 'text', 'content': component.text})
elif isinstance(component, platform_message.Image):
if component.base64:
items.append({'type': 'image', 'base64': component.base64})
elif component.url:
items.append({'type': 'image', 'url': component.url})
elif isinstance(component, platform_message.Voice):
if component.base64:
items.append({'type': 'voice', 'base64': component.base64})
elif component.url:
items.append({'type': 'voice', 'url': component.url})
elif isinstance(component, platform_message.File):
if component.base64:
items.append({'type': 'file', 'base64': component.base64, 'name': component.name or 'file'})
elif component.url:
items.append({'type': 'file', 'url': component.url, 'name': component.name or 'file'})
elif isinstance(component, platform_message.At):
items.append({'type': 'text', 'content': f'@{component.target} '})
elif isinstance(component, platform_message.AtAll):
items.append({'type': 'text', 'content': '@所有人 '})
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
items.extend(await ItchatMessageConverter.yiri2target(node.message_chain))
elif isinstance(component, platform_message.Unknown):
pass # skip unknown outbound
return items
@staticmethod
def target2yiri(msg: dict) -> platform_message.MessageChain:
"""Convert an itchat msg dict to a LangBot MessageChain."""
components: list[platform_message.MessageComponent] = []
msg_type = msg.get('Type', '')
if msg_type == 'Text':
text = msg.get('Text', '')
if text:
components.append(platform_message.Plain(text=text))
elif msg_type == 'Picture':
try:
temp_dir = tempfile.gettempdir()
file_path = os.path.join(temp_dir, msg.get('FileName', 'image.jpg'))
msg.download(file_path)
if os.path.exists(file_path):
with open(file_path, 'rb') as f:
img_bytes = f.read()
b64 = base64.b64encode(img_bytes).decode('utf-8')
components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}'))
os.remove(file_path)
else:
components.append(platform_message.Unknown(text='[Image download failed]'))
except Exception:
components.append(platform_message.Unknown(text='[Image download failed]'))
elif msg_type == 'Recording':
try:
temp_dir = tempfile.gettempdir()
file_path = os.path.join(temp_dir, msg.get('FileName', 'voice.mp3'))
msg.download(file_path)
if os.path.exists(file_path):
with open(file_path, 'rb') as f:
voice_bytes = f.read()
b64 = base64.b64encode(voice_bytes).decode('utf-8')
components.append(platform_message.Voice(base64=b64))
os.remove(file_path)
else:
components.append(platform_message.Unknown(text='[Voice download failed]'))
except Exception:
components.append(platform_message.Unknown(text='[Voice download failed]'))
elif msg_type == 'Sharing':
text = msg.get('Text', '')
url = msg.get('Url', '')
content = text
if url and url not in text:
content = f'{text}\n{url}' if text else url
if content:
components.append(platform_message.Plain(text=content))
elif msg_type == 'Video':
components.append(platform_message.Unknown(text='[Video]'))
elif msg_type == 'Map':
components.append(platform_message.Unknown(text='[Location]'))
elif msg_type == 'Card':
components.append(platform_message.Unknown(text='[Contact Card]'))
elif msg_type == 'Note':
text = msg.get('Text', '')
if text:
components.append(platform_message.Unknown(text=f'[Note: {text}]'))
else:
text = msg.get('Text', '')
if text:
components.append(platform_message.Plain(text=text))
else:
components.append(platform_message.Unknown(text=f'[Unsupported message type: {msg_type}]'))
return platform_message.MessageChain(components)
class ItchatEventConverter(abstract_platform_adapter.AbstractEventConverter):
"""Converts itchat msg dicts to LangBot events."""
def __init__(self, adapter_ref: typing.Callable[[], typing.Any]):
"""adapter_ref is a callable returning the ItchatAdapter instance."""
self._get_adapter = adapter_ref
@staticmethod
async def yiri2target(event: platform_events.MessageEvent) -> dict:
return event.source_platform_object
def target2yiri(self, msg: dict) -> typing.Optional[platform_events.MessageEvent]:
"""Convert itchat msg to FriendMessage or GroupMessage."""
from_user = msg.get('FromUserName', '')
if not from_user:
return None
adapter = self._get_adapter()
bot_account_id = adapter.bot_account_id
bot_nickname = adapter._bot_nickname
# Ignore the bot's own messages to avoid reply loops
bot_user_name = getattr(adapter._core.storageClass, 'userName', '')
if from_user == bot_account_id or (bot_user_name and from_user == bot_user_name):
return None
message_chain = ItchatMessageConverter.target2yiri(msg)
if not message_chain:
return None
# Determine if this is a group message
# itchat uses '@@' prefix for chatroom IDs (not '@chatroom' suffix)
is_group = from_user.startswith('@@')
timestamp = msg.get('CreateTime', 0)
if is_group:
# Actual sender within the group
actual_user = msg.get('ActualUserName', '')
actual_nick = msg.get('ActualNickName', '') or actual_user
if not actual_user:
return None
# Prepend @bot if the bot was mentioned
# itchat uses 'IsAt' (capital I, capital A) in produce_group_chat
if msg.get('IsAt', False):
# Strip @bot_nickname from the text content to avoid LLM confusion
if bot_nickname:
at_re = re.compile(re.escape('@' + bot_nickname) + r'[ ]?')
for component in message_chain:
if isinstance(component, platform_message.Plain):
component.text = at_re.sub('', component.text, count=1)
break
message_chain = platform_message.MessageChain(
[platform_message.At(target=bot_account_id)] + list(message_chain)
)
# Try to get group display name
group_obj = msg.get('User', {})
group_name = ''
if hasattr(group_obj, 'NickName'):
group_name = group_obj.NickName
elif isinstance(group_obj, dict):
group_name = group_obj.get('NickName', '')
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=actual_user,
member_name=actual_nick,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=from_user,
name=group_name or from_user,
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=timestamp,
source_platform_object=msg,
)
else:
# Private / friend message
user_obj = msg.get('User', {})
sender_nick = adapter._get_obj_value(user_obj, 'NickName')
sender_remark = adapter._get_obj_value(user_obj, 'RemarkName')
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=from_user,
nickname=sender_nick or from_user,
remark=sender_remark,
),
message_chain=message_chain,
time=timestamp,
source_platform_object=msg,
)
class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""LangBot adapter for itchat-uos (WeChat Web)."""
name: str = 'itchat'
config: dict
logger: EventLogger
message_converter: ItchatMessageConverter
event_converter: ItchatEventConverter
listeners: typing.Dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
_loop: typing.Optional[asyncio.AbstractEventLoop] = pydantic.PrivateAttr(default=None)
_logged_in: typing.Optional[threading.Event] = pydantic.PrivateAttr(default=None)
_itchat_thread: typing.Optional[threading.Thread] = pydantic.PrivateAttr(default=None)
_core: typing.Optional[ItchatCore] = pydantic.PrivateAttr(default=None)
_bot_nickname: str = pydantic.PrivateAttr(default='')
_bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None)
_startup_error: typing.Optional[str] = pydantic.PrivateAttr(default=None)
_connection_status: str = pydantic.PrivateAttr(default='disconnected')
_connection_error: str = pydantic.PrivateAttr(default='')
_last_connected_at: typing.Optional[float] = pydantic.PrivateAttr(default=None)
_last_disconnected_at: typing.Optional[float] = pydantic.PrivateAttr(default=None)
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
message_converter = ItchatMessageConverter()
# Event converter needs a reference to self for bot_account_id + nickname
event_converter = ItchatEventConverter(adapter_ref=lambda: self)
super().__init__(
config=config,
logger=logger,
message_converter=message_converter,
event_converter=event_converter,
listeners={},
bot_account_id='',
)
# Initialize private attributes (can't be class-level defaults due to pickle)
self._loop = None
self._logged_in = threading.Event()
self._itchat_thread = None
self._core = ItchatCore()
self._startup_error = None
self._connection_status = 'disconnected'
self._connection_error = ''
self._last_connected_at = None
self._last_disconnected_at = None
@staticmethod
def _get_obj_value(obj: typing.Any, key: str, default: str = '') -> str:
if isinstance(obj, dict):
return obj.get(key, default) or default
return getattr(obj, key, default) or default
@staticmethod
def _safe_status_name(value: str) -> str:
cleaned = re.sub(r'[^A-Za-z0-9_.@-]+', '_', value.strip())
cleaned = cleaned.strip('._')
return cleaned
@staticmethod
def login_status_dir() -> str:
path = os.path.join('data', 'itchat')
os.makedirs(path, exist_ok=True)
return path
@classmethod
def login_status_path_for_account(cls, account_id: str) -> str:
safe_name = cls._safe_status_name(account_id)
if not safe_name:
raise ValueError('account_id is required for itchat login status')
filename = f'{safe_name}.pkl'
return os.path.join(cls.login_status_dir(), filename)
def _login_status_path(self) -> str:
configured_path = self.config.get('login_status_path', '').strip()
if configured_path:
return configured_path
account_id = self.config.get('account_id', '').strip()
if not account_id:
raise ValueError('account_id is required. Please scan the QR code and save this bot first.')
return self.login_status_path_for_account(account_id)
def set_bot_uuid(self, bot_uuid: str):
self._bot_uuid = bot_uuid
def _set_connection_status(self, status: str, error: str = ''):
self._connection_status = status
self._connection_error = error
now = time.time()
if status == 'connected':
self._last_connected_at = now
elif status in {'disconnected', 'error'}:
self._last_disconnected_at = now
def get_runtime_status(self) -> dict:
return {
'connection_status': self._connection_status,
'connection_error': self._connection_error,
'last_connected_at': self._last_connected_at,
'last_disconnected_at': self._last_disconnected_at,
}
def _on_login(self):
"""Called by itchat after successful QR code login."""
try:
# Refresh contacts
self._core.get_friends(update=True)
self._core.get_chatrooms(update=True)
# Get bot's own WeChat info from loginInfo['User']
user_info = self._core.loginInfo.get('User', {})
nick_name = self._get_obj_value(user_info, 'NickName')
user_name = self._get_obj_value(user_info, 'UserName')
# bot_account_id: config override or auto-detected wxid
# Used by AtBotRule for matching At.target
configured_id = self.config.get('account_id', '').strip()
self.bot_account_id = configured_id or user_name or nick_name or 'itchat-bot'
# _bot_nickname: config override or auto-detected nickname
configured_nick = self.config.get('nickname', '').strip()
self._bot_nickname = configured_nick or nick_name
self._set_connection_status('connected')
try:
chatrooms = self._core.search_chatrooms() or []
group_names = []
for c in chatrooms:
name = self._get_obj_value(c, 'NickName', str(c))
if name:
group_names.append(name)
if group_names:
self._log_sync(
f'itchat login as {nick_name} ({user_name}) | Groups ({len(group_names)}): {", ".join(group_names[:10])}{"..." if len(group_names) > 10 else ""}'
)
else:
self._log_sync(f'itchat login as {nick_name} ({user_name}) | No groups found')
except Exception as e:
self._log_sync(f'itchat login as {nick_name} ({user_name}) | Failed to list groups: {e}', 'warning')
except Exception as e:
self.bot_account_id = f'WeChat Bot (Error: {e})'
self._set_connection_status('error', str(e))
finally:
self._logged_in.set()
def _log_sync(self, msg: str, level: str = 'info'):
"""Thread-safe logging from itchat's sync thread."""
try:
if self._loop and not self._loop.is_closed():
log_fn = getattr(self.logger, level)
asyncio.run_coroutine_threadsafe(log_fn(msg), self._loop)
except Exception:
pass
def _drain_msglist(self):
"""Clear all stale messages from the msgList queue.
itchat's load_login_status fetches old messages via get_msg() and
pushes them into msgList. We drain them to avoid replaying history.
"""
try:
q = self._core.msgList
while True:
q.get_nowait()
except queue.Empty:
pass
def _on_qr_callback(self, **kwargs):
"""Called by itchat when QR code is generated or status changes.
Args:
uuid: QR code uuid
status: '200' = logged in, '201' = confirmed on phone, '408' = timeout
qrcode: raw bytes of the QR code PNG image
"""
status = kwargs.get('status', '')
qr_bytes = kwargs.get('qrcode', b'')
if status == '200':
# Login success, no need to show QR
return
# Only show QR on new QR generation (status='0') to avoid spamming
if not qr_bytes or status != '0':
return
try:
b64 = base64.b64encode(qr_bytes).decode('utf-8')
if self._loop and not self._loop.is_closed():
asyncio.run_coroutine_threadsafe(
self.logger.info(
'Please scan the QR code to login WeChat:',
images=[platform_message.Image(base64=f'data:image/png;base64,{b64}')],
),
self._loop,
)
except Exception:
pass
def _on_exit(self):
"""Called by itchat on exit."""
self._set_connection_status('disconnected')
self._log_sync('itchat session exited')
def _register_itchat_handlers(self):
"""Register itchat message decorators by re-registering handlers."""
@self._core.msg_register([TEXT])
def _on_text(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([TEXT], isGroupChat=True)
def _on_group_text(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([PICTURE])
def _on_picture(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([PICTURE], isGroupChat=True)
def _on_group_picture(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([RECORDING])
def _on_recording(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([RECORDING], isGroupChat=True)
def _on_group_recording(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([SHARING])
def _on_sharing(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([SHARING], isGroupChat=True)
def _on_group_sharing(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([VIDEO])
def _on_video(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([VIDEO], isGroupChat=True)
def _on_group_video(msg):
self._dispatch_itchat_message(msg)
def _dispatch_itchat_message(self, msg: dict):
"""Bridge itchat callback (sync, in itchat thread) to async listener."""
try:
event = self.event_converter.target2yiri(msg)
if event is None:
return
event_type = type(event)
if event_type in self.listeners and self._loop:
callback = self.listeners[event_type]
asyncio.run_coroutine_threadsafe(
callback(event, self),
self._loop,
)
except Exception:
self._log_sync(f'Error dispatching itchat message: {traceback.format_exc()}', 'error')
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
):
"""Send a message to a user or group via itchat."""
items = await self.message_converter.yiri2target(message)
loop = asyncio.get_event_loop()
# Merge consecutive text items to avoid splitting messages
merged = []
for item in items:
if item['type'] == 'text' and merged and merged[-1]['type'] == 'text':
merged[-1]['content'] += item['content']
else:
merged.append(item)
for item in merged:
try:
if item['type'] == 'text':
await loop.run_in_executor(None, self._core.send, item['content'], target_id)
elif item['type'] == 'image':
# Save to temp file then send
temp_path = self._save_to_temp(item, 'image')
if temp_path:
await loop.run_in_executor(None, self._core.send, f'@img@{temp_path}', target_id)
self._cleanup_temp(temp_path)
elif item['type'] == 'voice':
temp_path = self._save_to_temp(item, 'voice')
if temp_path:
await loop.run_in_executor(None, self._core.send, f'@fil@{temp_path}', target_id)
self._cleanup_temp(temp_path)
elif item['type'] == 'file':
temp_path = self._save_to_temp(item, 'file')
if temp_path:
await loop.run_in_executor(None, self._core.send, f'@fil@{temp_path}', target_id)
self._cleanup_temp(temp_path)
except Exception:
await self.logger.error(f'Failed to send itchat message: {traceback.format_exc()}')
def _save_to_temp(self, item: dict, prefix: str) -> typing.Optional[str]:
"""Save base64 or URL data to a temp file and return the path."""
try:
if 'base64' in item:
b64_data = item['base64']
# Strip data URI prefix if present
if ',' in b64_data:
b64_data = b64_data.split(',', 1)[1]
file_bytes = base64.b64decode(b64_data)
suffix = '.jpg' if prefix == 'image' else ('.mp3' if prefix == 'voice' else '.bin')
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix=f'itchat_{prefix}_')
with os.fdopen(fd, 'wb') as f:
f.write(file_bytes)
return temp_path
elif 'url' in item:
import requests
resp = requests.get(item['url'], timeout=30)
if resp.status_code == 200:
suffix = '.jpg' if prefix == 'image' else ('.mp3' if prefix == 'voice' else '.bin')
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix=f'itchat_{prefix}_')
with os.fdopen(fd, 'wb') as f:
f.write(resp.content)
return temp_path
except Exception:
self._log_sync(f'Failed to save temp file: {traceback.format_exc()}', 'error')
return None
def _cleanup_temp(self, path: str):
"""Remove a temp file."""
try:
if os.path.exists(path):
os.remove(path)
except OSError:
pass
def _prepare_reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
) -> platform_message.MessageChain:
"""Render group sender mentions with display names while keeping internal IDs stable."""
if not isinstance(message_source, platform_events.GroupMessage):
return message
source_msg = message_source.source_platform_object or {}
actual_user = source_msg.get('ActualUserName', '')
actual_nick = source_msg.get('ActualNickName', '')
if not actual_user or not actual_nick:
return message
components: list[platform_message.MessageComponent] = []
changed = False
for component in message:
if isinstance(component, platform_message.At) and str(component.target) == str(actual_user):
components.append(platform_message.Plain(text=f'@{actual_nick} '))
changed = True
else:
components.append(component)
if not changed:
return message
return platform_message.MessageChain(components)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
"""Reply to a received message."""
source_msg = message_source.source_platform_object
if not source_msg:
return
# For group messages, reply to the group; for private, reply to the sender
from_user = source_msg.get('FromUserName', '')
if not from_user:
return
await self.send_message('friend', from_user, self._prepare_reply_message(message_source, message))
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners.pop(event_type, None)
async def run_async(self):
"""Start the itchat adapter.
If an account-specific cached session file exists from a previous QR login,
itchat will reuse data/itchat/<account_id>.pkl without requiring a new QR scan.
"""
self._loop = asyncio.get_running_loop()
self._logged_in.clear()
self._startup_error = None
self._set_connection_status('connecting')
await self.logger.info('itchat adapter starting...')
# Register itchat message handlers BEFORE calling itchat.auto_login()
self._register_itchat_handlers()
# Run itchat in a daemon thread (it blocks)
def _run_itchat():
try:
status_path = self._login_status_path()
if not os.path.exists(status_path):
self._startup_error = (
f'No cached WeChat session found at {status_path}. '
'Please scan the QR code in the bot config page first.'
)
self._set_connection_status('error', self._startup_error)
self._log_sync(self._startup_error, 'error')
self._logged_in.set()
return
# Use hotReload to reuse the cached session from QR login
# If no cache exists, fail fast instead of triggering QR login
result = self._core.load_login_status(
status_path, loginCallback=self._on_login, exitCallback=self._on_exit
)
if result.get('BaseResponse', {}).get('Ret') != 0:
self._startup_error = (
f'Cached WeChat session at {status_path} is invalid. '
'Please scan the QR code in the bot config page again.'
)
self._set_connection_status('error', self._startup_error)
self._log_sync(self._startup_error, 'error')
self._logged_in.set()
return
# Session loaded, start message loop
self._log_sync(f'WeChat session loaded from cache: {status_path}')
# Clear stale messages that itchat fetched during hot-reload
self._drain_msglist()
self._core.run(blockThread=True)
self._set_connection_status('disconnected')
self._log_sync('itchat message loop stopped', 'error')
except Exception as e:
error = f'itchat run error: {e}'
self._set_connection_status('error', error)
self._log_sync(error, 'error')
self._logged_in.set()
self._itchat_thread = threading.Thread(target=_run_itchat, daemon=True, name='itchat-thread')
self._itchat_thread.start()
# Wait for login to complete (with timeout)
await asyncio.get_event_loop().run_in_executor(None, lambda: self._logged_in.wait(timeout=300))
if not self._logged_in.is_set():
raise RuntimeError('itchat login timed out (300s)')
if self._startup_error:
raise RuntimeError(self._startup_error)
await self.logger.info(f'itchat adapter running, bot: {self.bot_account_id}')
# Keep the adapter alive
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
async def kill(self) -> bool:
"""Stop the itchat adapter."""
try:
self._core.alive = False
self._core.isLogging = False
except Exception:
pass
self._set_connection_status('disconnected')
await self.logger.info('itchat adapter stopped')
return True
@@ -1,75 +0,0 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: itchat
label:
en_US: Itchat WeChat
zh_Hans: 个人微信 (itchat)
zh_Hant: 個人微信 (itchat)
ja_JP: 個人WeChat (itchat)
description:
en_US: Personal WeChat adapter via itchat-uos, supports QR code login and text/image/voice messages
zh_Hans: 基于 itchat-uos 的个人微信适配器,扫码登录,支持文本/图片/语音消息
zh_Hant: 基於 itchat-uos 的個人微信適配器,掃碼登入,支援文字/圖片/語音訊息
icon: wechat.png
spec:
categories:
- china
help_links:
zh: https://github.com/littlecodersh/ItChat
en: https://github.com/littlecodersh/ItChat
config:
- name: qr-login
label:
en_US: Scan QR Login
zh_Hans: 扫码登录
zh_Hant: 掃碼登入
description:
en_US: Scan QR code with WeChat to login. The session will be cached for the adapter to reuse.
zh_Hans: 使用微信扫码登录,登录状态将被缓存供适配器复用
zh_Hant: 使用微信掃碼登入,登入狀態將被快取供適配器復用
type: qr-code-login
login_platform: itchat
required: false
- name: account_id
label:
en_US: Bot Account ID
zh_Hans: 机器人账号标识
zh_Hant: 機器人帳號標識
ja_JP: ボットアカウントID
description:
en_US: Auto-filled after QR login with the WeChat wxid. Used for @-mention matching and to load data/itchat/<account_id>.pkl; do not change it to a nickname.
zh_Hans: 扫码登录后自动填入微信 wxid。用于群聊 @ 匹配,并加载 data/itchat/<account_id>.pkl;不要改成昵称。
zh_Hant: 掃碼登入後自動填入微信 wxid。用於群聊 @ 匹配,並載入 data/itchat/<account_id>.pkl;不要改成暱稱。
ja_JP: QRログイン後にWeChatのwxidが自動入力されます。@メンション判定と data/itchat/<account_id>.pkl の読み込みに使うため、ニックネームへ変更しないでください。
type: string
required: true
default: ""
- name: nickname
label:
en_US: Bot Nickname
zh_Hans: 机器人昵称
zh_Hant: 機器人暱稱
description:
en_US: The display nickname of the bot. Used to strip @nickname from incoming group messages. Auto-filled after QR login.
zh_Hans: 机器人的微信昵称。用于删除群聊消息中的 @昵称 前缀。扫码登录后自动填入
zh_Hant: 機器人的微信暱稱。用於刪除群聊訊息中的 @暱稱 前綴。掃碼登入後自動填入
type: string
required: false
default: ""
- name: hot_reload
label:
en_US: Hot Reload
zh_Hans: 登录缓存
zh_Hant: 登入快取
description:
en_US: Persist login session to avoid repeated QR code scans on restart
zh_Hans: 保存登录状态到本地,重启后无需重新扫码
zh_Hant: 儲存登入狀態到本機,重啟後無需重新掃碼
type: boolean
required: false
default: true
execution:
python:
path: ./itchat.py
attr: ItchatAdapter
+1 -6
View File
@@ -1962,16 +1962,11 @@ class RuntimeConnectionHandler(handler.Handler):
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
action_context = ActionContext(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
)
result = await self.call_action(
LangBotToRuntimeAction.GET_DEBUG_INFO,
{},
timeout=10,
action_context=action_context,
action_context=execution_context,
)
return result
+2 -3
View File
@@ -328,9 +328,8 @@ box:
enabled: true
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
runtime:
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
# runtimes. To protect an exposed endpoint, set the same strong secret
# in both LangBot and Box. Keep it out of this config file.
# External WebSocket runtimes also require LANGBOT_BOX_CONTROL_TOKEN in
# both LangBot and Box. Keep the shared secret out of this config file.
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
limits:
max_sessions: 64
+1 -12
View File
@@ -235,24 +235,13 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api
assert operator_denied.status_code == 403
assert allowed.status_code == 200
assert (await allowed.get_json())['data'] == {
'debug_url': 'ws://localhost:5401/plugin/debug/ws',
'debug_url': 'http://localhost:5401',
'plugin_debug_key': 'runtime-debug-secret',
'expires_at': '2026-08-04T12:00:00Z',
}
application.plugin_connector.get_debug_info.assert_awaited_once()
@pytest.mark.asyncio
async def test_debug_info_uses_websocket_endpoint_for_legacy_config(plugin_security_api):
application, client, _ = plugin_security_api
application.instance_config.data['plugin'].pop('display_plugin_debug_url')
response = await client.get('/api/v1/plugins/debug-info', headers=_headers('manager-token'))
assert response.status_code == 200
assert (await response.get_json())['data']['debug_url'] == 'ws://localhost:5401/plugin/debug/ws'
@pytest.mark.asyncio
async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api):
application, client, _ = plugin_security_api
+1 -10
View File
@@ -306,19 +306,10 @@ def test_box_runtime_connector_rejects_relay_context_from_other_instance(
)
def test_external_box_runtime_control_headers_are_tokenless_when_secret_is_unset(
monkeypatch: pytest.MonkeyPatch,
):
def test_external_box_runtime_fails_closed_without_control_token(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
assert connector.get_control_headers() == {BOX_INSTANCE_HEADER: 'instance-a'}
def test_external_box_runtime_rejects_invalid_configured_control_token(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, 'too-short')
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
connector.get_control_headers()
@@ -1,130 +0,0 @@
"""Tests for itchat adapter group/private message conversion."""
from __future__ import annotations
from types import SimpleNamespace
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources.itchat import ItchatAdapter, ItchatEventConverter
def _make_adapter(bot_account_id: str = '@bot_wxid', bot_nickname: str = 'MyBot'):
adapter = SimpleNamespace(
bot_account_id=bot_account_id,
_bot_nickname=bot_nickname,
_core=SimpleNamespace(storageClass=SimpleNamespace(userName=bot_account_id)),
_get_obj_value=ItchatAdapter._get_obj_value,
)
return adapter
def _make_converter(adapter) -> ItchatEventConverter:
return ItchatEventConverter(adapter_ref=lambda: adapter)
def test_group_text_becomes_group_message():
converter = _make_converter(_make_adapter())
msg = {
'FromUserName': '@@group_wxid',
'Type': 'Text',
'Text': 'hello',
'ActualUserName': '@member_wxid',
'ActualNickName': 'MemberNick',
'IsAt': False,
'CreateTime': 123456,
'User': SimpleNamespace(NickName='Group Name'),
}
event = converter.target2yiri(msg)
assert isinstance(event, platform_events.GroupMessage)
assert isinstance(event.sender, platform_entities.GroupMember)
assert event.sender.id == '@member_wxid'
assert event.sender.member_name == 'MemberNick'
assert event.sender.group.id == '@@group_wxid'
assert event.sender.group.name == 'Group Name'
components = list(event.message_chain)
assert len(components) == 1
assert isinstance(components[0], platform_message.Plain)
assert components[0].text == 'hello'
def test_private_text_becomes_friend_message():
converter = _make_converter(_make_adapter())
msg = {
'FromUserName': '@friend_wxid',
'Type': 'Text',
'Text': 'hi',
'CreateTime': 123456,
'User': SimpleNamespace(NickName='FriendNick', RemarkName='FriendRemark'),
}
event = converter.target2yiri(msg)
assert isinstance(event, platform_events.FriendMessage)
assert isinstance(event.sender, platform_entities.Friend)
assert event.sender.id == '@friend_wxid'
assert event.sender.nickname == 'FriendNick'
assert event.sender.remark == 'FriendRemark'
def test_bot_own_message_is_ignored():
converter = _make_converter(_make_adapter(bot_account_id='@bot_wxid'))
msg = {
'FromUserName': '@bot_wxid',
'Type': 'Text',
'Text': 'self echo',
}
assert converter.target2yiri(msg) is None
def test_group_at_bot_strips_prefix_and_adds_at():
converter = _make_converter(_make_adapter(bot_account_id='@bot_wxid', bot_nickname='MyBot'))
msg = {
'FromUserName': '@@group_wxid',
'Type': 'Text',
'Text': '@MyBothello world',
'ActualUserName': '@member_wxid',
'ActualNickName': 'MemberNick',
'IsAt': True,
'CreateTime': 123456,
'User': SimpleNamespace(NickName='Group Name'),
}
event = converter.target2yiri(msg)
assert isinstance(event, platform_events.GroupMessage)
components = list(event.message_chain)
assert len(components) == 2
assert isinstance(components[0], platform_message.At)
assert components[0].target == '@bot_wxid'
assert isinstance(components[1], platform_message.Plain)
assert components[1].text == 'hello world'
def test_group_message_without_sender_is_ignored():
converter = _make_converter(_make_adapter())
msg = {
'FromUserName': '@@group_wxid',
'Type': 'Text',
'Text': 'system note',
'ActualUserName': '',
'ActualNickName': '',
'IsAt': False,
'CreateTime': 123456,
'User': SimpleNamespace(NickName='Group Name'),
}
assert converter.target2yiri(msg) is None
@@ -444,23 +444,3 @@ async def test_host_to_runtime_action_carries_trusted_connector_context():
'runtime_id': 'runtime-a',
}
assert request.get('context') is None
@pytest.mark.asyncio
async def test_get_debug_info_converts_execution_context_to_sdk_action_context():
runtime_handler, _app, _installation_context = make_handler()
runtime_handler.call_action = AsyncMock(return_value={'plugin_debug_key': 'debug-key'})
execution_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
)
result = await runtime_handler.get_debug_info(execution_context)
assert result == {'plugin_debug_key': 'debug-key'}
assert runtime_handler.call_action.await_args.kwargs['action_context'] == ActionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
)
Generated
+62 -83
View File
@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.11, <4.0"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -1018,7 +1018,7 @@ name = "cuda-bindings"
version = "13.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "cuda-pathfinder" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
@@ -1051,34 +1051,34 @@ wheels = [
[package.optional-dependencies]
cudart = [
{ name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-runtime" },
]
cufft = [
{ name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cufft" },
]
cufile = [
{ name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cufile" },
]
cupti = [
{ name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-cupti" },
]
curand = [
{ name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-curand" },
]
cusolver = [
{ name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cusolver" },
]
cusparse = [
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cusparse" },
]
nvjitlink = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nvjitlink" },
]
nvrtc = [
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-nvrtc" },
]
nvtx = [
{ name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nvtx" },
]
[[package]]
@@ -1814,19 +1814,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "itchat-uos"
version = "1.5.0.dev0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pypng" },
{ name = "pyqrcode" },
{ name = "requests" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/2b/0be7e46195dc3c461518b046a6fb9c34c97cd2fd44847b840a0011686575/itchat_uos-1.5.0.dev0-py3-none-any.whl", hash = "sha256:0293b77cab31fa8c9c2144ea8b5636d43836f47855beba0c603ad3fb1ff89625", size = 52507, upload-time = "2022-07-14T02:38:32.507Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
@@ -2048,7 +2035,6 @@ dependencies = [
{ name = "ebooklib" },
{ name = "gewechat-client" },
{ name = "html2text" },
{ name = "itchat-uos" },
{ name = "langbot-plugin" },
{ name = "langchain" },
{ name = "langchain-core" },
@@ -2139,8 +2125,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "itchat-uos", specifier = ">=1.5.0.dev0" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=9d216208cdfb41f0cb7fcb64632e2a46816d6dc6" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2206,8 +2191,8 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.1"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3#7b559da430a50f80a7d30c9d3d66f088503ddbb3" }
version = "0.5.0"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=9d216208cdfb41f0cb7fcb64632e2a46816d6dc6#9d216208cdfb41f0cb7fcb64632e2a46816d6dc6" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -3253,7 +3238,7 @@ name = "nvidia-cublas"
version = "13.1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cuda-nvrtc" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
@@ -3292,7 +3277,7 @@ name = "nvidia-cudnn-cu13"
version = "9.20.0.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cublas" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
@@ -3304,7 +3289,7 @@ name = "nvidia-cufft"
version = "12.0.0.61"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
@@ -3334,9 +3319,9 @@ name = "nvidia-cusolver"
version = "12.0.4.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cublas" },
{ name = "nvidia-cusparse" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
@@ -3348,7 +3333,7 @@ name = "nvidia-cusparse"
version = "12.6.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
@@ -4532,12 +4517,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
]
[[package]]
name = "pyqrcode"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/37/61/f07226075c347897937d4086ef8e55f0a62ae535e28069884ac68d979316/PyQRCode-1.2.1.tar.gz", hash = "sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5", size = 36989, upload-time = "2016-06-20T03:28:03.411Z" }
[[package]]
name = "pyreadline3"
version = "3.5.4"
@@ -5184,10 +5163,10 @@ name = "scikit-learn"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" },
{ name = "joblib" },
{ name = "numpy" },
{ name = "scipy" },
{ name = "threadpoolctl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [
@@ -5234,7 +5213,7 @@ name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
@@ -5305,14 +5284,14 @@ name = "sentence-transformers"
version = "5.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "torch", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "transformers", marker = "python_full_version >= '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "scikit-learn" },
{ name = "scipy" },
{ name = "torch" },
{ name = "tqdm" },
{ name = "transformers" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
wheels = [
@@ -5685,21 +5664,21 @@ name = "torch"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "filelock", marker = "python_full_version >= '3.14'" },
{ name = "fsspec", marker = "python_full_version >= '3.14'" },
{ name = "jinja2", marker = "python_full_version >= '3.14'" },
{ name = "networkx", marker = "python_full_version >= '3.14'" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "setuptools", marker = "python_full_version >= '3.14'" },
{ name = "sympy", marker = "python_full_version >= '3.14'" },
{ name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
{ name = "cuda-bindings", marker = "sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx" },
{ name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "triton", marker = "sys_platform == 'linux'" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
@@ -5741,15 +5720,15 @@ name = "transformers"
version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "packaging", marker = "python_full_version >= '3.14'" },
{ name = "pyyaml", marker = "python_full_version >= '3.14'" },
{ name = "regex", marker = "python_full_version >= '3.14'" },
{ name = "safetensors", marker = "python_full_version >= '3.14'" },
{ name = "tokenizers", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "typer", marker = "python_full_version >= '3.14'" },
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "regex" },
{ name = "safetensors" },
{ name = "tokenizers" },
{ name = "tqdm" },
{ name = "typer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
wheels = [
@@ -6010,9 +5989,9 @@ name = "valkey-glide"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'win32'" },
{ name = "protobuf", marker = "sys_platform != 'win32'" },
{ name = "sniffio", marker = "sys_platform != 'win32'" },
{ name = "anyio" },
{ name = "protobuf" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
wheels = [
+9 -87
View File
@@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
@@ -27,79 +26,9 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses
import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import {
Settings,
FileText,
Users,
RefreshCw,
Trash2,
CircleCheck,
CircleAlert,
Loader2,
CircleOff,
} from 'lucide-react';
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import type { Bot, BotAdapterRuntimeStatus } from '@/app/infra/entities/api';
function getBotRuntimeStatus(bot: Bot | null): BotAdapterRuntimeStatus | null {
return bot?.adapter_runtime_values?.runtime_status ?? null;
}
function RuntimeStatusBadge({
status,
}: {
status: BotAdapterRuntimeStatus | null;
}) {
const { t } = useTranslation();
if (!status) return null;
const value = status?.connection_status ?? 'disconnected';
const config = {
connected: {
label: t('bots.runtimeConnected'),
className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700',
icon: CircleCheck,
},
connecting: {
label: t('bots.runtimeConnecting'),
className: 'border-amber-500/30 bg-amber-500/10 text-amber-700',
icon: Loader2,
},
disconnected: {
label: t('bots.runtimeDisconnected'),
className: 'border-muted-foreground/20 bg-muted text-muted-foreground',
icon: CircleOff,
},
error: {
label: t('bots.runtimeError'),
className: 'border-destructive/30 bg-destructive/10 text-destructive',
icon: CircleAlert,
},
}[value];
const Icon = config.icon;
return (
<div className="flex min-w-0 items-center gap-2">
<Badge
variant="outline"
className={cn('h-6 gap-1.5 px-2 text-xs', config.className)}
>
<Icon
className={cn('size-3.5', value === 'connecting' && 'animate-spin')}
/>
{config.label}
</Badge>
{status?.connection_error && (
<span className="max-w-[360px] truncate text-xs text-destructive">
{status.connection_error}
</span>
)}
</div>
);
}
import { useCurrentWorkspace } from '@/app/infra/http';
export default function BotDetailContent({ id }: { id: string }) {
@@ -135,24 +64,16 @@ export default function BotDetailContent({ id }: { id: string }) {
// Enable state managed here so the header switch works
const [botEnabled, setBotEnabled] = useState(true);
const [enableLoaded, setEnableLoaded] = useState(false);
const [botDetail, setBotDetail] = useState<Bot | null>(null);
const fetchBotDetail = useCallback(async () => {
if (isCreateMode) return;
const res = await httpClient.getBot(id);
setBotDetail(res.bot);
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
}, [id, isCreateMode]);
// Fetch bot enable state
useEffect(() => {
if (!isCreateMode) {
fetchBotDetail();
const timer = window.setInterval(fetchBotDetail, 5000);
return () => window.clearInterval(timer);
httpClient.getBot(id).then((res) => {
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
});
}
}, [fetchBotDetail, isCreateMode]);
}, [id, isCreateMode]);
const handleEnableToggle = useCallback(
async (checked: boolean) => {
@@ -180,7 +101,9 @@ export default function BotDetailContent({ id }: { id: string }) {
function handleFormSubmit() {
// Re-sync enable state after form save (form may update enable too)
fetchBotDetail();
httpClient.getBot(id).then((res) => {
setBotEnabled(res.bot.enable ?? true);
});
refreshBots();
}
@@ -261,7 +184,6 @@ export default function BotDetailContent({ id }: { id: string }) {
</Label>
</div>
)}
<RuntimeStatusBadge status={getBotRuntimeStatus(botDetail)} />
</div>
{canManage && (
<Button
@@ -688,10 +688,7 @@ export default function DynamicFormComponent({
onSuccess={(credentials) => {
for (const [key, value] of Object.entries(credentials)) {
if (value) {
form.setValue(key as keyof FormValues, value as never, {
shouldDirty: true,
shouldValidate: true,
});
form.setValue(key as keyof FormValues, value as never);
}
}
}}
@@ -31,14 +31,27 @@ const getFormSchema = (t: (key: string) => string) =>
api_key: z.string().optional(),
});
export interface ProviderFormInitialValues {
name?: string;
requester?: string;
base_url?: string;
api_key?: string;
}
interface ProviderFormProps {
providerId?: string;
onFormSubmit: () => void;
initialValues?: ProviderFormInitialValues;
onValuesChange?: (values: ProviderFormInitialValues) => void;
submitButtonText?: string;
onFormSubmit: (providerUuid?: string) => void;
onFormCancel: () => void;
}
export default function ProviderForm({
providerId,
initialValues,
onValuesChange,
submitButtonText,
onFormSubmit,
onFormCancel,
}: ProviderFormProps) {
@@ -48,13 +61,22 @@ export default function ProviderForm({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
requester: '',
base_url: '',
api_key: '',
name: initialValues?.name ?? '',
requester: initialValues?.requester ?? '',
base_url: initialValues?.base_url ?? '',
api_key: initialValues?.api_key ?? '',
},
});
const { setValue } = form;
const { setValue, watch } = form;
// Report form values changes to parent
useEffect(() => {
if (!onValuesChange) return;
const subscription = watch((values) => {
onValuesChange(values as ProviderFormInitialValues);
});
return () => subscription.unsubscribe();
}, [watch, onValuesChange]);
const [requesterList, setRequesterList] = useState<
{
@@ -174,11 +196,12 @@ export default function ProviderForm({
if (providerId) {
await httpClient.updateModelProvider(providerId, data);
toast.success(t('models.providerSaved'));
onFormSubmit();
} else {
await httpClient.createModelProvider(data);
const resp = await httpClient.createModelProvider(data);
toast.success(t('models.providerCreated'));
onFormSubmit(resp.uuid);
}
onFormSubmit();
} catch (err) {
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
}
@@ -378,7 +401,7 @@ export default function ProviderForm({
/>
<DialogFooter>
<Button type="submit">{t('common.save')}</Button>
<Button type="submit">{submitButtonText || t('common.save')}</Button>
<Button type="button" variant="outline" onClick={onFormCancel}>
{t('common.cancel')}
</Button>
@@ -15,14 +15,12 @@ import {
XCircle,
} from 'lucide-react';
import QRCode from 'qrcode';
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
export type QrLoginPlatform =
| 'feishu'
| 'weixin'
| 'dingtalk'
| 'wecombot'
| 'itchat'
| 'qqofficial';
interface PlatformConfig {
@@ -57,12 +55,12 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
},
weixin: {
titleKey: 'weixin.scanLogin',
connectingKey: 'weixin.connecting',
connectingKey: 'feishu.connecting',
scanQRCodeKey: 'weixin.scanQRCode',
waitingKey: 'weixin.waitingForScan',
waitingKey: 'feishu.waitingForScan',
successKey: 'weixin.loginSuccess',
failedKey: 'weixin.loginFailed',
retryKey: 'weixin.retry',
retryKey: 'feishu.retry',
apiBase: '/api/v1/platform/adapters/weixin/login',
extractSuccess: (data) => ({
token: data.token,
@@ -100,20 +98,6 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
}),
successNoteKey: 'wecombot.robotNameNote',
},
itchat: {
titleKey: 'itchat.scanLogin',
connectingKey: 'itchat.connecting',
scanQRCodeKey: 'itchat.scanQRCode',
waitingKey: 'itchat.waitingForScan',
successKey: 'itchat.loginSuccess',
failedKey: 'itchat.loginFailed',
retryKey: 'itchat.retry',
apiBase: '/api/v1/platform/adapters/itchat/login',
extractSuccess: (data) => ({
account_id: data.wxid || '',
nickname: data.nickname || '',
}),
},
qqofficial: {
titleKey: 'qqofficial.createBinding',
connectingKey: 'qqofficial.connecting',
@@ -154,7 +138,6 @@ export default function QrCodeLoginDialog({
const [state, setState] = useState<DialogState>('connecting');
const [qrDataUrl, setQrDataUrl] = useState('');
const qrDataUrlRef = useRef('');
const [expireIn, setExpireIn] = useState(0);
const [errorMessage, setErrorMessage] = useState('');
const [successMeta, setSuccessMeta] = useState('');
@@ -163,8 +146,6 @@ export default function QrCodeLoginDialog({
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | null>(null);
const sessionWorkspaceUuidRef = useRef<string | null>(null);
const sessionApiBaseRef = useRef('');
const baseUrlRef = useRef('');
const cleanedRef = useRef(false);
@@ -199,23 +180,18 @@ export default function QrCodeLoginDialog({
}
if (sessionIdRef.current) {
const token = localStorage.getItem('token');
const workspaceUuid = sessionWorkspaceUuidRef.current;
const baseUrl =
import.meta.env.VITE_API_BASE_URL || window.location.origin;
fetch(
`${baseUrlRef.current}${sessionApiBaseRef.current}/${sessionIdRef.current}`,
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
headers: { Authorization: `Bearer ${token}` },
keepalive: true,
},
).catch(() => {});
sessionIdRef.current = null;
}
sessionWorkspaceUuidRef.current = null;
sessionApiBaseRef.current = '';
baseUrlRef.current = '';
}, []);
const startLogin = useCallback(async () => {
@@ -223,13 +199,11 @@ export default function QrCodeLoginDialog({
cleanedRef.current = false;
setState('connecting');
setQrDataUrl('');
qrDataUrlRef.current = '';
setExpireIn(0);
setErrorMessage('');
setSuccessMeta('');
const token = localStorage.getItem('token');
const workspaceUuid = getActiveWorkspaceUuid();
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
baseUrlRef.current = baseUrl;
const cfg = platformConfigRef.current;
@@ -240,10 +214,7 @@ export default function QrCodeLoginDialog({
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
@@ -254,19 +225,15 @@ export default function QrCodeLoginDialog({
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
sessionIdRef.current = session_id;
sessionWorkspaceUuidRef.current = workspaceUuid;
sessionApiBaseRef.current = cfg.apiBase;
if (qr_data_url) {
setQrDataUrl(qr_data_url);
qrDataUrlRef.current = qr_data_url;
} else if (qr_url) {
const dataUrl = await QRCode.toDataURL(qr_url, {
width: 224,
margin: 2,
});
setQrDataUrl(dataUrl);
qrDataUrlRef.current = dataUrl;
}
setState('waiting');
@@ -303,19 +270,11 @@ export default function QrCodeLoginDialog({
`${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid
? { 'X-Workspace-Id': workspaceUuid }
: {}),
},
headers: { Authorization: `Bearer ${token}` },
keepalive: true,
},
).catch(() => {});
sessionIdRef.current = null;
sessionWorkspaceUuidRef.current = null;
sessionApiBaseRef.current = '';
baseUrlRef.current = '';
}
setState('expired');
}
@@ -327,12 +286,7 @@ export default function QrCodeLoginDialog({
try {
const pollRes = await fetch(
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
{
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!pollRes.ok) return;
@@ -366,19 +320,6 @@ export default function QrCodeLoginDialog({
cleanup();
setExpireIn(0);
setState('expired');
} else if (status === 'waiting') {
// Update QR data URL if regenerated (e.g. itchat QR expiry)
if (rest.qr_data_url && rest.qr_data_url !== qrDataUrlRef.current) {
setQrDataUrl(rest.qr_data_url);
qrDataUrlRef.current = rest.qr_data_url;
}
if (rest.expire_at) {
const remaining = Math.max(
0,
Math.floor(rest.expire_at - Date.now() / 1000),
);
setExpireIn(remaining);
}
}
} catch {
// ignore poll errors
+1 -23
View File
@@ -203,28 +203,6 @@ export interface ApiRespPlatformBot {
bot: Bot;
}
export type BotAdapterConnectionStatus =
| 'connecting'
| 'connected'
| 'disconnected'
| 'error';
export interface BotAdapterRuntimeStatus {
connection_status?: BotAdapterConnectionStatus;
connection_error?: string;
last_connected_at?: number | null;
last_disconnected_at?: number | null;
}
export interface BotAdapterRuntimeValues {
bot_account_id?: string;
webhook_url?: string | null;
webhook_full_url?: string | null;
extra_webhook_full_url?: string | null;
runtime_status?: BotAdapterRuntimeStatus;
[key: string]: unknown;
}
export interface Bot {
uuid?: string;
name: string;
@@ -237,7 +215,7 @@ export interface Bot {
pipeline_routing_rules?: PipelineRoutingRule[];
created_at?: string;
updated_at?: string;
adapter_runtime_values?: BotAdapterRuntimeValues;
adapter_runtime_values?: object;
}
export type RoutingRuleOperator =
+700 -88
View File
@@ -12,6 +12,8 @@ import {
Loader2,
X,
ExternalLink,
Rocket,
Wrench,
} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -38,6 +40,7 @@ import {
parseDynamicFormItemType,
} from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import ProviderForm from '@/app/home/components/models-dialog/component/provider-form/ProviderForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import { extractI18nObject } from '@/i18n/I18nProvider';
import {
@@ -48,6 +51,7 @@ import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import i18n from 'i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
@@ -67,6 +71,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -92,6 +97,40 @@ export default function WizardPage() {
{},
);
const [runnerConfig, setRunnerConfig] = useState<Record<string, unknown>>({});
const [aiEngineMode, setAiEngineMode] = useState<
'orchestrated' | 'llm' | null
>(null);
const [modelSource, setModelSource] = useState<'space' | 'custom' | null>(
null,
);
const [providerCreated, setProviderCreated] = useState(false);
const [createdProviderUuid, setCreatedProviderUuid] = useState<string | null>(
null,
);
const [modelsAdded, setModelsAdded] = useState(false);
// Sub-layer within Step 2: 0=main, 1=sub-choice, 2=provider, 3=scan, 4=config
const [step2Layer, setStep2Layer] = useState(0);
const [maxReachedLayer, setMaxReachedLayer] = useState(0);
const goToStep2Layer = useCallback(
(layer: number) => {
setStep2Layer(layer);
setMaxReachedLayer((prev) => Math.max(prev, layer));
},
[],
);
// Saved provider form data for back navigation
const [savedProviderForm, setSavedProviderForm] = useState<{
name?: string;
requester?: string;
base_url?: string;
api_key?: string;
}>({});
// Saved selected scanned models for back navigation
const [savedSelectedModels, setSavedSelectedModels] = useState<Set<string>>(
new Set(),
);
const [createdBotUuid, setCreatedBotUuid] = useState<string | null>(null);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
@@ -273,11 +312,28 @@ export default function WizardPage() {
const handleSelectRunner = useCallback(
(runner: string) => {
setSelectedRunner(runner);
goToStep2Layer(4);
saveProgress({ step: 2, selected_runner: runner });
},
[saveProgress],
);
const handleAiEngineModeSelect = useCallback(
(mode: 'orchestrated' | 'llm') => {
setAiEngineMode(mode);
setSelectedRunner(null);
setModelSource(null);
setProviderCreated(false);
setCreatedProviderUuid(null);
setModelsAdded(false);
setSavedProviderForm({});
setSavedSelectedModels(new Set());
setMaxReachedLayer(0);
goToStep2Layer(1);
},
[goToStep2Layer],
);
// ---- Navigation helpers ----
const canProceed = useCallback((): boolean => {
@@ -287,27 +343,53 @@ export default function WizardPage() {
case 1:
return createdBotUuid !== null && botSaved;
case 2:
return selectedRunner !== null;
if (aiEngineMode === 'orchestrated') {
return selectedRunner !== null;
}
if (aiEngineMode === 'llm') {
return (
modelSource === 'space' ||
(modelSource === 'custom' && modelsAdded)
);
}
return false;
default:
return false;
}
}, [currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner]);
}, [
currentStep,
selectedAdapter,
createdBotUuid,
botSaved,
aiEngineMode,
selectedRunner,
modelSource,
modelsAdded,
]);
const goNext = useCallback(() => {
if (currentStep === 2 && step2Layer < maxReachedLayer) {
setStep2Layer(maxReachedLayer);
return;
}
if (currentStep < TOTAL_STEPS - 1 && canProceed()) {
const nextStep = currentStep + 1;
setCurrentStep(nextStep);
saveProgress({ step: nextStep });
}
}, [currentStep, canProceed, saveProgress]);
}, [currentStep, step2Layer, maxReachedLayer, canProceed, saveProgress]);
const goPrev = useCallback(() => {
if (currentStep === 2 && step2Layer > 0) {
setStep2Layer(step2Layer - 1);
return;
}
if (currentStep > 0) {
const prevStep = currentStep - 1;
setCurrentStep(prevStep);
saveProgress({ step: prevStep });
}
}, [currentStep, saveProgress]);
}, [currentStep, step2Layer, saveProgress]);
// ---- Create Bot (Step 0) ----
// Creates a disabled bot using the adapter label as name.
@@ -503,6 +585,22 @@ export default function WizardPage() {
}
}, [t]);
const handleModelSourceSelect = useCallback(
(source: 'space' | 'custom') => {
setModelSource(source);
if (source === 'space') {
handleSpaceAuth();
} else if (providerCreated && modelsAdded) {
goToStep2Layer(4);
} else if (providerCreated) {
goToStep2Layer(3);
} else {
goToStep2Layer(2);
}
},
[handleSpaceAuth, providerCreated, modelsAdded],
);
// ---- Check if local account ----
// Re-evaluated after remote data fetch (when userInfo is populated)
const isLocalAccount =
@@ -660,10 +758,41 @@ export default function WizardPage() {
{currentStep === 2 && (
<StepAIEngine
runnerOptions={runnerOptions}
selected={selectedRunner}
onSelect={handleSelectRunner}
step2Layer={step2Layer}
aiEngineMode={aiEngineMode}
onAiEngineModeSelect={handleAiEngineModeSelect}
selectedRunner={selectedRunner}
onSelectRunner={handleSelectRunner}
isLocalAccount={isLocalAccount}
onSpaceAuth={handleSpaceAuth}
modelSource={modelSource}
onModelSourceSelect={handleModelSourceSelect}
providerCreated={providerCreated}
createdProviderUuid={createdProviderUuid}
modelsAdded={modelsAdded}
onProviderCreated={(uuid) => {
setProviderCreated(true);
setCreatedProviderUuid(uuid ?? null);
goToStep2Layer(3);
}}
onModelsAdded={() => {
setModelsAdded(true);
setSelectedRunner('local-agent');
goToStep2Layer(4);
saveProgress({ step: 2, selected_runner: 'local-agent' });
}}
onResetModelSource={() => setModelSource(null)}
savedProviderForm={savedProviderForm}
onSavedProviderFormChange={setSavedProviderForm}
savedSelectedModels={savedSelectedModels}
onSavedSelectedModelsChange={setSavedSelectedModels}
onGoToNextLayer={() => {
if (modelsAdded) {
goToStep2Layer(4);
} else {
goToStep2Layer(3);
}
}}
runnerConfigItems={selectedRunnerConfigItems}
runnerConfigValues={runnerConfig}
onRunnerConfigChange={setRunnerConfig}
@@ -998,25 +1127,354 @@ function StepBotConfig({
);
}
// ---------------------------------------------------------------------------
// Model scan sub-component (used within Step 2)
// ---------------------------------------------------------------------------
function WizardModelScan({
providerUuid,
initialSelectedModels,
onSelectedModelsChange,
onModelsAdded,
}: {
providerUuid: string | null;
initialSelectedModels: Set<string>;
onSelectedModelsChange: (v: Set<string>) => void;
onModelsAdded: () => void;
}) {
const { t } = useTranslation();
const [scanning, setScanning] = useState(false);
const [scannedModels, setScannedModels] = useState<
{ name: string; context_length?: number | null }[]
>([]);
const [selectedModels, setSelectedModels] = useState<Set<string>>(
initialSelectedModels,
);
const [adding, setAdding] = useState(false);
const [scanDone, setScanDone] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [manualModelName, setManualModelName] = useState('');
const [manualAdding, setManualAdding] = useState(false);
const [addedModelNames, setAddedModelNames] = useState<Set<string>>(
new Set(initialSelectedModels),
);
// Auto-scan on mount
useEffect(() => {
if (!providerUuid) return;
let cancelled = false;
(async () => {
setScanning(true);
try {
const resp = await httpClient.scanProviderModels(
providerUuid,
'llm',
);
if (!cancelled) {
setScannedModels(resp.models ?? []);
setScanDone(true);
}
} catch {
if (!cancelled) setScanDone(true);
} finally {
if (!cancelled) setScanning(false);
}
})();
return () => {
cancelled = true;
};
}, [providerUuid]);
const filteredModels = searchQuery
? scannedModels.filter((m) =>
m.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: scannedModels;
const toggleModel = (name: string) => {
setSelectedModels((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
onSelectedModelsChange(next);
return next;
});
};
const getExistingModelNames = async (): Promise<Set<string>> => {
if (!providerUuid) return new Set();
try {
const resp = await httpClient.getProviderLLMModels(providerUuid);
return new Set((resp.models ?? []).map((m: { name: string }) => m.name));
} catch {
return new Set();
}
};
const handleAddSelected = async () => {
if (!providerUuid || selectedModels.size === 0) return;
setAdding(true);
try {
const existing = await getExistingModelNames();
let added = 0;
for (const name of selectedModels) {
if (existing.has(name)) continue;
const model = scannedModels.find((m) => m.name === name);
await httpClient.createProviderLLMModel({
name,
provider_uuid: providerUuid,
abilities: (model as { abilities?: string[] })?.abilities || ['llm'],
reasoning_config: { level: 'provider_default' },
context_length: model?.context_length ?? null,
extra_args: {},
} as never);
existing.add(name);
added++;
}
if (added > 0) {
toast.success(t('wizard.provider.modelsAdded', { count: added }));
}
setAddedModelNames((prev) => {
const next = new Set(prev);
for (const name of selectedModels) next.add(name);
return next;
});
setSelectedModels(new Set());
onSelectedModelsChange(new Set());
} catch {
toast.error(t('wizard.provider.modelsAddError'));
} finally {
setAdding(false);
}
};
const handleManualAdd = async () => {
const name = manualModelName.trim();
if (!providerUuid || !name) return;
setManualAdding(true);
try {
const existing = await getExistingModelNames();
if (existing.has(name)) {
toast.error(t('wizard.provider.modelAlreadyExists', { name }));
setManualAdding(false);
return;
}
await httpClient.createProviderLLMModel({
name,
provider_uuid: providerUuid,
abilities: ['llm'],
reasoning_config: { level: 'provider_default' },
context_length: null,
extra_args: {},
} as never);
toast.success(t('wizard.provider.modelsAdded', { count: 1 }));
setAddedModelNames((prev) => new Set(prev).add(name));
setManualModelName('');
} catch {
toast.error(t('wizard.provider.modelsAddError'));
} finally {
setManualAdding(false);
}
};
return (
<div className="max-w-2xl mx-auto w-full animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="text-center mb-6">
<h2 className="text-xl font-semibold">
{t('wizard.provider.scanTitle')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.provider.scanDescription')}
</p>
</div>
<div className="border rounded-lg p-6 bg-card space-y-4">
{scanning && (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span>{t('wizard.provider.scanning')}</span>
</div>
)}
{!scanning && scanDone && (
<>
{/* Search input for scanned models */}
{scannedModels.length > 0 && (
<Input
placeholder={t('wizard.provider.searchModels')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mb-2"
/>
)}
{/* Scanned models list */}
{scannedModels.length > 0 ? (
<>
<div className="max-h-48 overflow-y-auto space-y-2">
{filteredModels.map((model) => {
const isAdded = addedModelNames.has(model.name);
return (
<label
key={model.name}
className={cn(
'flex items-center gap-3 p-3 rounded-lg border transition-colors',
isAdded
? 'opacity-50 cursor-not-allowed bg-muted/50'
: 'cursor-pointer hover:bg-accent',
)}
>
<input
type="checkbox"
checked={selectedModels.has(model.name) || isAdded}
disabled={isAdded}
onChange={() => toggleModel(model.name)}
className="w-4 h-4 rounded"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
{model.name}
</span>
{model.context_length && (
<span className="text-xs text-muted-foreground">
ctx: {model.context_length.toLocaleString()}
</span>
)}
</div>
{isAdded && (
<span className="text-xs text-green-600 font-medium shrink-0">
{t('wizard.provider.alreadyAdded')}
</span>
)}
</label>
);
})}
{filteredModels.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
{t('wizard.provider.noMatch')}
</p>
)}
</div>
<div className="flex gap-3 pt-2">
<Button
onClick={handleAddSelected}
disabled={selectedModels.size === 0 || adding}
className="flex-1"
>
{adding && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)}
{t('wizard.provider.addSelected', {
count: selectedModels.size,
})}
</Button>
<Button variant="outline" onClick={onModelsAdded}>
{t('wizard.provider.skipModelAdd')}
</Button>
</div>
</>
) : (
<div className="text-center py-4 text-muted-foreground">
<p>{t('wizard.provider.noModelsFound')}</p>
</div>
)}
{/* Divider */}
<div className="flex items-center gap-3 py-2">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-muted-foreground">
{t('wizard.provider.orDivider')}
</span>
<div className="flex-1 h-px bg-border" />
</div>
{/* Manual add */}
<div className="flex gap-2">
<Input
placeholder={t('wizard.provider.manualModelPlaceholder')}
value={manualModelName}
onChange={(e) => setManualModelName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleManualAdd()}
/>
<Button
onClick={handleManualAdd}
disabled={!manualModelName.trim() || manualAdding}
>
{manualAdding && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)}
{t('wizard.provider.addManual')}
</Button>
</div>
</>
)}
{/* Next button when models have been added */}
{addedModelNames.size > 0 && (
<div className="flex justify-end pt-2 border-t">
<Button onClick={onModelsAdded}>
{t('wizard.next')}
<ArrowRight className="w-4 h-4 ml-1.5" />
</Button>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Step 2: Select & Configure AI Engine
// ---------------------------------------------------------------------------
function StepAIEngine({
runnerOptions,
selected,
onSelect,
step2Layer,
aiEngineMode,
onAiEngineModeSelect,
selectedRunner,
onSelectRunner,
isLocalAccount,
onSpaceAuth,
modelSource,
onModelSourceSelect,
providerCreated,
createdProviderUuid,
modelsAdded,
onProviderCreated,
onModelsAdded,
onResetModelSource,
savedProviderForm,
onSavedProviderFormChange,
savedSelectedModels,
onSavedSelectedModelsChange,
onGoToNextLayer,
runnerConfigItems,
runnerConfigValues,
onRunnerConfigChange,
}: {
runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[];
selected: string | null;
onSelect: (name: string) => void;
step2Layer: number;
aiEngineMode: 'orchestrated' | 'llm' | null;
onAiEngineModeSelect: (mode: 'orchestrated' | 'llm') => void;
selectedRunner: string | null;
onSelectRunner: (name: string) => void;
isLocalAccount: boolean;
onSpaceAuth: () => void;
modelSource: 'space' | 'custom' | null;
onModelSourceSelect: (source: 'space' | 'custom') => void;
providerCreated: boolean;
createdProviderUuid: string | null;
modelsAdded: boolean;
onProviderCreated: (uuid?: string) => void;
onModelsAdded: () => void;
onResetModelSource: () => void;
savedProviderForm: { name?: string; requester?: string; base_url?: string; api_key?: string };
onSavedProviderFormChange: (v: { name?: string; requester?: string; base_url?: string; api_key?: string }) => void;
savedSelectedModels: Set<string>;
onSavedSelectedModelsChange: (v: Set<string>) => void;
onGoToNextLayer: () => void;
runnerConfigItems: IDynamicFormItemSchema[];
runnerConfigValues: Record<string, unknown>;
onRunnerConfigChange: (v: Record<string, unknown>) => void;
@@ -1032,14 +1490,19 @@ function StepAIEngine({
);
const runnerLabel = useMemo(() => {
const r = runnerOptions.find((o) => o.name === selected);
return r ? extractI18nObject(r.label) : (selected ?? '');
}, [runnerOptions, selected]);
const r = runnerOptions.find((o) => o.name === selectedRunner);
return r ? extractI18nObject(r.label) : (selectedRunner ?? '');
}, [runnerOptions, selectedRunner]);
// Before any runner is selected: centered grid layout
if (!selected) {
// Orchestrated runners: everything except local-agent
const orchestratedRunners = runnerOptions.filter(
(o) => o.name !== 'local-agent',
);
// ---- Layer 0: Main choice ----
if (step2Layer === 0) {
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div className="space-y-6 max-w-3xl mx-auto">
<div className="text-center">
<h2 className="text-xl font-semibold">
{t('wizard.aiEngine.title')}
@@ -1048,12 +1511,75 @@ function StepAIEngine({
{t('wizard.aiEngine.description')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{/* Orchestrated Agent Apps */}
<div
className="relative rounded-xl border-2 border-border cursor-pointer transition-all hover:shadow-lg hover:border-primary/50 hover:scale-[1.02]"
onClick={() => onAiEngineModeSelect('orchestrated')}
>
<div className="p-6 flex flex-col items-center gap-4 text-center h-full">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
<Wrench className="w-7 h-7 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">
{t('wizard.aiEngine.orchestrated.title')}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.aiEngine.orchestrated.description')}
</p>
</div>
<Button variant="outline" size="lg" className="w-full">
{t('wizard.aiEngine.orchestrated.action')}
</Button>
</div>
</div>
{/* Direct LLM */}
<div
className="relative rounded-xl border-2 border-border cursor-pointer transition-all hover:shadow-lg hover:border-primary/50 hover:scale-[1.02]"
onClick={() => onAiEngineModeSelect('llm')}
>
<div className="p-6 flex flex-col items-center gap-4 text-center h-full">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
<Rocket className="w-7 h-7 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">
{t('wizard.aiEngine.llm.title')}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.aiEngine.llm.description')}
</p>
</div>
<Button variant="outline" size="lg" className="w-full">
{t('wizard.aiEngine.llm.action')}
</Button>
</div>
</div>
</div>
</div>
);
}
// ---- Layer 1a: Orchestrated runners ----
if (step2Layer === 1 && aiEngineMode === 'orchestrated') {
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div className="text-center">
<h2 className="text-xl font-semibold">
{t('wizard.aiEngine.orchestrated.selectTitle')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.aiEngine.orchestrated.selectDescription')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{runnerOptions.map((opt) => (
{orchestratedRunners.map((opt) => (
<Card
key={opt.name}
className="cursor-pointer transition-all hover:shadow-md hover:border-primary/50"
onClick={() => onSelect(opt.name)}
onClick={() => onSelectRunner(opt.name)}
>
<CardHeader className="flex flex-row items-center gap-3">
<div className="min-w-0 flex-1">
@@ -1072,9 +1598,109 @@ function StepAIEngine({
);
}
// After a runner is selected: left-right split layout
// On mobile (< lg): single column, normal scroll from parent
// On desktop (>= lg): side-by-side with independent scroll per column
// ---- Layer 1b: LLM model source selection ----
if (step2Layer === 1 && aiEngineMode === 'llm') {
return (
<div className="space-y-6 max-w-3xl mx-auto">
<div className="text-center">
<h2 className="text-xl font-semibold">
{t('wizard.modelSource.title')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.modelSource.description')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{/* LangBot Service */}
<div
className="relative rounded-xl border-2 border-border cursor-pointer transition-all hover:shadow-lg hover:border-primary/50 hover:scale-[1.02]"
onClick={() => onModelSourceSelect('space')}
>
<div className="p-6 flex flex-col items-center gap-4 text-center h-full">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
<Rocket className="w-7 h-7 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">
{t('wizard.modelSource.space.title')}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.modelSource.space.description')}
</p>
</div>
<Button variant="outline" size="lg" className="w-full">
{t('wizard.modelSource.space.action')}
</Button>
</div>
</div>
{/* Custom provider */}
<div
className="relative rounded-xl border-2 border-border cursor-pointer transition-all hover:shadow-lg hover:border-primary/50 hover:scale-[1.02]"
onClick={() => onModelSourceSelect('custom')}
>
<div className="p-6 flex flex-col items-center gap-4 text-center h-full">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
<Wrench className="w-7 h-7 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">
{t('wizard.modelSource.custom.title')}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.modelSource.custom.description')}
</p>
</div>
<Button variant="outline" size="lg" className="w-full">
{t('wizard.modelSource.custom.action')}
</Button>
</div>
</div>
</div>
</div>
);
}
// ---- Layer 2: Custom provider creation form ----
if (step2Layer === 2) {
return (
<div className="max-w-2xl mx-auto w-full animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="text-center mb-6">
<h2 className="text-xl font-semibold">
{t('wizard.provider.title')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.provider.description')}
</p>
</div>
<div className="border rounded-lg p-6 bg-card">
<ProviderForm
initialValues={savedProviderForm}
onValuesChange={onSavedProviderFormChange}
submitButtonText={t('wizard.provider.saveAndNext')}
onFormSubmit={(uuid) => {
onProviderCreated(uuid);
}}
onFormCancel={onResetModelSource}
/>
</div>
</div>
);
}
// ---- Layer 2.5: Model scan & add after provider creation ----
if (step2Layer === 3) {
return (
<WizardModelScan
providerUuid={createdProviderUuid}
initialSelectedModels={savedSelectedModels}
onSelectedModelsChange={onSavedSelectedModelsChange}
onModelsAdded={onModelsAdded}
/>
);
}
// ---- Layer 3: Runner configuration form ----
return (
<div className="flex flex-col lg:flex-1 lg:min-h-0 max-w-6xl mx-auto w-full">
<div className="text-center shrink-0 mb-4">
@@ -1085,75 +1711,61 @@ function StepAIEngine({
</div>
<div className="flex flex-col lg:flex-row lg:justify-center gap-6 lg:flex-1 lg:min-h-0 animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* Left: runner list */}
<div className="w-full lg:w-[280px] shrink-0 lg:overflow-y-auto lg:pr-3">
{/* p-1 provides space for ring-2 (4px) to render without clipping */}
<div className="space-y-3 p-1">
{runnerOptions.map((opt) => {
const isSelected = selected === opt.name;
return (
<Card
key={opt.name}
className={cn(
'cursor-pointer transition-all',
isSelected
? 'ring-2 ring-primary shadow-md'
: 'opacity-50 hover:opacity-80 hover:border-primary/50',
)}
onClick={() => onSelect(opt.name)}
>
<CardHeader className="flex flex-row items-center gap-3 py-3 px-4">
<div className="min-w-0 flex-1">
<CardTitle
className={cn(
'text-sm',
!isSelected && 'text-muted-foreground',
)}
>
{extractI18nObject(opt.label)}
</CardTitle>
<CardDescription className="text-xs font-mono text-muted-foreground">
{opt.name}
</CardDescription>
</div>
{isSelected && (
<div className="shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
</div>
{/* Left: runner list (only for orchestrated mode) */}
{aiEngineMode === 'orchestrated' && (
<div className="w-full lg:w-[280px] shrink-0 lg:overflow-y-auto lg:pr-3">
<div className="space-y-3 p-1">
{orchestratedRunners.map((opt) => {
const isSelected = selectedRunner === opt.name;
return (
<Card
key={opt.name}
className={cn(
'cursor-pointer transition-all',
isSelected
? 'ring-2 ring-primary shadow-md'
: 'opacity-50 hover:opacity-80 hover:border-primary/50',
)}
</CardHeader>
</Card>
);
})}
{/* Space promotion banner */}
{selected === 'local-agent' && isLocalAccount && (
<div className="animate-in fade-in slide-in-from-left-2 duration-300">
<div className="relative rounded-lg p-[2px] bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500">
<div className="rounded-[calc(0.5rem-2px)] bg-background p-3 flex flex-col items-center gap-2 text-center">
<Sparkles className="w-6 h-6 text-purple-500 shrink-0" />
<p className="text-xs font-medium">
{t('wizard.spaceBanner.message')}
</p>
<Button
variant="outline"
size="sm"
onClick={onSpaceAuth}
className="w-full"
>
{t('wizard.spaceBanner.action')}
</Button>
</div>
</div>
</div>
)}
onClick={() => onSelectRunner(opt.name)}
>
<CardHeader className="flex flex-row items-center gap-3 py-3 px-4">
<div className="min-w-0 flex-1">
<CardTitle
className={cn(
'text-sm',
!isSelected && 'text-muted-foreground',
)}
>
{extractI18nObject(opt.label)}
</CardTitle>
<CardDescription className="text-xs font-mono text-muted-foreground">
{opt.name}
</CardDescription>
</div>
{isSelected && (
<div className="shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
</div>
)}
</CardHeader>
</Card>
);
})}
</div>
</div>
</div>
)}
{/* Right: runner configuration — fixed width on desktop */}
<div className="w-full lg:w-[560px] shrink-0 lg:overflow-y-auto lg:pr-3 animate-in fade-in slide-in-from-right-2 duration-300">
{/* Right: runner configuration */}
<div
className={cn(
'shrink-0 lg:overflow-y-auto lg:pr-3 animate-in fade-in slide-in-from-right-2 duration-300',
aiEngineMode === 'orchestrated'
? 'w-full lg:w-[560px]'
: 'w-full max-w-2xl mx-auto',
)}
>
<div className="p-1">
{runnerConfigItems.length > 0 && (
<Card>
@@ -1164,7 +1776,7 @@ function StepAIEngine({
</CardHeader>
<CardContent>
<DynamicFormComponent
key={selected}
key={selectedRunner ?? 'llm'}
itemConfigList={runnerConfigItems}
initialValues={runnerConfigValues as Record<string, object>}
onSubmit={stableRunnerConfigCb}
+51 -17
View File
@@ -372,10 +372,6 @@ const enUS = {
log: 'Log',
configuration: 'Configuration',
logs: 'Logs',
runtimeConnected: 'Connected',
runtimeConnecting: 'Connecting',
runtimeDisconnected: 'Disconnected',
runtimeError: 'Connection error',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the bot name and description',
routingConnection: 'Routing & Connection',
@@ -1839,12 +1835,63 @@ const enUS = {
title: 'Select an AI Engine',
description:
"Choose the AI engine that will power your bot's intelligence.",
orchestrated: {
title: 'Use Orchestrated Agent Apps',
description:
'Use pre-built Agent apps from Dify, n8n, Coze, and more.',
action: 'Select Agent App',
selectTitle: 'Select Agent App',
selectDescription: 'Choose the Agent app platform you want to use.',
},
llm: {
title: 'Use LLM Directly',
description:
'Configure a model provider and use LLM to drive your bot directly.',
action: 'Configure LLM',
},
},
spaceBanner: {
message:
'Connect to LangBot Space for free trial model credits and zero-config instant setup!',
action: 'Authorize with Space',
},
provider: {
title: 'Add Model Provider',
description:
'Add your own model provider. Configure the API key to get started.',
scanTitle: 'Scan Available Models',
scanDescription: 'Scanning your provider for available LLM models.',
scanning: 'Scanning models...',
noModelsFound: 'No models found. Please check your provider config.',
addSelected: 'Add {{count}} selected model(s)',
modelsAdded: 'Added {{count}} model(s)',
modelsAddError: 'Failed to add models',
skipModelAdd: 'Skip, add later',
searchModels: 'Search models...',
noMatch: 'No matching models',
orDivider: 'or',
manualModelPlaceholder: 'Enter model name to add manually',
addManual: 'Add',
saveAndNext: 'Save & Next',
alreadyAdded: 'Added',
modelAlreadyExists: 'Model "{{name}}" already exists',
},
modelSource: {
title: 'Choose Model Source',
description: 'Select how to provide model capabilities for your AI engine.',
space: {
title: 'LangBot Service',
description:
'Zero-config ready with free trial model credits. No API key needed — just plug and play.',
action: 'Use LangBot Service',
},
custom: {
title: 'Custom Model',
description:
'Bring your own model API key. Supports OpenAI, Claude, Gemini, and more.',
action: 'Add My Own Model',
},
},
config: {
botInfo: 'Bot Information',
botNamePlaceholder: 'Enter bot name',
@@ -1925,19 +1972,6 @@ const enUS = {
'Scan the QR code below with WeChat to authorize and automatically fill in the token',
loginSuccess: 'Login successful! Token has been filled in',
loginFailed: 'Login failed',
connecting: 'Connecting to WeChat service...',
waitingForScan: 'Waiting for scan',
retry: 'Retry',
},
itchat: {
scanLogin: 'Scan QR Login',
scanQRCode: 'Scan the QR code below with WeChat to login',
loginSuccess:
'Login successful! Session cached. Save config to start using.',
loginFailed: 'Login failed',
connecting: 'Starting WeChat login...',
waitingForScan: 'Waiting for scan',
retry: 'Retry',
},
dingtalk: {
createApp: 'One-Click Create DingTalk App',
+54 -16
View File
@@ -220,19 +220,6 @@ const esES = {
selectModelAbilities: 'Seleccionar capacidades del modelo',
visionAbility: 'Capacidad de visión',
functionCallAbility: 'Llamada a funciones',
reasoningAbility: 'Razonamiento',
reasoningLevel: 'Nivel de razonamiento',
reasoningLevels: {
providerDefault: 'Predeterminado del proveedor',
disabled: 'Desactivado',
enabled: 'Activado',
minimal: 'Mínimo',
low: 'Bajo',
medium: 'Medio',
high: 'Alto',
xhigh: 'Extra alto',
max: 'Máximo',
},
contextLength: 'Ventana de contexto',
contextLengthPlaceholder: 'Desconocido',
contextLengthInvalid: 'La ventana de contexto debe ser un entero positivo',
@@ -1704,12 +1691,66 @@ const esES = {
title: 'Selecciona un motor de IA',
description:
'Elige el motor de IA que impulsará la inteligencia de tu Bot.',
orchestrated: {
title: 'Usar aplicaciones Agent orquestadas',
description:
'Usar aplicaciones Agent predefinidas de Dify, n8n, Coze y más.',
action: 'Seleccionar aplicación Agent',
selectTitle: 'Seleccionar aplicación Agent',
selectDescription:
'Elige la plataforma de aplicación Agent que deseas usar.',
},
llm: {
title: 'Usar modelo directamente',
description:
'Configurar un proveedor de modelos y usar el modelo para impulsar tu Bot.',
action: 'Configurar modelo',
},
},
spaceBanner: {
message:
'¡Conéctate a LangBot Space para obtener créditos de prueba gratuitos y configuración instantánea sin esfuerzo!',
action: 'Autorizar con Space',
},
provider: {
title: 'Agregar proveedor de modelos',
description:
'Agrega tu propio proveedor de modelos. Configura la API key para comenzar.',
scanTitle: 'Escanear modelos disponibles',
scanDescription: 'Escaneando modelos LLM de tu proveedor.',
scanning: 'Escaneando modelos...',
noModelsFound:
'No se encontraron modelos. Verifica la configuración del proveedor.',
addSelected: 'Agregar {{count}} modelo(s) seleccionado(s)',
modelsAdded: '{{count}} modelo(s) agregado(s)',
modelsAddError: 'Error al agregar modelos',
skipModelAdd: 'Omitir, agregar después',
searchModels: 'Buscar modelos...',
noMatch: 'No hay modelos coincidentes',
orDivider: 'o',
manualModelPlaceholder: 'Ingresa el nombre del modelo para agregar manualmente',
addManual: 'Agregar',
saveAndNext: 'Guardar y siguiente',
alreadyAdded: 'Agregado',
modelAlreadyExists: 'El modelo "{{name}}" ya existe',
},
modelSource: {
title: 'Elegir fuente del modelo',
description:
'Selecciona cómo proporcionar capacidades de modelo para tu motor de IA.',
space: {
title: 'Servicio LangBot',
description:
'Listo para usar sin configuración, con créditos de prueba gratuitos. No se necesita API key.',
action: 'Usar servicio LangBot',
},
custom: {
title: 'Modelo personalizado',
description:
'Usa tu propia API key. Compatible con OpenAI, Claude, Gemini y más.',
action: 'Agregar mi propio modelo',
},
},
config: {
botInfo: 'Información del Bot',
botNamePlaceholder: 'Introduce el nombre del Bot',
@@ -1760,9 +1801,6 @@ const esES = {
loginSuccess:
'¡Inicio de sesión correcto! El token se ha rellenado automáticamente',
loginFailed: 'Error al iniciar sesión',
connecting: 'Conectando con el servicio de WeChat...',
waitingForScan: 'Esperando escaneo',
retry: 'Reintentar',
},
dingtalk: {
createApp: 'Crear aplicación de DingTalk con un clic',
+53 -7
View File
@@ -378,10 +378,6 @@ const jaJP = {
log: 'ログ',
configuration: '設定',
logs: 'ログ',
runtimeConnected: '接続済み',
runtimeConnecting: '接続中',
runtimeDisconnected: '切断済み',
runtimeError: '接続エラー',
basicInfo: '基本情報',
basicInfoDescription: 'ボットの名前と説明を設定',
routingConnection: 'ルーティングと接続',
@@ -1756,12 +1752,65 @@ const jaJP = {
title: 'AIエンジンを選択',
description:
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
orchestrated: {
title: 'オーケストレーション済みAgentアプリを使用',
description:
'Dify、n8n、Cozeなどのプラットフォームで構築済みのAgentアプリを使用。',
action: 'Agentアプリを選択',
selectTitle: 'Agentアプリを選択',
selectDescription:
'使用するAgentアプリプラットフォームを選択してください。',
},
llm: {
title: '大規模モデルを直接使用',
description:
'モデルプロバイダーを設定し、大規模モデルでボットを直接駆動。',
action: '大規模モデルを設定',
},
},
spaceBanner: {
message:
'LangBot Spaceに接続して、無料トライアルモデルクレジットとゼロ設定の即時セットアップを入手!',
action: 'Spaceで認証',
},
provider: {
title: 'モデルプロバイダーを追加',
description:
'独自のモデルプロバイダーを追加します。APIキーを設定すると使用可能になります。',
scanTitle: '利用可能なモデルをスキャン',
scanDescription: 'プロバイダーから利用可能なLLMモデルをスキャン中です。',
scanning: 'モデルをスキャン中...',
noModelsFound:
'モデルが見つかりません。プロバイダー設定を確認してください。',
addSelected: '選択した{{count}}個のモデルを追加',
modelsAdded: '{{count}}個のモデルを追加しました',
modelsAddError: 'モデルの追加に失敗しました',
skipModelAdd: 'スキップ(後で追加)',
searchModels: 'モデルを検索...',
noMatch: '一致するモデルがありません',
orDivider: 'または',
manualModelPlaceholder: 'モデル名を入力して手動追加',
addManual: '追加',
saveAndNext: '保存して次へ',
alreadyAdded: '追加済み',
modelAlreadyExists: 'モデル「{{name}}」は既に存在します',
},
modelSource: {
title: 'モデルソースを選択',
description: 'AIエンジンにモデル機能を提供する方法を選択してください。',
space: {
title: 'LangBot サービス',
description:
'設定不要で利用可能。無料トライアルモデルクレジット付き。APIキー不要ですぐに使えます。',
action: 'LangBot サービスを使用',
},
custom: {
title: 'カスタムモデル',
description:
'お手持ちのモデルAPIキーを使用。OpenAI、Claude、Geminiなど主要モデルに対応。',
action: '独自のモデルを追加',
},
},
config: {
botInfo: 'ボット情報',
botNamePlaceholder: 'ボット名を入力',
@@ -1841,9 +1890,6 @@ const jaJP = {
scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力',
loginSuccess: 'ログイン成功!トークンが自動入力されました',
loginFailed: 'ログイン失敗',
connecting: 'WeChatサービスに接続中...',
waitingForScan: 'スキャン待ち',
retry: '再試行',
},
dingtalk: {
createApp: 'ワンクリックでDingTalkアプリ作成',
+54 -16
View File
@@ -217,19 +217,6 @@ const ruRU = {
selectModelAbilities: 'Выберите возможности модели',
visionAbility: 'Распознавание изображений',
functionCallAbility: 'Вызов функций',
reasoningAbility: 'Рассуждение',
reasoningLevel: 'Уровень рассуждений',
reasoningLevels: {
providerDefault: 'По умолчанию провайдера',
disabled: 'Выключено',
enabled: 'Включено',
minimal: 'Минимальный',
low: 'Низкий',
medium: 'Средний',
high: 'Высокий',
xhigh: 'Очень высокий',
max: 'Максимальный',
},
contextLength: 'Контекстное окно',
contextLengthPlaceholder: 'Неизвестно',
contextLengthInvalid:
@@ -1674,12 +1661,66 @@ const ruRU = {
title: 'Выберите ИИ-движок',
description:
'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.',
orchestrated: {
title: 'Использовать оркестрированные Agent-приложения',
description:
'Использовать готовые Agent-приложения из Dify, n8n, Coze и других.',
action: 'Выбрать Agent-приложение',
selectTitle: 'Выбрать Agent-приложение',
selectDescription:
'Выберите платформу Agent-приложения для использования.',
},
llm: {
title: 'Использовать LLM напрямую',
description:
'Настроить провайдера моделей и использовать LLM для управления ботом.',
action: 'Настроить LLM',
},
},
spaceBanner: {
message:
'Подключитесь к LangBot Space для бесплатных пробных кредитов и мгновенной настройки!',
action: 'Авторизация через Space',
},
provider: {
title: 'Добавить провайдера моделей',
description:
'Добавьте своего провайдера моделей. Настройте API-ключ для начала работы.',
scanTitle: 'Сканирование доступных моделей',
scanDescription: 'Сканирование LLM-моделей у вашего провайдера.',
scanning: 'Сканирование моделей...',
noModelsFound:
'Модели не найдены. Проверьте настройки провайдера.',
addSelected: 'Добавить {{count}} выбранную(ых)',
modelsAdded: 'Добавлено {{count}} модель(ей)',
modelsAddError: 'Ошибка добавления моделей',
skipModelAdd: 'Пропустить, добавить позже',
searchModels: 'Поиск моделей...',
noMatch: 'Нет подходящих моделей',
orDivider: 'или',
manualModelPlaceholder: 'Введите имя модели для ручного добавления',
addManual: 'Добавить',
saveAndNext: 'Сохранить и далее',
alreadyAdded: 'Добавлено',
modelAlreadyExists: 'Модель "{{name}}" уже существует',
},
modelSource: {
title: 'Выберите источник модели',
description:
'Выберите способ предоставления возможностей модели для вашего AI-движка.',
space: {
title: 'Сервис LangBot',
description:
'Готов к использованию без настройки, с бесплатными пробными кредитами. API-ключ не требуется.',
action: 'Использовать сервис LangBot',
},
custom: {
title: 'Пользовательская модель',
description:
'Используйте свой собственный API-ключ. Поддержка OpenAI, Claude, Gemini и других.',
action: 'Добавить свою модель',
},
},
config: {
botInfo: 'Информация о боте',
botNamePlaceholder: 'Введите имя бота',
@@ -1730,9 +1771,6 @@ const ruRU = {
'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен',
loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически',
loginFailed: 'Не удалось выполнить вход',
connecting: 'Подключение к сервису WeChat...',
waitingForScan: 'Ожидание сканирования',
retry: 'Повторить',
},
dingtalk: {
createApp: 'Создать приложение DingTalk в один клик',
+52 -16
View File
@@ -213,19 +213,6 @@ const thTH = {
selectModelAbilities: 'เลือกความสามารถของโมเดล',
visionAbility: 'ความสามารถด้านภาพ',
functionCallAbility: 'การเรียกฟังก์ชัน',
reasoningAbility: 'ความสามารถในการให้เหตุผล',
reasoningLevel: 'ระดับการให้เหตุผล',
reasoningLevels: {
providerDefault: 'ค่าเริ่มต้นของผู้ให้บริการ',
disabled: 'ปิด',
enabled: 'เปิด',
minimal: 'ต่ำสุด',
low: 'ต่ำ',
medium: 'ปานกลาง',
high: 'สูง',
xhigh: 'สูงมาก',
max: 'สูงสุด',
},
contextLength: 'หน้าต่างบริบท',
contextLengthPlaceholder: 'ไม่ทราบ',
contextLengthInvalid: 'หน้าต่างบริบทต้องเป็นจำนวนเต็มบวก',
@@ -1639,12 +1626,64 @@ const thTH = {
aiEngine: {
title: 'เลือกเครื่องมือ AI',
description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot',
orchestrated: {
title: 'ใช้แอป Agent ที่จัดเตรียมไว้',
description:
'ใช้แอป Agent ที่สร้างไว้จาก Dify, n8n, Coze และอื่นๆ',
action: 'เลือกแอป Agent',
selectTitle: 'เลือกแอป Agent',
selectDescription: 'เลือกแพลตฟอร์มแอป Agent ที่ต้องการใช้',
},
llm: {
title: 'ใช้โมเดลโดยตรง',
description:
'กำหนดค่าผู้ให้บริการโมเดลและใช้โมเดลขับเคลื่อน Bot โดยตรง',
action: 'กำหนดค่าโมเดล',
},
},
spaceBanner: {
message:
'เชื่อมต่อกับ LangBot Space เพื่อรับเครดิตทดลองใช้โมเดลฟรีและตั้งค่าทันทีโดยไม่ต้องกำหนดค่า!',
action: 'ยืนยันสิทธิ์กับ Space',
},
provider: {
title: 'เพิ่มผู้ให้บริการโมเดล',
description:
'เพิ่มผู้ให้บริการโมเดลของคุณเอง กำหนดค่า API key เพื่อเริ่มต้น',
scanTitle: 'สแกนโมเดลที่ใช้ได้',
scanDescription: 'กำลังสแกนโมเดล LLM จากผู้ให้บริการของคุณ',
scanning: 'กำลังสแกนโมเดล...',
noModelsFound:
'ไม่พบโมเดล กรุณาตรวจสอบการตั้งค่าผู้ให้บริการ',
addSelected: 'เพิ่ม {{count}} โมเดลที่เลือก',
modelsAdded: 'เพิ่ม {{count}} โมเดลแล้ว',
modelsAddError: 'เพิ่มโมเดลไม่สำเร็จ',
skipModelAdd: 'ข้าม เพิ่มทีหลัง',
searchModels: 'ค้นหาโมเดล...',
noMatch: 'ไม่พบโมเดลที่ตรงกัน',
orDivider: 'หรือ',
manualModelPlaceholder: 'กรอกชื่อโมเดลเพื่อเพิ่มด้วยตนเอง',
addManual: 'เพิ่ม',
saveAndNext: 'บันทึกและถัดไป',
alreadyAdded: 'เพิ่มแล้ว',
modelAlreadyExists: 'โมเดล "{{name}}" มีอยู่แล้ว',
},
modelSource: {
title: 'เลือกแหล่งโมเดล',
description: 'เลือกวิธีการให้ความสามารถของโมเดลสำหรับ AI engine ของคุณ',
space: {
title: 'บริการ LangBot',
description:
'พร้อมใช้งานทันทีไม่ต้องตั้งค่า พร้อมเครดิตทดลองใช้โมเดลฟรี ไม่ต้องใช้ API key',
action: 'ใช้บริการ LangBot',
},
custom: {
title: 'โมเดลกำหนดเอง',
description:
'ใช้ API key โมเดลของคุณเอง รองรับ OpenAI, Claude, Gemini และอื่นๆ',
action: 'เพิ่มโมเดลของฉัน',
},
},
config: {
botInfo: 'ข้อมูล Bot',
botNamePlaceholder: 'กรอกชื่อ Bot',
@@ -1693,9 +1732,6 @@ const thTH = {
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ',
loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว',
loginFailed: 'เข้าสู่ระบบไม่สำเร็จ',
connecting: 'กำลังเชื่อมต่อบริการ WeChat...',
waitingForScan: 'กำลังรอการสแกน',
retry: 'ลองอีกครั้ง',
},
dingtalk: {
createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว',
+52 -16
View File
@@ -217,19 +217,6 @@ const viVN = {
selectModelAbilities: 'Chọn khả năng mô hình',
visionAbility: 'Khả năng thị giác',
functionCallAbility: 'Gọi hàm',
reasoningAbility: 'Khả năng suy luận',
reasoningLevel: 'Mức độ suy luận',
reasoningLevels: {
providerDefault: 'Mặc định của nhà cung cấp',
disabled: 'Tắt',
enabled: 'Bật',
minimal: 'Tối thiểu',
low: 'Thấp',
medium: 'Trung bình',
high: 'Cao',
xhigh: 'Rất cao',
max: 'Tối đa',
},
contextLength: 'Cửa sổ ngữ cảnh',
contextLengthPlaceholder: 'Không rõ',
contextLengthInvalid: 'Cửa sổ ngữ cảnh phải là số nguyên dương',
@@ -1665,12 +1652,64 @@ const viVN = {
aiEngine: {
title: 'Chọn công cụ AI',
description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.',
orchestrated: {
title: 'Sử dụng ứng dụng Agent đã được phối hợp',
description:
'Sử dụng các ứng dụng Agent đã được xây dựng từ Dify, n8n, Coze.',
action: 'Chọn ứng dụng Agent',
selectTitle: 'Chọn ứng dụng Agent',
selectDescription: 'Chọn nền tảng ứng dụng Agent bạn muốn sử dụng.',
},
llm: {
title: 'Sử dụng mô hình trực tiếp',
description:
'Cấu hình nhà cung cấp mô hình và sử dụng mô hình lớn để điều khiển Bot.',
action: 'Cấu hình mô hình',
},
},
spaceBanner: {
message:
'Kết nối với LangBot Space để nhận tín dụng dùng thử mô hình miễn phí và thiết lập tức thì không cần cấu hình!',
action: 'Ủy quyền với Space',
},
provider: {
title: 'Thêm nhà cung cấp mô hình',
description:
'Thêm nhà cung cấp mô hình của bạn. Cấu hình API key để bắt đầu.',
scanTitle: 'Quét mô hình khả dụng',
scanDescription: 'Đang quét mô hình LLM từ nhà cung cấp của bạn.',
scanning: 'Đang quét mô hình...',
noModelsFound:
'Không tìm thấy mô hình. Vui lòng kiểm tra cấu hình nhà cung cấp.',
addSelected: 'Thêm {{count}} mô hình đã chọn',
modelsAdded: 'Đã thêm {{count}} mô hình',
modelsAddError: 'Thêm mô hình thất bại',
skipModelAdd: 'Bỏ qua, thêm sau',
searchModels: 'Tìm kiếm mô hình...',
noMatch: 'Không tìm thấy mô hình phù hợp',
orDivider: 'hoặc',
manualModelPlaceholder: 'Nhập tên mô hình để thêm thủ công',
addManual: 'Thêm',
saveAndNext: 'Lưu & Tiếp theo',
alreadyAdded: 'Đã thêm',
modelAlreadyExists: 'Mô hình "{{name}}" đã tồn tại',
},
modelSource: {
title: 'Chọn nguồn mô hình',
description: 'Chọn cách cung cấp khả năng mô hình cho AI engine của bạn.',
space: {
title: 'Dịch vụ LangBot',
description:
'Sẵn sàng sử dụng không cần cấu hình, tặng tín dụng mô hình thử nghiệm miễn phí. Không cần API key.',
action: 'Sử dụng dịch vụ LangBot',
},
custom: {
title: 'Mô hình tùy chỉnh',
description:
'Sử dụng API key mô hình của riêng bạn. Hỗ trợ OpenAI, Claude, Gemini và nhiều hơn nữa.',
action: 'Thêm mô hình của tôi',
},
},
config: {
botInfo: 'Thông tin Bot',
botNamePlaceholder: 'Nhập tên Bot',
@@ -1721,9 +1760,6 @@ const viVN = {
'Quét mã QR bên dưới bằng WeChat để ủy quyền và tự động điền token',
loginSuccess: 'Đăng nhập thành công! Token đã được điền tự động',
loginFailed: 'Đăng nhập thất bại',
connecting: 'Đang kết nối tới dịch vụ WeChat...',
waitingForScan: 'Đang chờ quét mã',
retry: 'Thử lại',
},
dingtalk: {
createApp: 'Tạo ứng dụng DingTalk chỉ với một lần nhấp',
+48 -16
View File
@@ -355,10 +355,6 @@ const zhHans = {
log: '日志',
configuration: '配置',
logs: '日志',
runtimeConnected: '已连接',
runtimeConnecting: '连接中',
runtimeDisconnected: '已掉线',
runtimeError: '连接错误',
basicInfo: '基础信息',
basicInfoDescription: '设置机器人名称和描述',
routingConnection: '路由与连接',
@@ -1759,11 +1755,59 @@ const zhHans = {
aiEngine: {
title: '选择 AI 引擎',
description: '选择驱动机器人智能的 AI 引擎。',
orchestrated: {
title: '使用编排好的 Agent 应用',
description: '使用 Dify、n8n、Coze 等平台编排好的 Agent 应用。',
action: '选择 Agent 应用',
selectTitle: '选择 Agent 应用',
selectDescription: '选择你要使用的 Agent 应用平台。',
},
llm: {
title: '直接使用大模型',
description: '配置大模型供应商,直接使用大模型驱动机器人。',
action: '配置大模型',
},
},
spaceBanner: {
message: '接入 LangBot Space,获取免费试用模型额度,零配置极速开箱!',
action: '前往授权登录',
},
provider: {
title: '添加模型供应商',
description: '添加你自己的模型供应商,配置 API Key 后即可使用。',
scanTitle: '扫描可用模型',
scanDescription: '正在从你的供应商中扫描可用的 LLM 模型。',
scanning: '正在扫描模型...',
noModelsFound: '未发现可用模型,请检查供应商配置。',
addSelected: '添加选中的 {{count}} 个模型',
modelsAdded: '已添加 {{count}} 个模型',
modelsAddError: '添加模型失败',
skipModelAdd: '跳过,稍后添加',
searchModels: '搜索模型...',
noMatch: '没有匹配的模型',
orDivider: '或',
manualModelPlaceholder: '输入模型名称手动添加',
addManual: '添加',
saveAndNext: '保存并下一步',
alreadyAdded: '已添加',
modelAlreadyExists: '模型 "{{name}}" 已存在',
},
modelSource: {
title: '选择模型来源',
description: '选择如何为你的 AI 引擎提供模型能力。',
space: {
title: 'LangBot 服务',
description:
'零配置即可使用,提供免费试用模型额度,开箱即用,无需自备 API Key。',
action: '使用 LangBot 服务',
},
custom: {
title: '自定义模型',
description:
'使用你自己的模型 API Key,支持 OpenAI、Claude、Gemini 等主流模型。',
action: '添加我自己的模型',
},
},
config: {
botInfo: '机器人信息',
botNamePlaceholder: '请输入机器人名称',
@@ -1838,18 +1882,6 @@ const zhHans = {
scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌',
loginSuccess: '登录成功!令牌已自动填入',
loginFailed: '登录失败',
connecting: '正在连接微信服务...',
waitingForScan: '等待扫码中',
retry: '重试',
},
itchat: {
scanLogin: '扫码登录微信',
scanQRCode: '请使用微信扫描以下二维码登录',
loginSuccess: '登录成功!会话已缓存,保存配置后即可使用',
loginFailed: '登录失败',
connecting: '正在启动微信登录...',
waitingForScan: '等待扫码中',
retry: '重试',
},
dingtalk: {
createApp: '一键创建钉钉应用',
+48 -16
View File
@@ -205,19 +205,6 @@ const zhHant = {
selectModelAbilities: '選擇模型能力',
visionAbility: '視覺能力',
functionCallAbility: '函數呼叫',
reasoningAbility: '思考能力',
reasoningLevel: '思考等級',
reasoningLevels: {
providerDefault: '供應商預設',
disabled: '關閉',
enabled: '開啟',
minimal: '最低',
low: '低',
medium: '中',
high: '高',
xhigh: '極高',
max: '最大',
},
contextLength: '上下文視窗',
contextLengthPlaceholder: '未知',
contextLengthInvalid: '上下文視窗必須是正整數',
@@ -1591,11 +1578,59 @@ const zhHant = {
aiEngine: {
title: '選擇 AI 引擎',
description: '選擇驅動機器人智慧的 AI 引擎。',
orchestrated: {
title: '使用編排好的 Agent 應用',
description: '使用 Dify、n8n、Coze 等平台編排好的 Agent 應用。',
action: '選擇 Agent 應用',
selectTitle: '選擇 Agent 應用',
selectDescription: '選擇你要使用的 Agent 應用平台。',
},
llm: {
title: '直接使用大模型',
description: '配置大模型供應商,直接使用大模型驅動機器人。',
action: '配置大模型',
},
},
spaceBanner: {
message: '接入 LangBot Space,取得免費試用模型額度,零配置極速開箱!',
action: '前往授權登入',
},
provider: {
title: '新增模型供應商',
description: '新增你自己的模型供應商,配置 API Key 後即可使用。',
scanTitle: '掃描可用模型',
scanDescription: '正在從你的供應商中掃描可用的 LLM 模型。',
scanning: '正在掃描模型...',
noModelsFound: '未發現可用模型,請檢查供應商配置。',
addSelected: '新增選取的 {{count}} 個模型',
modelsAdded: '已新增 {{count}} 個模型',
modelsAddError: '新增模型失敗',
skipModelAdd: '跳過,稍後新增',
searchModels: '搜尋模型...',
noMatch: '沒有符合的模型',
orDivider: '或',
manualModelPlaceholder: '輸入模型名稱手動新增',
addManual: '新增',
saveAndNext: '儲存並下一步',
alreadyAdded: '已新增',
modelAlreadyExists: '模型「{{name}}」已存在',
},
modelSource: {
title: '選擇模型來源',
description: '選擇如何為你的 AI 引擎提供模型能力。',
space: {
title: 'LangBot 服務',
description:
'零配置即可使用,提供免費試用模型額度,開箱即用,無需自備 API Key。',
action: '使用 LangBot 服務',
},
custom: {
title: '自訂模型',
description:
'使用你自己的模型 API Key,支援 OpenAI、Claude、Gemini 等主流模型。',
action: '新增我自己的模型',
},
},
config: {
botInfo: '機器人資訊',
botNamePlaceholder: '請輸入機器人名稱',
@@ -1670,9 +1705,6 @@ const zhHant = {
scanQRCode: '請使用微信掃描以下 QR Code,授權後將自動登入並填寫令牌',
loginSuccess: '登入成功!令牌已自動填入',
loginFailed: '登入失敗',
connecting: '正在連接微信服務...',
waitingForScan: '等待掃碼中',
retry: '重試',
},
dingtalk: {
createApp: '一鍵建立釘釘應用',
@@ -1,73 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const dialogPath = path.join(
root,
'src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx',
);
const localeDir = path.join(root, 'src/i18n/locales');
const dialogSource = fs.readFileSync(dialogPath, 'utf8');
test('QR credential exchanges preserve the active Workspace scope', () => {
assert.match(dialogSource, /getActiveWorkspaceUuid/);
assert.match(
dialogSource,
/sessionWorkspaceUuidRef\.current = workspaceUuid/,
);
assert.match(
dialogSource,
/const workspaceUuid = sessionWorkspaceUuidRef\.current/,
);
assert.match(dialogSource, /sessionApiBaseRef\.current = cfg\.apiBase/);
assert.match(
dialogSource,
/`\$\{baseUrlRef\.current\}\$\{sessionApiBaseRef\.current\}\/\$\{sessionIdRef\.current\}`/,
);
assert.match(dialogSource, /'X-Workspace-Id': workspaceUuid/);
const workspaceHeaderUses = dialogSource.match(
/'X-Workspace-Id': workspaceUuid/g,
);
assert.equal(
workspaceHeaderUses?.length,
4,
'start, poll, expiry cleanup, and dialog cleanup must all retain Workspace scope',
);
});
test('WeChat QR login never reuses Feishu progress copy', () => {
const weixinConfig = dialogSource.match(
/weixin:\s*\{[\s\S]*?apiBase:\s*'\/api\/v1\/platform\/adapters\/weixin\/login'/,
)?.[0];
assert.ok(weixinConfig, 'WeChat platform config is missing');
assert.match(weixinConfig, /connectingKey:\s*'weixin\.connecting'/);
assert.match(weixinConfig, /waitingKey:\s*'weixin\.waitingForScan'/);
assert.match(weixinConfig, /retryKey:\s*'weixin\.retry'/);
assert.doesNotMatch(weixinConfig, /feishu\./);
for (const locale of [
'en-US.ts',
'es-ES.ts',
'ja-JP.ts',
'ru-RU.ts',
'th-TH.ts',
'vi-VN.ts',
'zh-Hans.ts',
'zh-Hant.ts',
]) {
const source = fs.readFileSync(path.join(localeDir, locale), 'utf8');
const block = source.match(/weixin:\s*\{[\s\S]*?\n\s*\},/)?.[0];
assert.ok(block, `${locale} is missing the WeChat locale block`);
for (const key of ['connecting', 'waitingForScan', 'retry']) {
assert.match(
block,
new RegExp(`\\b${key}:`),
`${locale} is missing weixin.${key}`,
);
}
}
});
+6
View File
@@ -11,6 +11,12 @@ export default defineConfig({
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://127.0.0.1:5300',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',