fix(migration): improve runner migration and plugin installation feedback

This commit is contained in:
RockChinQ
2026-09-18 13:10:32 +08:00
parent e77acfa3ab
commit 60606e1997
28 changed files with 1197 additions and 131 deletions
+3 -3
View File
@@ -46,9 +46,9 @@
### LocalAgent(28 项) ### LocalAgent(28 项)
- **`ai.local-agent.box-session-id-template`** → `Host execution_context.build_host_box_scope + Box service`。 - **`ai.local-agent.box-session-id-template`** → `R.box-session-id-template`。
- 缺失、空值或原生默认 {launcher_type}_{launcher_id} 不写入新 Runner;用户确认明确的 Box 状态重置警告后创建新隔离会话。任何其他自定义共享模板仍阻断,不自动复制文件或跨作用域授权。 - 全局、每会话、每用户、每对话上下文、每消息及有效自定义模板均原样保留;缺失时沿用插件默认 `{launcher_type}_{launcher_id}`。插件清单的 `allow_custom: true` 允许非预制值。空值、错误括号、位置参数、格式转换和属性访问会提示修正;公开请求变量保留,但运行时必须有值。复用范围保留不等于迁移旧容器及文件,新运行器按同样规则申请新的沙箱。
- 所有权:`host_tenancy_and_existing_file_state`;原生依据:`src/langbot/pkg/box/service.py:641`。 - 所有权:Runner 管理复用策略、申请、绑定及附件传输;Host 管理连接、工作区隔离和配额。沙箱开关沿用 LocalAgent 插件默认 `true`,仅在有沙箱工具授权且平台支持时使用。
- **`ai.local-agent.enable-all-tools`** → `R.enable-all-tools`。 - **`ai.local-agent.enable-all-tools`** → `R.enable-all-tools`。
- 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。 - 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。
- 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:34`。 - 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:34`。
+1 -1
View File
@@ -235,4 +235,4 @@ skip-magic-trailing-comma = false
line-ending = "auto" line-ending = "auto"
[tool.uv.sources] [tool.uv.sources]
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "94c098ee0d1c4535043d9bf4a74c25f90849bc41" } langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9" }
@@ -11,12 +11,15 @@ import copy
import hashlib import hashlib
import hmac import hmac
import json import json
import math
import time
import secrets import secrets
import sys import sys
import uuid import uuid
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
import httpx
import sqlalchemy as sa import sqlalchemy as sa
from ..authz import Permission, permissions_for_role, require_permission from ..authz import Permission, permissions_for_role, require_permission
@@ -37,9 +40,78 @@ from ....entity.persistence.pipeline_migration import PipelineMigrationSnapshot
from ....entity.persistence.plugin import PluginSetting from ....entity.persistence.plugin import PluginSetting
from ....entity.persistence.user import User from ....entity.persistence.user import User
from ....entity.persistence.workspace import Workspace, WorkspaceMembership, WorkspaceExecutionState from ....entity.persistence.workspace import Workspace, WorkspaceMembership, WorkspaceExecutionState
from ....plugin.errors import (
PluginRuntimeNotConnectedError,
MarketplacePluginVersionNotFoundError,
PluginInstallationFailedError,
)
from ....pipeline.legacy_config_migration import PLANNER_VERSION, plan_legacy_pipeline from ....pipeline.legacy_config_migration import PLANNER_VERSION, plan_legacy_pipeline
class MigrationInstallContext(TaskContext):
"""Publish only structured installer progress, never upstream logs or URLs."""
ACTIONS = {
'downloading plugin package': 'downloading',
'validating plugin package': 'validating',
'preparing plugin installation': 'preparing',
'installing plugin dependencies': 'installing_deps',
'waiting for plugin initialization': 'activating',
'refreshing plugin components': 'refreshing',
'plugin installed': 'done',
}
METRICS = ('progress_percent', 'download_current', 'download_total', 'download_speed', 'deps_total')
def __init__(self, target):
super().__init__()
self.progress = {
**target,
'status': 'installing',
'stage': 'checking',
'code': None,
'started_at': time.time(),
'finished_at': None,
'steps': [],
}
self._step('checking')
def _step(self, stage):
steps = self.progress['steps']
if steps and steps[-1]['stage'] == stage:
return
now = time.time()
if steps:
steps[-1]['finished_at'] = now
steps.append({'stage': stage, 'started_at': now, 'finished_at': None})
self.progress['stage'] = stage
def set_current_action(self, action):
super().set_current_action(action)
stage = self.ACTIONS.get(action)
if stage:
self._step(stage)
self.publish()
def publish(self):
for key in self.METRICS:
value = self.metadata.get(key)
if type(value) in (int, float) and math.isfinite(value) and value >= 0:
self.progress[key] = min(value, 100) if key == 'progress_percent' else value
self.progress['updated_at'] = time.time()
def finish(self, code=None):
self.publish()
self.progress.update(status='failed' if code else 'completed', code=code, finished_at=time.time())
self.progress['steps'][-1]['finished_at'] = self.progress['finished_at']
if not code:
self.progress['progress_percent'] = 100
async def observe(self):
while True:
self.publish()
await asyncio.sleep(0.2)
class MigrationError(ValueError): class MigrationError(ValueError):
"""Only constant, credential-free codes cross the API/task boundary.""" """Only constant, credential-free codes cross the API/task boundary."""
@@ -49,6 +121,14 @@ class MigrationError(ValueError):
self.status_code = status_code self.status_code = status_code
def _failure_code(exc: Exception) -> str:
if isinstance(exc, MigrationError):
return exc.code
if isinstance(exc, PluginRuntimeNotConnectedError):
return 'plugin_runtime_unavailable'
return 'migration_failed'
def validate_execute_request(body) -> list[dict]: def validate_execute_request(body) -> list[dict]:
if not isinstance(body, dict) or set(body) != {'confirmed', 'items'} or body['confirmed'] is not True: if not isinstance(body, dict) or set(body) != {'confirmed', 'items'} or body['confirmed'] is not True:
raise MigrationError('confirmation_required', 400) raise MigrationError('confirmation_required', 400)
@@ -368,6 +448,7 @@ class PipelineMigrationService:
task_context.metadata = { task_context.metadata = {
'kind': 'pipeline_migration', 'kind': 'pipeline_migration',
'phase': 'installing' if install_plugins else 'migrating', 'phase': 'installing' if install_plugins else 'migrating',
'installations': [],
'results': [{'pipeline_uuid': row['uuid'], 'state': 'pending', 'code': None} for row in sources], 'results': [{'pipeline_uuid': row['uuid'], 'state': 'pending', 'code': None} for row in sources],
} }
task = self.ap.task_mgr.create_user_task( task = self.ap.task_mgr.create_user_task(
@@ -429,12 +510,13 @@ class PipelineMigrationService:
) )
if not ready: if not ready:
task_context.metadata['phase'] = 'installing' task_context.metadata['phase'] = 'installing'
install_context = MigrationInstallContext(target)
task_context.metadata['installations'].append(install_context.progress)
observer = asyncio.create_task(install_context.observe())
install_code = 'operation_cancelled'
try: try:
await self._authorize(ctx) await self._authorize(ctx)
await self.ap.plugin_connector.require_workspace_context(execution) await self.ap.plugin_connector.require_workspace_context(execution)
# Keep installer diagnostics out of the user task: upstream
# errors may contain credentials. Reuse normal quota and
# runtime-readiness checks in the connector.
await self.ap.plugin_connector.install_plugin( await self.ap.plugin_connector.install_plugin(
PluginInstallSource.MARKETPLACE, PluginInstallSource.MARKETPLACE,
{ {
@@ -442,11 +524,38 @@ class PipelineMigrationService:
'plugin_name': target['name'], 'plugin_name': target['name'],
'plugin_version': target['version'], 'plugin_version': target['version'],
}, },
task_context=TaskContext.new(), task_context=install_context,
) )
installed[key] = None install_code = None
except PluginRuntimeNotConnectedError:
install_code = 'plugin_runtime_unavailable'
except MarketplacePluginVersionNotFoundError:
install_code = 'plugin_version_unavailable'
except httpx.TimeoutException:
install_code = 'plugin_download_timeout'
except httpx.HTTPStatusError as exc:
install_code = (
'plugin_version_unavailable'
if exc.response.status_code == 404
else 'plugin_marketplace_unavailable'
)
except httpx.RequestError:
install_code = 'plugin_download_failed'
except PluginInstallationFailedError as exc:
install_code = {
'dependency_prepare_failed': 'dependency_prepare_failed',
'worker_launch_failed': 'plugin_launch_failed',
}.get(exc.error_code, 'plugin_install_failed')
except Exception: except Exception:
installed[key] = 'plugin_install_failed' install_code = 'plugin_install_failed'
finally:
observer.cancel()
try:
await observer
except asyncio.CancelledError:
pass
install_context.finish(install_code)
installed[key] = install_code
else: else:
installed[key] = None installed[key] = None
if installed[key]: if installed[key]:
@@ -467,7 +576,7 @@ class PipelineMigrationService:
except Exception as exc: except Exception as exc:
result.update( result.update(
state='blocked' if isinstance(exc, MigrationError) else 'failed', state='blocked' if isinstance(exc, MigrationError) else 'failed',
code=exc.code if isinstance(exc, MigrationError) else 'migration_failed', code=_failure_code(exc),
) )
if any( if any(
r['code'] in ('operation_cancelled', 'commit_outcome_unknown') r['code'] in ('operation_cancelled', 'commit_outcome_unknown')
@@ -557,7 +666,7 @@ class PipelineMigrationService:
) )
if not valid: if not valid:
raise MigrationError('runner_schema_incompatible') raise MigrationError('runner_schema_incompatible')
if kind == 'select' and field.get('options'): if kind == 'select' and field.get('options') and field.get('allow_custom') is not True:
if value not in [o.get('value', o.get('name')) for o in field['options']]: if value not in [o.get('value', o.get('name')) for o in field['options']]:
raise MigrationError('runner_schema_incompatible') raise MigrationError('runner_schema_incompatible')
if any( if any(
@@ -771,13 +880,7 @@ class PipelineMigrationService:
except (Exception, asyncio.CancelledError) as exc: except (Exception, asyncio.CancelledError) as exc:
cancelled = isinstance(exc, asyncio.CancelledError) cancelled = isinstance(exc, asyncio.CancelledError)
reconciliation_cancel = None reconciliation_cancel = None
code = ( code = 'operation_cancelled' if cancelled else _failure_code(exc)
'operation_cancelled'
if cancelled
else exc.code
if isinstance(exc, MigrationError)
else 'migration_failed'
)
if commit_attempted and not committed and not isinstance(exc, MigrationError): if commit_attempted and not committed and not isinstance(exc, MigrationError):
# A commit can succeed while acknowledgement/connection # A commit can succeed while acknowledgement/connection
# cleanup fails, including cancellation. Make one read # cleanup fails, including cancellation. Make one read
@@ -17,6 +17,7 @@ import json
import math import math
import re import re
from urllib.parse import urlsplit from urllib.parse import urlsplit
from string import Formatter
PLANNER_VERSION = '3' PLANNER_VERSION = '3'
@@ -344,7 +345,18 @@ def _validate_local(result, section):
_warn(result, 'local.retrieval_defaults', f'{prefix}.knowledge-bases') _warn(result, 'local.retrieval_defaults', f'{prefix}.knowledge-bases')
template = section.get('box-session-id-template', '{launcher_type}_{launcher_id}') template = section.get('box-session-id-template', '{launcher_type}_{launcher_id}')
if type(template) is not str or not template.strip(): if type(template) is not str or not template.strip():
_block(result, 'invalid_type', f'{prefix}.box-session-id-template') _block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template')
else:
try:
fields = list(Formatter().parse(template))
if any(
name is not None
and (not name or name.isdecimal() or re.search(r'[.\[\]{}]', name) or spec or conversion)
for _, name, spec, conversion in fields
):
raise ValueError('Only named interpolation variables are supported')
except ValueError:
_block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template')
_warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template') _warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template')
+26 -19
View File
@@ -23,6 +23,11 @@ from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
from ..core import app from ..core import app
from . import handler from . import handler
from .errors import (
PluginRuntimeNotConnectedError,
PluginInstallationFailedError,
MarketplacePluginVersionNotFoundError,
)
from .archive import inspect_plugin_archive_metadata from .archive import inspect_plugin_archive_metadata
from .github import ( from .github import (
validate_github_plugin_install_info, validate_github_plugin_install_info,
@@ -89,8 +94,10 @@ async def _read_httpx_response_limited(
response: httpx.Response, response: httpx.Response,
*, *,
max_bytes: int, max_bytes: int,
task_context: taskmgr.TaskContext | None = None,
) -> bytes: ) -> bytes:
content_length = response.headers.get('content-length') content_length = response.headers.get('content-length')
declared_size = None
if content_length is not None: if content_length is not None:
try: try:
declared_size = int(content_length) declared_size = int(content_length)
@@ -99,9 +106,16 @@ async def _read_httpx_response_limited(
if declared_size is not None and declared_size > max_bytes: if declared_size is not None and declared_size > max_bytes:
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit') raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
started = time.monotonic()
if task_context is not None:
task_context.metadata.update(download_current=0, download_total=max(declared_size or 0, 0))
body = bytearray() body = bytearray()
async for chunk in response.aiter_bytes(chunk_size=64 * 1024): async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
body.extend(chunk) body.extend(chunk)
if task_context is not None:
task_context.metadata.update(
download_current=len(body), download_speed=len(body) / max(time.monotonic() - started, 0.001)
)
if len(body) > max_bytes: if len(body) > max_bytes:
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit') raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
return bytes(body) return bytes(body)
@@ -113,14 +127,25 @@ async def _marketplace_get(
*, *,
max_bytes: int, max_bytes: int,
allow_not_found: bool = False, allow_not_found: bool = False,
task_context: taskmgr.TaskContext | None = None,
) -> tuple[int, bytes]: ) -> tuple[int, bytes]:
async with client.stream('GET', url) as response: async with client.stream('GET', url) as response:
if allow_not_found and response.status_code == 404: if allow_not_found and response.status_code == 404:
return response.status_code, b'' return response.status_code, b''
if response.is_error:
body = await _read_httpx_response_limited(response, max_bytes=min(max_bytes, 64 * 1024))
try:
payload = json.loads(body)
except (ValueError, UnicodeDecodeError):
payload = {}
# Space currently returns HTTP 500 for a missing plugin release.
if isinstance(payload, dict) and str(payload.get('msg', '')).startswith('plugin version not found:'):
raise MarketplacePluginVersionNotFoundError('The requested plugin version is not available')
response.raise_for_status() response.raise_for_status()
return response.status_code, await _read_httpx_response_limited( return response.status_code, await _read_httpx_response_limited(
response, response,
max_bytes=max_bytes, max_bytes=max_bytes,
task_context=task_context,
) )
@@ -134,25 +159,6 @@ def _decode_json_object(body: bytes, *, subject: str) -> dict[str, Any]:
return payload return payload
class PluginRuntimeNotConnectedError(RuntimeError):
"""Raised when plugin runtime operations are requested before connection."""
class PluginInstallationFailedError(RuntimeError):
"""Stable Runtime desired-state failure for one plugin installation."""
def __init__(
self,
installation_uuid: str,
error_code: str,
message: str,
) -> None:
self.installation_uuid = installation_uuid
self.error_code = error_code
self.runtime_message = message
super().__init__(f'Plugin installation {installation_uuid} failed [{error_code}]: {message}')
class PluginRuntimeConnector(ManagedRuntimeConnector): class PluginRuntimeConnector(ManagedRuntimeConnector):
"""Plugin runtime connector""" """Plugin runtime connector"""
@@ -1654,6 +1660,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
client, client,
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{version}', f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{version}',
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES, max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
task_context=task_context,
) )
return package, version return package, version
+24
View File
@@ -0,0 +1,24 @@
"""Shared plugin operation errors without runtime or application imports."""
class PluginRuntimeNotConnectedError(RuntimeError):
"""Raised when plugin runtime operations are requested before connection."""
class PluginInstallationFailedError(RuntimeError):
"""Stable Runtime desired-state failure for one plugin installation."""
def __init__(
self,
installation_uuid: str,
error_code: str,
message: str,
) -> None:
self.installation_uuid = installation_uuid
self.error_code = error_code
self.runtime_message = message
super().__init__(f'Plugin installation {installation_uuid} failed [{error_code}]: {message}')
class MarketplacePluginVersionNotFoundError(ValueError):
"""The requested release is not available from the marketplace."""
@@ -7,11 +7,11 @@
"name": "model", "name": "model",
"description": { "description": {
"en_US": "Primary/fallback model UUIDs and per-model reasoning levels. Host validates and applies reasoning for every request; this is per-pipeline configuration.", "en_US": "Primary/fallback model UUIDs and per-model reasoning levels. Host validates and applies reasoning for every request; this is per-pipeline configuration.",
"zh_Hans": "\u4e3b\u6a21\u578b/\u5907\u7528\u6a21\u578b UUID \u4e0e\u5404\u6a21\u578b\u63a8\u7406\u7b49\u7ea7\u3002\u7531 Host \u5bf9\u6bcf\u6b21\u8bf7\u6c42\u6821\u9a8c\u5e76\u5e94\u7528\uff0c\u5c5e\u4e8e\u6d41\u6c34\u7ebf\u72ec\u7acb\u914d\u7f6e\u3002" "zh_Hans": "主模型/备用模型 UUID 与各模型推理等级。由 Host 对每次请求校验并应用,属于流水线独立配置。"
}, },
"label": { "label": {
"en_US": "Model", "en_US": "Model",
"zh_Hans": "\u6a21\u578b" "zh_Hans": "模型"
}, },
"type": "model-fallback-selector", "type": "model-fallback-selector",
"required": true, "required": true,
@@ -25,7 +25,7 @@
"name": "prompt", "name": "prompt",
"label": { "label": {
"en_US": "Prompt", "en_US": "Prompt",
"zh_Hans": "\u63d0\u793a\u8bcd" "zh_Hans": "提示词"
}, },
"type": "prompt-editor", "type": "prompt-editor",
"required": true, "required": true,
@@ -40,21 +40,122 @@
"name": "knowledge-bases", "name": "knowledge-bases",
"label": { "label": {
"en_US": "Knowledge Bases", "en_US": "Knowledge Bases",
"zh_Hans": "\u77e5\u8bc6\u5e93" "zh_Hans": "知识库"
}, },
"type": "knowledge-base-multi-selector", "type": "knowledge-base-multi-selector",
"required": false, "required": false,
"default": [] "default": []
}, },
{
"name": "box-enabled",
"label": {
"en_US": "Sandbox",
"zh_Hans": "沙箱"
},
"description": {
"en_US": "Use Box for code execution and file processing.",
"zh_Hans": "使用 Box 执行代码和处理文件。"
},
"type": "boolean",
"required": false,
"default": true
},
{
"name": "box-session-id-template",
"label": {
"en_US": "Sandbox reuse",
"zh_Hans": "沙箱复用范围"
},
"description": {
"en_US": "Choose how sandbox files and environments are shared. Custom templates substitute {variable_name}, for example {launcher_type}_{launcher_id}_{sender_id}. Equal results reuse the same sandbox; {global} shares one sandbox throughout the workspace. Available variables: {launcher_type}, {launcher_id}, {sender_id}, {conversation_id}, {bot_id}, {query_id}, {run_id}, {event_id}, {event_type}, and public request variables. Only named variables are supported, not expressions or attribute access; unavailable variables cause an error.",
"zh_Hans": "选择沙箱文件和环境的共享方式。自定义模板使用 {变量名} 插值,如 {launcher_type}_{launcher_id}_{sender_id};插值结果相同就复用同一个沙箱,{global} 表示在工作区内全局共享。可用变量:{launcher_type}(会话类型)、{launcher_id}(会话 ID)、{sender_id}(用户 ID)、{conversation_id}(对话上下文 ID)、{bot_id}(机器人 ID)、{query_id}(请求 ID)、{run_id}(运行 ID)、{event_id}(事件 ID)、{event_type}(事件类型),也可引用公开请求变量。仅支持变量名,不支持运算或属性访问;变量不可用时会报错。"
},
"type": "select",
"allow_custom": true,
"required": true,
"default": "{launcher_type}_{launcher_id}",
"options": [
{
"name": "{global}",
"label": {
"en_US": "Global (shared by all)",
"zh_Hans": "全局(所有人共享)",
"zh_Hant": "全域(所有人共用)",
"ja_JP": "グローバル(全員共有)",
"vi_VN": "Toàn cục (chia sẻ cho tất cả)",
"th_TH": "ทั่วไป (แชร์ทั้งหมด)",
"es_ES": "Global (compartido por todos)",
"ru_RU": "Глобальный (общий для всех)"
}
},
{
"name": "{launcher_type}_{launcher_id}",
"label": {
"en_US": "Per chat (Recommended)",
"zh_Hans": "每个会话(推荐)",
"zh_Hant": "每個會話(推薦)",
"ja_JP": "チャットごと(推奨)",
"vi_VN": "Mỗi cuộc trò chuyện (Khuyến nghị)",
"th_TH": "ต่อแชท (แนะนำ)",
"es_ES": "Por chat (Recomendado)",
"ru_RU": "По чату (Рекомендуется)"
}
},
{
"name": "{launcher_type}_{launcher_id}_{sender_id}",
"label": {
"en_US": "Per user in chat",
"zh_Hans": "会话中每个用户",
"zh_Hant": "會話中每個用戶",
"ja_JP": "チャット内のユーザーごと",
"vi_VN": "Mỗi người dùng trong cuộc trò chuyện",
"th_TH": "ต่อผู้ใช้ในแชท",
"es_ES": "Por usuario en chat",
"ru_RU": "По пользователю в чате"
}
},
{
"name": "{launcher_type}_{launcher_id}_{conversation_id}",
"label": {
"en_US": "Per conversation context",
"zh_Hans": "每个对话上下文",
"zh_Hant": "每個對話上下文",
"ja_JP": "会話コンテキストごと",
"vi_VN": "Mỗi ngữ cảnh hội thoại",
"th_TH": "ต่อบริบทการสนทนา",
"es_ES": "Por contexto de conversación",
"ru_RU": "По контексту разговора"
}
},
{
"name": "{query_id}",
"label": {
"en_US": "Per message (isolated)",
"zh_Hans": "每条消息(完全隔离)",
"zh_Hant": "每條訊息(完全隔離)",
"ja_JP": "メッセージごと(隔離)",
"vi_VN": "Mỗi tin nhắn (cách ly)",
"th_TH": "ต่อข้อความ (แยกส่วน)",
"es_ES": "Por mensaje (aislado)",
"ru_RU": "По сообщению (изолированно)"
}
}
],
"show_if": {
"field": "box-enabled",
"operator": "eq",
"value": true
}
},
{ {
"name": "advanced-settings", "name": "advanced-settings",
"label": { "label": {
"en_US": "Advanced Settings", "en_US": "Advanced Settings",
"zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e" "zh_Hans": "高级设置"
}, },
"description": { "description": {
"en_US": "Show tuning controls for retrieval, tools, timeouts, and context management.", "en_US": "Show tuning controls for retrieval, tools, timeouts, and context management.",
"zh_Hans": "\u663e\u793a\u68c0\u7d22\u3001\u5de5\u5177\u3001\u8d85\u65f6\u548c\u4e0a\u4e0b\u6587\u7ba1\u7406\u7684\u8c03\u4f18\u9009\u9879\u3002" "zh_Hans": "显示检索、工具、超时和上下文管理的调优选项。"
}, },
"type": "boolean", "type": "boolean",
"required": false, "required": false,
@@ -64,11 +165,11 @@
"name": "date-grounding", "name": "date-grounding",
"label": { "label": {
"en_US": "Current Date Grounding", "en_US": "Current Date Grounding",
"zh_Hans": "\u5f53\u524d\u65e5\u671f\u951a\u5b9a" "zh_Hans": "当前日期锚定"
}, },
"description": { "description": {
"en_US": "Add the current UTC date and a reminder to verify time-sensitive facts with available search tools.", "en_US": "Add the current UTC date and a reminder to verify time-sensitive facts with available search tools.",
"zh_Hans": "\u6ce8\u5165\u5f53\u524d UTC \u65e5\u671f\uff0c\u5e76\u63d0\u9192\u4f7f\u7528\u53ef\u7528\u641c\u7d22\u5de5\u5177\u6838\u5b9e\u6709\u65f6\u6548\u6027\u7684\u4fe1\u606f\u3002" "zh_Hans": "注入当前 UTC 日期,并提醒使用可用搜索工具核实有时效性的信息。"
}, },
"type": "boolean", "type": "boolean",
"required": false, "required": false,
@@ -83,7 +184,7 @@
"name": "timeout", "name": "timeout",
"label": { "label": {
"en_US": "Timeout", "en_US": "Timeout",
"zh_Hans": "\u6267\u884c\u8d85\u65f6" "zh_Hans": "执行超时"
}, },
"type": "integer", "type": "integer",
"required": false, "required": false,
@@ -98,7 +199,7 @@
"name": "remove-think", "name": "remove-think",
"label": { "label": {
"en_US": "Remove Thinking Output", "en_US": "Remove Thinking Output",
"zh_Hans": "\u79fb\u9664\u601d\u8003\u5185\u5bb9" "zh_Hans": "移除思考内容"
}, },
"type": "boolean", "type": "boolean",
"required": false, "required": false,
@@ -113,7 +214,7 @@
"name": "retrieval-top-k", "name": "retrieval-top-k",
"label": { "label": {
"en_US": "Retrieval Top K", "en_US": "Retrieval Top K",
"zh_Hans": "\u77e5\u8bc6\u5e93\u68c0\u7d22\u6570\u91cf" "zh_Hans": "知识库检索数量"
}, },
"type": "integer", "type": "integer",
"required": false, "required": false,
@@ -128,7 +229,7 @@
"name": "rerank-model", "name": "rerank-model",
"label": { "label": {
"en_US": "Rerank Model", "en_US": "Rerank Model",
"zh_Hans": "\u91cd\u6392\u5e8f\u6a21\u578b" "zh_Hans": "重排序模型"
}, },
"type": "rerank-model-selector", "type": "rerank-model-selector",
"required": false, "required": false,
@@ -143,7 +244,7 @@
"name": "rerank-top-k", "name": "rerank-top-k",
"label": { "label": {
"en_US": "Rerank Top K", "en_US": "Rerank Top K",
"zh_Hans": "\u91cd\u6392\u5e8f\u4fdd\u7559\u6570\u91cf" "zh_Hans": "重排序保留数量"
}, },
"type": "integer", "type": "integer",
"required": false, "required": false,
@@ -158,7 +259,7 @@
"name": "max-tool-iterations", "name": "max-tool-iterations",
"label": { "label": {
"en_US": "Max Tool Iterations", "en_US": "Max Tool Iterations",
"zh_Hans": "\u6700\u5927\u5de5\u5177\u8c03\u7528\u8f6e\u6570" "zh_Hans": "最大工具调用轮数"
}, },
"type": "integer", "type": "integer",
"required": false, "required": false,
@@ -173,11 +274,11 @@
"name": "tool-execution-mode", "name": "tool-execution-mode",
"description": { "description": {
"en_US": "Parallel is faster for independent tools. Use serial for dependent or side-effecting actions; result order does not guarantee execution order.", "en_US": "Parallel is faster for independent tools. Use serial for dependent or side-effecting actions; result order does not guarantee execution order.",
"zh_Hans": "\u5e76\u884c\u9002\u5408\u72ec\u7acb\u5de5\u5177\uff1b\u6709\u4f9d\u8d56\u6216\u526f\u4f5c\u7528\u7684\u64cd\u4f5c\u8bf7\u9009\u62e9\u4e32\u884c\u3002\u7ed3\u679c\u6392\u5217\u4e0d\u4fdd\u8bc1\u6267\u884c\u987a\u5e8f\u3002" "zh_Hans": "并行适合独立工具;有依赖或副作用的操作请选择串行。结果排列不保证执行顺序。"
}, },
"label": { "label": {
"en_US": "Tool Execution Mode", "en_US": "Tool Execution Mode",
"zh_Hans": "\u5de5\u5177\u6267\u884c\u6a21\u5f0f" "zh_Hans": "工具执行模式"
}, },
"type": "select", "type": "select",
"required": false, "required": false,
@@ -187,14 +288,14 @@
"name": "parallel", "name": "parallel",
"label": { "label": {
"en_US": "Parallel", "en_US": "Parallel",
"zh_Hans": "\u5e76\u884c" "zh_Hans": "并行"
} }
}, },
{ {
"name": "serial", "name": "serial",
"label": { "label": {
"en_US": "Serial", "en_US": "Serial",
"zh_Hans": "\u4e32\u884c" "zh_Hans": "串行"
} }
} }
], ],
@@ -208,7 +309,7 @@
"name": "max-tool-result-chars", "name": "max-tool-result-chars",
"label": { "label": {
"en_US": "Max Tool Result Characters", "en_US": "Max Tool Result Characters",
"zh_Hans": "\u6700\u5927\u5de5\u5177\u7ed3\u679c\u5b57\u7b26\u6570" "zh_Hans": "最大工具结果字符数"
}, },
"type": "integer", "type": "integer",
"required": false, "required": false,
@@ -223,7 +324,7 @@
"name": "context-history-fetch-limit", "name": "context-history-fetch-limit",
"label": { "label": {
"en_US": "History Fetch Limit", "en_US": "History Fetch Limit",
"zh_Hans": "\u5386\u53f2\u6d88\u606f\u62c9\u53d6\u6570\u91cf" "zh_Hans": "历史消息拉取数量"
}, },
"type": "integer", "type": "integer",
"required": false, "required": false,
@@ -238,7 +339,7 @@
"name": "context-window-tokens", "name": "context-window-tokens",
"label": { "label": {
"en_US": "Context Window Tokens", "en_US": "Context Window Tokens",
"zh_Hans": "\u4e0a\u4e0b\u6587\u7a97\u53e3 Token \u6570" "zh_Hans": "上下文窗口 Token 数"
}, },
"type": "integer", "type": "integer",
"required": false, "required": false,
@@ -253,7 +354,7 @@
"name": "context-reserve-tokens", "name": "context-reserve-tokens",
"label": { "label": {
"en_US": "Reserved Output Tokens", "en_US": "Reserved Output Tokens",
"zh_Hans": "\u8f93\u51fa\u4fdd\u7559 Token \u6570" "zh_Hans": "输出保留 Token 数"
}, },
"type": "integer", "type": "integer",
"required": false, "required": false,
@@ -268,7 +369,7 @@
"name": "context-keep-recent-tokens", "name": "context-keep-recent-tokens",
"label": { "label": {
"en_US": "Recent Context Tokens", "en_US": "Recent Context Tokens",
"zh_Hans": "\u6700\u8fd1\u4e0a\u4e0b\u6587 Token \u6570" "zh_Hans": "最近上下文 Token 数"
}, },
"type": "integer", "type": "integer",
"required": false, "required": false,
@@ -283,7 +384,7 @@
"name": "context-summary-tokens", "name": "context-summary-tokens",
"label": { "label": {
"en_US": "Summary Tokens", "en_US": "Summary Tokens",
"zh_Hans": "\u6458\u8981 Token \u6570" "zh_Hans": "摘要 Token 数"
}, },
"type": "integer", "type": "integer",
"required": false, "required": false,
@@ -864,3 +864,90 @@ async def test_all_mode_rejects_duplicate_tasks_and_unconfirmed_requests(env):
with pytest.raises(env.m.MigrationError, match='migration_running'): with pytest.raises(env.m.MigrationError, match='migration_running'):
await execute_all(env) await execute_all(env)
env.svc._all_tasks.clear() env.svc._all_tasks.clear()
@pytest.mark.asyncio
@pytest.mark.parametrize('stage', ['discovery', 'install', 'validation'])
async def test_disconnected_runtime_has_specific_error_without_modifying_sources(env, stage):
from langbot.pkg.plugin.connector import PluginRuntimeNotConnectedError
error = PluginRuntimeNotConnectedError('private connection diagnostics')
if stage == 'discovery':
env.ap.runner_registry.list_runners.side_effect = error
elif stage == 'install':
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=error)
else:
env.svc._verify_runtime = AsyncMock(side_effect=error)
_, metadata = await execute_all(env)
assert all(r['code'] == 'plugin_runtime_unavailable' for r in metadata['results'])
assert 'private connection diagnostics' not in str(metadata)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and configs['two'] == SOURCE
assert not backups
def test_install_progress_records_stages_without_exposing_diagnostics():
from langbot.pkg.api.http.service.pipeline_migration import MigrationInstallContext
ctx = MigrationInstallContext({'author': 'team', 'name': 'Runner', 'version': '1.0'})
ctx.trace('private-token', action='downloading plugin package')
ctx.metadata.update(download_current=120, download_total=240, progress_percent=15, url='private-token')
ctx.set_current_action('validating plugin package')
ctx.metadata['progress_percent'] = float('nan')
ctx.finish('plugin_install_failed')
assert ctx.progress['download_current'] == 120
assert ctx.progress['progress_percent'] == 15
assert [step['stage'] for step in ctx.progress['steps']] == ['checking', 'downloading', 'validating']
assert all(step['finished_at'] is not None for step in ctx.progress['steps'])
assert ctx.progress['status'] == 'failed'
assert 'private-token' not in str(ctx.progress)
@pytest.mark.asyncio
async def test_install_progress_visible_while_download_is_running(env):
import asyncio
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
started, release = asyncio.Event(), asyncio.Event()
async def install(source, info, task_context):
task_context.set_current_action('downloading plugin package')
task_context.metadata.update(download_current=123, download_total=456)
started.set()
await release.wait()
raise RuntimeError('private-token')
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install)
response = await env.svc.execute(context(), {'confirmed': True, 'all': True, 'install_plugins': True})
task = env.ap.task_mgr.get_task_by_id(response['task_id'])
try:
await asyncio.wait_for(started.wait(), 3)
await asyncio.sleep(0.25)
progress = task.task_context.metadata['installations'][0]
assert progress['status'] == 'installing'
assert progress['download_current'] == 123
assert progress['stage'] == 'downloading'
finally:
release.set()
await task.task
assert len(task.task_context.metadata['installations']) == 1
assert progress['status'] == 'failed'
assert 'private-token' not in str(task.task_context.to_dict())
@pytest.mark.asyncio
async def test_missing_marketplace_release_has_specific_migration_result(env):
from langbot.pkg.plugin.connector import MarketplacePluginVersionNotFoundError
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
env.ap.plugin_connector.install_plugin = AsyncMock(
side_effect=MarketplacePluginVersionNotFoundError('private-token')
)
_, metadata = await execute_all(env)
assert all(r['code'] == 'plugin_version_unavailable' for r in metadata['results'])
assert metadata['installations'][0]['code'] == 'plugin_version_unavailable'
assert 'private-token' not in str(metadata)
@@ -168,3 +168,76 @@ async def test_local_descriptor_migrates_through_detached_task(local, monkeypatc
assert configs['one'] == plan['config'] assert configs['one'] == plan['config']
assert len(snapshots) == 1 and snapshots[0]['source_snapshot']['config'] == base.SOURCE assert len(snapshots) == 1 and snapshots[0]['source_snapshot']['config'] == base.SOURCE
env.ap.pipeline_mgr.publish_pipeline.assert_called_once() env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
'template',
[
None,
'{global}',
'{launcher_type}_{launcher_id}',
'{launcher_type}_{launcher_id}_{sender_id}',
'{launcher_type}_{launcher_id}_{conversation_id}',
'{query_id}',
'{bot_id}_{launcher_type}_{launcher_id}',
'project-{project}',
],
)
@pytest.mark.parametrize('install_plugins', [False, True])
async def test_box_templates_roundtrip_in_both_migration_modes(local, monkeypatch, template, install_plugins):
from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline
env, descriptor, _, _ = local
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline)
section = {'model': 'model', 'prompt': [], 'enable-all-tools': False, 'tools': []}
if template is not None:
section['box-session-id-template'] = template
source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': section}}
plan = plan_legacy_pipeline(source)
descriptor.plugin_version = plan['target_plugin']['version']
async with env.engine.begin() as conn:
await conn.execute(
sa.update(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'one').values(config=source)
)
await conn.execute(sa.delete(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'two'))
if install_plugins:
await conn.execute(
sa.insert(base.PluginSetting).values(
workspace_uuid=WS,
plugin_author='langbot-team',
plugin_name='LocalAgent',
enabled=True,
)
)
if not install_plugins:
env.ap.runner_registry.list_runners.side_effect = AssertionError('Data-only must work offline')
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=AssertionError('Data-only must not install'))
_, result = await base.execute_all(env, install_plugins=install_plugins)
assert result['results'] == [
{
'pipeline_uuid': 'one',
'state': 'migrated',
'code': None if install_plugins else 'data_only',
}
]
configs, snapshots = await base.rows(env)
saved = configs['one']['ai']['runner_config'][plan['target_runner_id']]
effective = {field['name']: field['default'] for field in descriptor.config_schema if 'default' in field}
effective.update(saved)
assert effective['box-enabled'] is True
assert effective['box-session-id-template'] == (template or '{launcher_type}_{launcher_id}')
assert snapshots[0]['source_snapshot']['config'] == source
assert 'box-session-id-template' not in configs['one']['ai']
@pytest.mark.asyncio
@pytest.mark.parametrize('allow_custom', [False, None, 'true'])
async def test_select_requires_explicit_custom_support(local, allow_custom):
env, descriptor, config, plan = local
field = next(f for f in descriptor.config_schema if f['name'] == 'box-session-id-template')
field['allow_custom'] = allow_custom
config['box-session-id-template'] = '{bot_id}_{launcher_id}'
async with env.pm.tenant_scope(WS):
with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'):
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
@@ -992,3 +992,13 @@ def test_local_box_reuse_templates_are_preserved_for_plugin(template):
result = plan(source) result = plan(source)
assert result['state'] != 'blocked', result assert result['state'] != 'blocked', result
assert result['config']['ai']['runner_config'][result['target_runner_id']]['box-session-id-template'] == template assert result['config']['ai']['runner_config'][result['target_runner_id']]['box-session-id-template'] == template
@pytest.mark.parametrize(
'template', ['', ' ', '{}', '{0}', '{sender_id', '{sender_id!r}', '{query_id:04}', '{actor.id}', '{actor[id]}']
)
def test_invalid_box_templates_are_reported_before_migration(template):
source = source_for('local-agent')
source['ai']['local-agent']['box-session-id-template'] = template
result = plan(source)
assert_block(result, 'local.box_template_invalid', 'ai.local-agent.box-session-id-template')
@@ -62,4 +62,6 @@ def test_empty_box_template_requires_correction():
source['ai']['local-agent']['box-session-id-template'] = '' source['ai']['local-agent']['box-session-id-template'] = ''
result = plan_legacy_pipeline(source) result = plan_legacy_pipeline(source)
assert result['state'] == 'blocked' assert result['state'] == 'blocked'
assert {'code': 'invalid_type', 'field': 'ai.local-agent.box-session-id-template'} in result['blockers'] assert {'code': 'local.box_template_invalid', 'field': 'ai.local-agent.box-session-id-template'} in result[
'blockers'
]
@@ -163,3 +163,32 @@ async def test_marketplace_response_reader_is_bounded():
with pytest.raises(ValueError, match='exceeds'): with pytest.raises(ValueError, match='exceeds'):
await _read_httpx_response_limited(response, max_bytes=4) await _read_httpx_response_limited(response, max_bytes=4)
@pytest.mark.asyncio
async def test_marketplace_missing_release_is_classified_without_upstream_message():
from langbot.pkg.plugin.connector import _marketplace_get, MarketplacePluginVersionNotFoundError
async with httpx.AsyncClient(
transport=httpx.MockTransport(
lambda r: httpx.Response(500, json={'msg': 'plugin version not found: record not found private-token'})
)
) as client:
with pytest.raises(MarketplacePluginVersionNotFoundError) as exc:
await _marketplace_get(client, 'https://market.test/download', max_bytes=4096)
assert 'private-token' not in str(exc.value)
@pytest.mark.asyncio
async def test_marketplace_download_reports_byte_progress():
from langbot.pkg.plugin.connector import _marketplace_get
from langbot.pkg.core.taskmgr import TaskContext
ctx = TaskContext.new()
async with httpx.AsyncClient(
transport=httpx.MockTransport(lambda r: httpx.Response(200, content=b'x' * 1024))
) as client:
status, body = await _marketplace_get(client, 'https://market.test/download', max_bytes=2048, task_context=ctx)
assert status == 200 and len(body) == 1024
assert ctx.metadata['download_current'] == ctx.metadata['download_total'] == 1024
assert ctx.metadata['download_speed'] > 0
Generated
+2 -2
View File
@@ -2180,7 +2180,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=94c098ee0d1c4535043d9bf4a74c25f90849bc41" }, { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9" },
{ name = "langchain", specifier = ">=1.3.9" }, { name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2251,7 +2251,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.6.0b2" version = "0.6.0b2"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=94c098ee0d1c4535043d9bf4a74c25f90849bc41#94c098ee0d1c4535043d9bf4a74c25f90849bc41" } source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9#a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "aiohttp" }, { name = "aiohttp" },
@@ -0,0 +1,213 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
CheckCircle2,
ChevronDown,
Loader2,
Puzzle,
XCircle,
} from 'lucide-react';
import type { MigrationInstallation } from '@/app/infra/entities/api/pipeline-migration';
import type { PluginV4 } from '@/app/infra/entities/plugin';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
const stageKeys: Record<string, string> = {
checking: 'pipelineMigration.checkingPlugin',
downloading: 'plugins.installProgress.downloading',
validating: 'plugins.installProgress.validating',
preparing: 'pipelineMigration.preparingPlugin',
installing_deps: 'plugins.installProgress.installingDeps',
activating: 'plugins.installProgress.initializing',
refreshing: 'plugins.installProgress.activating',
done: 'plugins.installProgress.completed',
};
function bytes(value: number) {
return value < 1024 * 1024
? `${(value / 1024).toFixed(1)} KB`
: `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function InstallationPluginIdentity({ item }: { item: MigrationInstallation }) {
const { t } = useTranslation();
const [plugin, setPlugin] = useState<PluginV4 | null>(null);
useEffect(() => {
let active = true;
void getCloudServiceClientSync()
.getPluginDetail(item.author, item.name)
.then(({ plugin }) => {
if (active) setPlugin(plugin);
})
.catch(() => {
// Marketplace metadata is optional and must not interrupt installation.
});
return () => {
active = false;
};
}, [item.author, item.name]);
const name = (plugin?.label && extractI18nObject(plugin.label)) || item.name;
const description =
(plugin?.description && extractI18nObject(plugin.description)) ||
t('market.noDescription');
const iconURL = getCloudServiceClientSync().resolveMarketplaceIconURL(
'plugin',
item.author,
item.name,
plugin?.icon,
);
return (
<div className="flex items-start gap-3">
<Avatar className="size-10 rounded-lg">
<AvatarImage src={iconURL} alt={name} className="object-contain" />
<AvatarFallback className="rounded-lg">
<Puzzle className="size-5 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span
className="min-w-0 flex-1 truncate text-sm font-medium"
title={name}
>
{name}
</span>
<Badge variant="secondary" className="shrink-0">
{item.version}
</Badge>
</div>
<p
className="line-clamp-2 break-words text-xs text-muted-foreground"
title={description}
>
{description}
</p>
</div>
</div>
);
}
export default function MigrationInstallProgress({
installations,
}: {
installations: MigrationInstallation[];
}) {
const { t } = useTranslation();
if (!installations.length) return null;
return (
<div className="space-y-2" data-testid="migration-installations">
{installations.map((item) => {
const failed = item.status === 'failed';
const done = item.status === 'completed';
const Icon = failed ? XCircle : done ? CheckCircle2 : Loader2;
const label = t(
stageKeys[item.stage] ?? 'pipelineMigration.installing',
);
const end = item.finished_at ?? item.updated_at;
const seconds = (start: number, finish: number) =>
`${Math.max(0, finish - start).toFixed(1)} s`;
return (
<Collapsible
key={`${item.author}/${item.name}/${item.version}`}
className="rounded-md border p-3"
>
<InstallationPluginIdentity item={item} />
<div className="mt-3 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Icon
className={`size-3.5 shrink-0 ${failed ? 'text-destructive' : !done ? 'animate-spin' : 'text-green-600'}`}
/>
{failed ? t('plugins.installProgress.failed') : label}
</span>
<span className="tabular-nums">
{seconds(item.started_at, end)}
</span>
</div>
{!failed && !done && (
<Progress
className="mt-2 h-1.5"
value={item.progress_percent ?? 0}
aria-label={t('pipelineMigration.stageProgress')}
/>
)}
{failed && (
<p className="mt-2 text-xs text-destructive">
{t(
item.code === 'plugin_runtime_unavailable'
? 'pipelineMigration.notices.runtimeUnavailable'
: `pipelineMigration.installErrors.${item.code}`,
{ defaultValue: t('pipelineMigration.installFailed') },
)}
</p>
)}
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="group mt-1 h-7 px-0 text-xs text-muted-foreground hover:bg-transparent"
>
<ChevronDown className="size-3 group-data-[state=open]:rotate-180" />
{t('pipelineMigration.installDetails')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-1 text-xs">
<p className="break-all text-muted-foreground">
{item.author}/{item.name}
</p>
{item.steps.map((step, index) => (
<div key={index} className="flex justify-between gap-2">
<span
className={
failed && index === item.steps.length - 1
? 'text-destructive'
: ''
}
>
{t(stageKeys[step.stage] ?? 'pipelineMigration.installing')}
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{seconds(step.started_at, step.finished_at ?? end)}
</span>
</div>
))}
{item.download_current !== undefined && (
<p className="text-muted-foreground">
{t('pipelineMigration.downloaded', {
size: bytes(item.download_current),
})}
{item.download_total
? ` / ${bytes(item.download_total)}`
: ''}
{item.status === 'installing' &&
item.stage === 'downloading' &&
item.download_speed
? ` · ${bytes(item.download_speed)}/s`
: ''}
</p>
)}
{item.deps_total !== undefined && (
<p className="text-muted-foreground">
{t('plugins.installProgress.depsInfo', {
count: item.deps_total,
})}
</p>
)}
</CollapsibleContent>
</Collapsible>
);
})}
</div>
);
}
@@ -3,9 +3,11 @@ import { useTranslation } from 'react-i18next';
import { AlertTriangle, ChevronDown, Loader2 } from 'lucide-react'; import { AlertTriangle, ChevronDown, Loader2 } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore'; import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
import MigrationInstallProgress from './MigrationInstallProgress';
import { migrationIssueKey } from './pipeline-migration-issues'; import { migrationIssueKey } from './pipeline-migration-issues';
import type { CurrentWorkspace } from '@/app/infra/entities/workspace'; import type { CurrentWorkspace } from '@/app/infra/entities/workspace';
import type { import type {
MigrationInstallation,
PipelineMigrationIssue, PipelineMigrationIssue,
PipelineMigrationPreview, PipelineMigrationPreview,
PipelineMigrationResult, PipelineMigrationResult,
@@ -70,6 +72,9 @@ export default function PipelineMigration({
const [phase, setPhase] = useState('migrating'); const [phase, setPhase] = useState('migrating');
const [dataOnly, setDataOnly] = useState(false); const [dataOnly, setDataOnly] = useState(false);
const [status, setStatus] = useState<Status>('idle'); const [status, setStatus] = useState<Status>('idle');
const [installations, setInstallations] = useState<MigrationInstallation[]>(
[],
);
const [results, setResults] = useState<PipelineMigrationResult[]>([]); const [results, setResults] = useState<PipelineMigrationResult[]>([]);
const active = useRef(false); const active = useRef(false);
const submitting = useRef(false); const submitting = useRef(false);
@@ -135,6 +140,12 @@ export default function PipelineMigration({
}, [refresh]); }, [refresh]);
const busy = status === 'submitting' || status === 'running'; const busy = status === 'submitting' || status === 'running';
const completed =
status === 'finished' &&
results.length > 0 &&
results.every((item) =>
['migrated', 'already_current'].includes(item.state),
);
const rows = preview?.items ?? []; const rows = preview?.items ?? [];
const count = rows.filter( const count = rows.filter(
(item) => !['already_current', 'not_legacy'].includes(item.state), (item) => !['already_current', 'not_legacy'].includes(item.state),
@@ -142,6 +153,17 @@ export default function PipelineMigration({
function renderIssue(issue: PipelineMigrationIssue, warning = false) { function renderIssue(issue: PipelineMigrationIssue, warning = false) {
// Unknown server codes use localized fallbacks, never raw upstream messages. // Unknown server codes use localized fallbacks, never raw upstream messages.
if (
[
'plugin_version_unavailable',
'plugin_download_timeout',
'plugin_marketplace_unavailable',
'plugin_download_failed',
'dependency_prepare_failed',
'plugin_launch_failed',
].includes(issue.code)
)
return <span>{t(`pipelineMigration.installErrors.${issue.code}`)}</span>;
if (issue.code === 'plugin_install_failed') if (issue.code === 'plugin_install_failed')
return <span>{t('pipelineMigration.installFailed')}</span>; return <span>{t('pipelineMigration.installFailed')}</span>;
const key = migrationIssueKey(issue.code); const key = migrationIssueKey(issue.code);
@@ -169,6 +191,7 @@ export default function PipelineMigration({
let items = rows.filter( let items = rows.filter(
(item) => !['already_current', 'not_legacy'].includes(item.state), (item) => !['already_current', 'not_legacy'].includes(item.state),
); );
setInstallations([]);
setDataOnly(!installPlugins); setDataOnly(!installPlugins);
setPhase(installPlugins ? 'installing' : 'migrating'); setPhase(installPlugins ? 'installing' : 'migrating');
submitting.current = true; submitting.current = true;
@@ -267,6 +290,8 @@ export default function PipelineMigration({
}, },
); );
setResults(scopedResults); setResults(scopedResults);
if (Array.isArray(metadata.installations))
setInstallations(metadata.installations as MigrationInstallation[]);
if (metadata.phase === 'installing' || metadata.phase === 'migrating') if (metadata.phase === 'installing' || metadata.phase === 'migrating')
setPhase(metadata.phase); setPhase(metadata.phase);
if (task.runtime.done) { if (task.runtime.done) {
@@ -309,7 +334,7 @@ export default function PipelineMigration({
function changeOpen(next: boolean) { function changeOpen(next: boolean) {
setOpen(next); setOpen(next);
if (!submitting.current) { if (!submitting.current) {
if (next) void refresh(status === 'idle'); if (next) void refresh();
} }
} }
@@ -386,7 +411,17 @@ export default function PipelineMigration({
{t('pipelineMigration.detected', { count })} {t('pipelineMigration.detected', { count })}
</p> </p>
)} )}
{!busy && (count > 0 || results.length > 0) && ( <MigrationInstallProgress installations={installations} />
{busy && results.length > 0 && (
<p className="text-xs text-muted-foreground">
{t('pipelineMigration.processed', {
completed: results.filter((r) => r.state !== 'pending')
.length,
total: results.length,
})}
</p>
)}
{(count > 0 || results.length > 0) && (
<Collapsible> <Collapsible>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
@@ -439,6 +474,14 @@ export default function PipelineMigration({
{item.target_plugin.name} {item.target_plugin.name}
</p> </p>
)} )}
{item.warnings.some(
(issue) =>
issue.code === 'local.box_state_reset',
) && (
<p className="text-xs text-muted-foreground">
{t('pipelineMigration.notices.boxReset')}
</p>
)}
{result?.code && result.code !== 'data_only' ? ( {result?.code && result.code !== 'data_only' ? (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
{renderIssue({ code: result.code })} {renderIssue({ code: result.code })}
@@ -472,39 +515,36 @@ export default function PipelineMigration({
)} )}
</div> </div>
<DialogFooter className="shrink-0 flex-col gap-2 sm:flex-col"> <DialogFooter className="shrink-0 flex-col gap-2 sm:flex-col">
{!busy && ( {completed ? (
<> <Button
<Button className="w-full"
disabled={!canManage || loading || !valid || !count} onClick={() => window.location.reload()}
className="w-full" >
onClick={() => void execute(true)} {t('pipelineMigration.complete')}
> </Button>
{t('pipelineMigration.autoInstall')} ) : (
</Button> !busy && (
<Button <>
variant="outline" <Button
disabled={!canManage || loading || !valid || !count} disabled={!canManage || loading || !valid || !count}
className="w-full" className="w-full"
onClick={() => void execute(false)} onClick={() => void execute(true)}
> >
{t('pipelineMigration.dataOnly')} {t('pipelineMigration.autoInstall')}
</Button> </Button>
<p className="text-center text-xs text-muted-foreground">
{t('pipelineMigration.dataOnlyHint')}
</p>
{(status !== 'idle' || previewError) && (
<Button <Button
variant="outline" variant="outline"
disabled={loading} disabled={!canManage || loading || !valid || !count}
onClick={() => { className="w-full"
setStatus('idle'); onClick={() => void execute(false)}
void refresh();
}}
> >
{t('pipelineMigration.refresh')} {t('pipelineMigration.dataOnly')}
</Button> </Button>
)} <p className="text-center text-xs text-muted-foreground">
</> {t('pipelineMigration.dataOnlyHint')}
</p>
</>
)
)} )}
<Button variant="ghost" onClick={() => changeOpen(false)}> <Button variant="ghost" onClick={() => changeOpen(false)}>
{t('common.close')} {t('common.close')}
@@ -1,4 +1,6 @@
const issueKeys: Record<string, string> = { const issueKeys: Record<string, string> = {
plugin_runtime_unavailable: 'runtimeUnavailable',
migration_failed: 'executionFailed',
plugin_missing: 'pluginRequired', plugin_missing: 'pluginRequired',
plugin_disabled: 'pluginRequired', plugin_disabled: 'pluginRequired',
'local.context_defaults': 'contextDefaults', 'local.context_defaults': 'contextDefaults',
@@ -25,7 +27,9 @@ const issueKeys: Record<string, string> = {
'runtime.schema_invalid': 'schemaChanged', 'runtime.schema_invalid': 'schemaChanged',
'runtime.schema_missing': 'schemaChanged', 'runtime.schema_missing': 'schemaChanged',
'extensions.runner_excluded': 'runnerExcluded', 'extensions.runner_excluded': 'runnerExcluded',
'local.box_scope': 'boxScope', 'local.box_template_invalid': 'boxTemplateInvalid',
runner_schema_incompatible: 'schemaChanged',
plugin_version_incompatible: 'pluginVersion',
'runtime.pending_interaction': 'pendingInteraction', 'runtime.pending_interaction': 'pendingInteraction',
}; };
@@ -55,4 +55,23 @@ export interface PipelineMigrationTaskMetadata {
kind: 'pipeline_migration'; kind: 'pipeline_migration';
phase?: 'installing' | 'migrating' | 'finished'; phase?: 'installing' | 'migrating' | 'finished';
results: PipelineMigrationResult[]; results: PipelineMigrationResult[];
installations?: MigrationInstallation[];
}
export interface MigrationInstallation {
author: string;
name: string;
version: string;
status: 'installing' | 'completed' | 'failed';
stage: string;
code: string | null;
progress_percent?: number;
download_current?: number;
download_total?: number;
download_speed?: number;
deps_total?: number;
started_at: number;
updated_at: number;
finished_at: number | null;
steps: { stage: string; started_at: number; finished_at: number | null }[];
} }
+1
View File
@@ -9,6 +9,7 @@ const Progress = React.forwardRef<
>(({ className, value, ...props }, ref) => ( >(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root <ProgressPrimitive.Root
ref={ref} ref={ref}
value={value}
className={cn( className={cn(
'relative h-2 w-full overflow-hidden rounded-full bg-primary/20', 'relative h-2 w-full overflow-hidden rounded-full bg-primary/20',
className, className,
@@ -1,4 +1,25 @@
export default { export default {
complete: 'Done',
checkingPlugin: 'Check plugin',
preparingPlugin: 'Prepare installation',
installDetails: 'Installation details',
stageProgress: 'Stage progress',
downloaded: 'Downloaded {{size}}',
processed: 'Processed {{completed}} / {{total}} pipelines',
installErrors: {
plugin_version_unavailable:
'The required plugin version is not available in the marketplace. Retry after it is published, or migrate data only.',
plugin_download_timeout:
'Plugin download timed out. Check the network and retry.',
plugin_marketplace_unavailable:
'The marketplace could not serve the plugin package. Retry later.',
plugin_download_failed:
'Could not download the plugin. Check the network and retry.',
dependency_prepare_failed:
'Plugin dependencies could not be installed. Check the runtime environment and retry.',
plugin_launch_failed:
'The plugin could not start. Check the runtime environment and retry.',
},
autoDescription: autoDescription:
'Legacy runners are now plugins. Migrate all pipelines while keeping your settings. Original configurations are backed up; conversations start fresh.', 'Legacy runners are now plugins. Migrate all pipelines while keeping your settings. Original configurations are backed up; conversations start fresh.',
viewPipelines: 'View pipelines', viewPipelines: 'View pipelines',
@@ -10,12 +31,15 @@ export default {
migrating: 'Migrating pipelines…', migrating: 'Migrating pipelines…',
summary: '{{migrated}} migrated; {{remaining}} need attention.', summary: '{{migrated}} migrated; {{remaining}} need attention.',
installFailed: installFailed:
'Plugin installation failed. Check your network and extension quota, then retry, or migrate data only.', 'Plugin installation failed. Expand the details to see the failed stage, then retry or migrate data only.',
activationRetryHint: activationRetryHint:
'After checking the runtime, refresh, select this pipeline and confirm to retry activation only. Its saved configuration will not be migrated again.', 'After checking the runtime, refresh, select this pipeline and confirm to retry activation only. Its saved configuration will not be migrated again.',
details: 'Migration details', details: 'Migration details',
notices: { notices: {
runtimeUnavailable:
'The plugin runtime is disconnected. Restore the connection and retry, or migrate data only.',
executionFailed: 'Migration failed. Check the server logs and retry.',
pluginRequired: pluginRequired:
'Install or enable the runner plugin shown above, then refresh the preview.', 'Install or enable the runner plugin shown above, then refresh the preview.',
legacyArchive: legacyArchive:
@@ -28,7 +52,7 @@ export default {
retrievalDefaults: retrievalDefaults:
'Retrieval uses the new top-k and result limits. Review them after migration.', 'Retrieval uses the new top-k and result limits. Review them after migration.',
boxReset: boxReset:
'Existing Box session state is not transferred; a new isolated session will be created.', 'Sandbox reuse settings are preserved. Existing container state is not migrated; new sandboxes follow the same reuse rules.',
persistentHistory: persistentHistory:
'New conversations use persistent, isolated history. Existing remote history is not imported.', 'New conversations use persistent, isolated history. Existing remote history is not imported.',
tweaksDefault: 'Langflow tweaks default to an empty object.', tweaksDefault: 'Langflow tweaks default to an empty object.',
@@ -53,8 +77,8 @@ export default {
'The installed runner configuration does not match the migration target. Check the plugin version and refresh.', 'The installed runner configuration does not match the migration target. Check the plugin version and refresh.',
runnerExcluded: runnerExcluded:
'This pipeline excludes the required runner plugin. Update its extension settings first.', 'This pipeline excludes the required runner plugin. Update its extension settings first.',
boxScope: boxTemplateInvalid:
'A custom Box session template cannot be migrated safely. Remove it or review the isolation requirements first.', 'Invalid sandbox reuse template. Use {variable_name}; positional fields, format conversions and attribute access are not supported.',
pendingInteraction: pendingInteraction:
'A conversation is waiting for input. Complete or cancel it before migration.', 'A conversation is waiting for input. Complete or cancel it before migration.',
}, },
@@ -1,4 +1,24 @@
export default { export default {
complete: 'Finalizar',
checkingPlugin: 'Comprobar plugin',
preparingPlugin: 'Preparar instalación',
installDetails: 'Detalles de instalación',
stageProgress: 'Progreso por etapas',
downloaded: 'Descargado {{size}}',
processed: 'Procesados {{completed}} / {{total}} pipelines',
installErrors: {
plugin_version_unavailable:
'La versión requerida aún no está disponible en el mercado. Reintenta tras su publicación o migra solo los datos.',
plugin_download_timeout:
'La descarga agotó el tiempo de espera. Revisa la red y reintenta.',
plugin_marketplace_unavailable:
'El mercado no puede servir el paquete. Reintenta más tarde.',
plugin_download_failed: 'No se pudo descargar el plugin. Revisa la red.',
dependency_prepare_failed:
'No se pudieron instalar las dependencias. Revisa el entorno de ejecución.',
plugin_launch_failed:
'El plugin no pudo iniciarse. Revisa el entorno de ejecución.',
},
autoDescription: autoDescription:
'Los ejecutores antiguos ahora son plugins. Migra todos los pipelines conservando sus ajustes. Se guardará una copia de la configuración y las conversaciones empezarán de nuevo.', 'Los ejecutores antiguos ahora son plugins. Migra todos los pipelines conservando sus ajustes. Se guardará una copia de la configuración y las conversaciones empezarán de nuevo.',
viewPipelines: 'Ver pipelines', viewPipelines: 'Ver pipelines',
@@ -10,12 +30,16 @@ export default {
migrating: 'Migrando pipelines…', migrating: 'Migrando pipelines…',
summary: '{{migrated}} migrados; {{remaining}} requieren atención.', summary: '{{migrated}} migrados; {{remaining}} requieren atención.',
installFailed: installFailed:
'No se pudieron instalar los plugins. Comprueba la red y la cuota de extensiones y reintenta, o migra solo los datos.', 'La instalación falló. Consulta la etapa fallida en los detalles y reintenta, o migra solo los datos.',
activationRetryHint: activationRetryHint:
'Tras comprobar el entorno, actualiza, selecciona esta canalización y confirma para reintentar solo la activación. No se volverá a migrar la configuración guardada.', 'Tras comprobar el entorno, actualiza, selecciona esta canalización y confirma para reintentar solo la activación. No se volverá a migrar la configuración guardada.',
details: 'Detalles de migración', details: 'Detalles de migración',
notices: { notices: {
runtimeUnavailable:
'El entorno de plugins está desconectado. Restablece la conexión y reintenta, o migra solo los datos.',
executionFailed:
'La migración falló. Revisa los registros del servidor y reintenta.',
pluginRequired: pluginRequired:
'Instala o activa el plugin de ejecución indicado arriba y actualiza la vista previa.', 'Instala o activa el plugin de ejecución indicado arriba y actualiza la vista previa.',
legacyArchive: legacyArchive:
@@ -28,7 +52,7 @@ export default {
retrievalDefaults: retrievalDefaults:
'La recuperación usará los nuevos límites de top-k y longitud. Revísalos después de migrar.', 'La recuperación usará los nuevos límites de top-k y longitud. Revísalos después de migrar.',
boxReset: boxReset:
'No se transfiere el estado de Box; se creará una nueva sesión aislada.', 'Se conserva la configuración de reutilización del sandbox. El estado del contenedor no se migra; los nuevos sandboxes siguen las mismas reglas.',
persistentHistory: persistentHistory:
'Las nuevas conversaciones tendrán un historial persistente y aislado. No se importa el historial remoto anterior.', 'Las nuevas conversaciones tendrán un historial persistente y aislado. No se importa el historial remoto anterior.',
tweaksDefault: tweaksDefault:
@@ -59,8 +83,8 @@ export default {
'La configuración del Runner instalado no coincide con el destino. Revisa la versión del plugin y actualiza.', 'La configuración del Runner instalado no coincide con el destino. Revisa la versión del plugin y actualiza.',
runnerExcluded: runnerExcluded:
'Esta canalización excluye el plugin Runner necesario. Ajusta primero la configuración de extensiones.', 'Esta canalización excluye el plugin Runner necesario. Ajusta primero la configuración de extensiones.',
boxScope: boxTemplateInvalid:
'La plantilla personalizada de sesión de Box no se puede migrar de forma segura. Elimínala o revisa los requisitos de aislamiento.', 'Plantilla de reutilización inválida. Use {nombre_variable}; no se admiten campos posicionales, conversiones de formato ni acceso a atributos.',
pendingInteraction: pendingInteraction:
'Hay una conversación esperando una respuesta. Complétala o cancélala antes de migrar.', 'Hay una conversación esperando una respuesta. Complétala o cancélala antes de migrar.',
}, },
@@ -1,4 +1,25 @@
export default { export default {
complete: '完了',
checkingPlugin: 'プラグインを確認',
preparingPlugin: 'インストールを準備',
installDetails: 'インストール詳細',
stageProgress: '段階の進捗',
downloaded: 'ダウンロード済み {{size}}',
processed: '{{completed}} / {{total}} 件のパイプラインを処理済み',
installErrors: {
plugin_version_unavailable:
'必要なバージョンはまだマーケットにありません。公開後に再試行するかデータのみ移行してください。',
plugin_download_timeout:
'ダウンロードがタイムアウトしました。ネットワークを確認して再試行してください。',
plugin_marketplace_unavailable:
'マーケットからプラグインを取得できません。後で再試行してください。',
plugin_download_failed:
'ダウンロードに失敗しました。ネットワークを確認してください。',
dependency_prepare_failed:
'依存関係のインストールに失敗しました。実行環境を確認してください。',
plugin_launch_failed:
'プラグインを起動できません。実行環境を確認してください。',
},
autoDescription: autoDescription:
'従来の実行方式はプラグインになりました。設定を保持して全パイプラインを移行します。元の設定はバックアップされ、会話は新しく始まります。', '従来の実行方式はプラグインになりました。設定を保持して全パイプラインを移行します。元の設定はバックアップされ、会話は新しく始まります。',
viewPipelines: 'パイプラインを表示', viewPipelines: 'パイプラインを表示',
@@ -10,12 +31,16 @@ export default {
migrating: 'パイプラインを移行中…', migrating: 'パイプラインを移行中…',
summary: '{{migrated}} 件移行済み、{{remaining}} 件の確認が必要です。', summary: '{{migrated}} 件移行済み、{{remaining}} 件の確認が必要です。',
installFailed: installFailed:
'プラグインのインストールに失敗しました。ネットワークと拡張機能の上限を確認して再試行するか、データのみ移行してください。', 'インストールに失敗しました。詳細で失敗した段階を確認し、再試行するかデータのみ移行してください。',
activationRetryHint: activationRetryHint:
'実行環境を確認して再読み込みし、このパイプラインを選択して確定すると、有効化のみを再試行します。保存済み設定は再移行しません。', '実行環境を確認して再読み込みし、このパイプラインを選択して確定すると、有効化のみを再試行します。保存済み設定は再移行しません。',
details: '移行の詳細', details: '移行の詳細',
notices: { notices: {
runtimeUnavailable:
'プラグインランタイムが未接続です。接続を復旧して再試行するか、データのみ移行してください。',
executionFailed:
'移行に失敗しました。サーバーログを確認して再試行してください。',
pluginRequired: pluginRequired:
'上記のランナープラグインをインストールまたは有効化し、プレビューを更新してください。', '上記のランナープラグインをインストールまたは有効化し、プレビューを更新してください。',
legacyArchive: legacyArchive:
@@ -27,7 +52,7 @@ export default {
retrievalDefaults: retrievalDefaults:
'検索には新しい top-k と結果長の既定値を適用します。移行後に確認してください。', '検索には新しい top-k と結果長の既定値を適用します。移行後に確認してください。',
boxReset: boxReset:
'既存の Box セッション状態は引き継がず、新しい分離セッションを作成します。', 'サンドボックスの再利用設定は保持されます。既存コンテナの状態は移行せず、同じ再利用ルールで新しく作成されます。',
persistentHistory: persistentHistory:
'新しい会話の履歴は分離して永続化します。既存のリモート履歴は取り込みません。', '新しい会話の履歴は分離して永続化します。既存のリモート履歴は取り込みません。',
tweaksDefault: 'Langflow tweaks の既定値は空のオブジェクトです。', tweaksDefault: 'Langflow tweaks の既定値は空のオブジェクトです。',
@@ -53,8 +78,8 @@ export default {
'インストール済み Runner の設定形式が移行先と一致しません。プラグインのバージョンを確認してください。', 'インストール済み Runner の設定形式が移行先と一致しません。プラグインのバージョンを確認してください。',
runnerExcluded: runnerExcluded:
'このパイプラインでは必要な Runner プラグインが除外されています。拡張機能の設定を変更してください。', 'このパイプラインでは必要な Runner プラグインが除外されています。拡張機能の設定を変更してください。',
boxScope: boxTemplateInvalid:
'独自の Box セッションテンプレートは安全に移行できません。削除するか、分離要件を確認してください。', '再利用テンプレートが無効です。{変数名} を使用してください。位置引数、書式変換、属性アクセスは使用できません。',
pendingInteraction: pendingInteraction:
'入力待ちの会話があります。完了またはキャンセルしてから移行してください。', '入力待ちの会話があります。完了またはキャンセルしてから移行してください。',
}, },
@@ -1,4 +1,24 @@
export default { export default {
complete: 'Готово',
checkingPlugin: 'Проверка плагина',
preparingPlugin: 'Подготовка установки',
installDetails: 'Подробности установки',
stageProgress: 'Прогресс этапов',
downloaded: 'Загружено {{size}}',
processed: 'Обработано {{completed}} / {{total}} конвейеров',
installErrors: {
plugin_version_unavailable:
'Нужная версия ещё не опубликована в магазине. Повторите после публикации либо перенесите только данные.',
plugin_download_timeout:
'Время загрузки истекло. Проверьте сеть и повторите попытку.',
plugin_marketplace_unavailable:
'Магазин не может предоставить пакет. Повторите позже.',
plugin_download_failed: 'Не удалось загрузить плагин. Проверьте сеть.',
dependency_prepare_failed:
'Не удалось установить зависимости. Проверьте среду выполнения.',
plugin_launch_failed:
'Не удалось запустить плагин. Проверьте среду выполнения.',
},
autoDescription: autoDescription:
'Прежние исполнители стали плагинами. Все конвейеры будут перенесены с сохранением настроек. Исходные настройки будут скопированы, а разговоры начнутся заново.', 'Прежние исполнители стали плагинами. Все конвейеры будут перенесены с сохранением настроек. Исходные настройки будут скопированы, а разговоры начнутся заново.',
viewPipelines: 'Посмотреть конвейеры', viewPipelines: 'Посмотреть конвейеры',
@@ -10,12 +30,16 @@ export default {
migrating: 'Перенос конвейеров…', migrating: 'Перенос конвейеров…',
summary: 'Перенесено: {{migrated}}; требуют внимания: {{remaining}}.', summary: 'Перенесено: {{migrated}}; требуют внимания: {{remaining}}.',
installFailed: installFailed:
'Не удалось установить плагины. Проверьте сеть и квоту расширений и повторите попытку либо перенесите только данные.', 'Установка не удалась. Откройте подробности, проверьте этап сбоя и повторите попытку либо перенесите только данные.',
activationRetryHint: activationRetryHint:
'Проверьте среду выполнения, обновите список, выберите конвейер и подтвердите повторную активацию. Сохранённая конфигурация не будет мигрировать повторно.', 'Проверьте среду выполнения, обновите список, выберите конвейер и подтвердите повторную активацию. Сохранённая конфигурация не будет мигрировать повторно.',
details: 'Подробности переноса', details: 'Подробности переноса',
notices: { notices: {
runtimeUnavailable:
'Среда плагинов отключена. Восстановите подключение и повторите попытку или перенесите только данные.',
executionFailed:
'Миграция не удалась. Проверьте журнал сервера и повторите попытку.',
pluginRequired: pluginRequired:
'Установите или включите указанный выше плагин исполнителя, затем обновите предпросмотр.', 'Установите или включите указанный выше плагин исполнителя, затем обновите предпросмотр.',
legacyArchive: legacyArchive:
@@ -28,7 +52,7 @@ export default {
retrievalDefaults: retrievalDefaults:
'Для поиска применяются новые значения top-k и ограничения длины результатов. Проверьте их после миграции.', 'Для поиска применяются новые значения top-k и ограничения длины результатов. Проверьте их после миграции.',
boxReset: boxReset:
'Состояние сеанса Box не переносится; будет создан новый изолированный сеанс.', 'Настройки повторного использования песочницы сохраняются. Состояние контейнера не переносится; новые песочницы используют те же правила.',
persistentHistory: persistentHistory:
'Новые беседы используют постоянную изолированную историю. Прежняя удалённая история не импортируется.', 'Новые беседы используют постоянную изолированную историю. Прежняя удалённая история не импортируется.',
tweaksDefault: tweaksDefault:
@@ -56,8 +80,8 @@ export default {
'Схема установленного Runner не соответствует целевой. Проверьте версию плагина и обновите список.', 'Схема установленного Runner не соответствует целевой. Проверьте версию плагина и обновите список.',
runnerExcluded: runnerExcluded:
'В этом конвейере исключён нужный плагин Runner. Сначала измените настройки расширений.', 'В этом конвейере исключён нужный плагин Runner. Сначала измените настройки расширений.',
boxScope: boxTemplateInvalid:
'Пользовательский шаблон сеанса Box нельзя безопасно перенести. Удалите его или проверьте требования изоляции.', 'Неверный шаблон песочницы. Используйте {имя_переменной}; позиционные поля, преобразования формата и доступ к атрибутам не поддерживаются.',
pendingInteraction: pendingInteraction:
'Беседа ожидает ввода. Завершите или отмените её перед миграцией.', 'Беседа ожидает ввода. Завершите или отмените её перед миграцией.',
}, },
@@ -1,4 +1,24 @@
export default { export default {
complete: 'เสร็จสิ้น',
checkingPlugin: 'ตรวจสอบปลั๊กอิน',
preparingPlugin: 'เตรียมติดตั้ง',
installDetails: 'รายละเอียดการติดตั้ง',
stageProgress: 'ความคืบหน้าแต่ละขั้นตอน',
downloaded: 'ดาวน์โหลดแล้ว {{size}}',
processed: 'ดำเนินการแล้ว {{completed}} / {{total}} ไปป์ไลน์',
installErrors: {
plugin_version_unavailable:
'เวอร์ชันที่ต้องการยังไม่มีในตลาด กรุณาลองใหม่หลังเผยแพร่หรือย้ายเฉพาะข้อมูล',
plugin_download_timeout:
'การดาวน์โหลดหมดเวลา กรุณาตรวจสอบเครือข่ายแล้วลองใหม่',
plugin_marketplace_unavailable:
'ตลาดไม่สามารถให้แพ็กเกจปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
plugin_download_failed: 'ดาวน์โหลดปลั๊กอินไม่สำเร็จ กรุณาตรวจสอบเครือข่าย',
dependency_prepare_failed:
'ติดตั้งส่วนพึ่งพาไม่สำเร็จ กรุณาตรวจสอบสภาพแวดล้อมรันไทม์',
plugin_launch_failed:
'เริ่มปลั๊กอินไม่สำเร็จ กรุณาตรวจสอบสภาพแวดล้อมรันไทม์',
},
autoDescription: autoDescription:
'รันเนอร์เดิมเปลี่ยนเป็นปลั๊กอินแล้ว ย้ายไปป์ไลน์ทั้งหมดโดยคงการตั้งค่าเดิม ระบบจะสำรองการตั้งค่าและเริ่มการสนทนาใหม่', 'รันเนอร์เดิมเปลี่ยนเป็นปลั๊กอินแล้ว ย้ายไปป์ไลน์ทั้งหมดโดยคงการตั้งค่าเดิม ระบบจะสำรองการตั้งค่าและเริ่มการสนทนาใหม่',
viewPipelines: 'ดูไปป์ไลน์', viewPipelines: 'ดูไปป์ไลน์',
@@ -10,12 +30,16 @@ export default {
migrating: 'กำลังย้ายไปป์ไลน์…', migrating: 'กำลังย้ายไปป์ไลน์…',
summary: 'ย้ายแล้ว {{migrated}} รายการ ต้องตรวจสอบ {{remaining}} รายการ', summary: 'ย้ายแล้ว {{migrated}} รายการ ต้องตรวจสอบ {{remaining}} รายการ',
installFailed: installFailed:
'ติดตั้งปลั๊กอินไม่สำเร็จ ตรวจสอบเครือข่ายและโควตาส่วนขยายแล้วลองใหม่ หรือย้ายเฉพาะข้อมูล', 'ติดตั้งไม่สำเร็จ เปิดรายละเอียดเพื่อดูขั้นตอนที่ล้มเหลว แล้วลองใหม่หรือย้ายเฉพาะข้อมูล',
activationRetryHint: activationRetryHint:
'หลังตรวจสอบสภาพแวดล้อมการทำงาน ให้รีเฟรช เลือกไปป์ไลน์นี้และยืนยันเพื่อลองเปิดใช้งานอีกครั้งเท่านั้น การตั้งค่าที่บันทึกไว้จะไม่ถูกย้ายซ้ำ', 'หลังตรวจสอบสภาพแวดล้อมการทำงาน ให้รีเฟรช เลือกไปป์ไลน์นี้และยืนยันเพื่อลองเปิดใช้งานอีกครั้งเท่านั้น การตั้งค่าที่บันทึกไว้จะไม่ถูกย้ายซ้ำ',
details: 'รายละเอียดการย้าย', details: 'รายละเอียดการย้าย',
notices: { notices: {
runtimeUnavailable:
'ไม่ได้เชื่อมต่อรันไทม์ปลั๊กอิน โปรดเชื่อมต่อใหม่แล้วลองอีกครั้ง หรือย้ายเฉพาะข้อมูล',
executionFailed:
'การย้ายข้อมูลล้มเหลว โปรดตรวจสอบบันทึกเซิร์ฟเวอร์แล้วลองอีกครั้ง',
pluginRequired: pluginRequired:
'ติดตั้งหรือเปิดใช้งานปลั๊กอินรันเนอร์ที่แสดงด้านบน แล้วรีเฟรชตัวอย่าง', 'ติดตั้งหรือเปิดใช้งานปลั๊กอินรันเนอร์ที่แสดงด้านบน แล้วรีเฟรชตัวอย่าง',
legacyArchive: legacyArchive:
@@ -27,7 +51,8 @@ export default {
serialTools: 'หลังย้ายข้อมูล เครื่องมือยังคงทำงานตามลำดับ', serialTools: 'หลังย้ายข้อมูล เครื่องมือยังคงทำงานตามลำดับ',
retrievalDefaults: retrievalDefaults:
'การค้นคืนจะใช้ค่า top-k และขีดจำกัดความยาวผลลัพธ์ใหม่ โปรดตรวจสอบหลังย้ายข้อมูล', 'การค้นคืนจะใช้ค่า top-k และขีดจำกัดความยาวผลลัพธ์ใหม่ โปรดตรวจสอบหลังย้ายข้อมูล',
boxReset: 'สถานะเซสชัน Box เดิมจะไม่ถูกย้าย ระบบจะสร้างเซสชันแยกใหม่', boxReset:
'คงการตั้งค่าการใช้แซนด์บ็อกซ์ซ้ำไว้ ไม่ย้ายสถานะคอนเทนเนอร์เดิม แซนด์บ็อกซ์ใหม่จะใช้กฎการใช้ซ้ำเดิม',
persistentHistory: persistentHistory:
'บทสนทนาใหม่จะมีประวัติถาวรที่แยกจากกัน โดยไม่นำเข้าประวัติระยะไกลเดิม', 'บทสนทนาใหม่จะมีประวัติถาวรที่แยกจากกัน โดยไม่นำเข้าประวัติระยะไกลเดิม',
tweaksDefault: 'Langflow tweaks จะใช้วัตถุว่างเป็นค่าเริ่มต้น', tweaksDefault: 'Langflow tweaks จะใช้วัตถุว่างเป็นค่าเริ่มต้น',
@@ -50,8 +75,8 @@ export default {
'รูปแบบการตั้งค่า Runner ที่ติดตั้งไม่ตรงกับเป้าหมาย โปรดตรวจสอบเวอร์ชันปลั๊กอินแล้วรีเฟรช', 'รูปแบบการตั้งค่า Runner ที่ติดตั้งไม่ตรงกับเป้าหมาย โปรดตรวจสอบเวอร์ชันปลั๊กอินแล้วรีเฟรช',
runnerExcluded: runnerExcluded:
'ไปป์ไลน์นี้ยกเว้นปลั๊กอิน Runner ที่จำเป็น โปรดแก้ไขการตั้งค่าส่วนขยายก่อน', 'ไปป์ไลน์นี้ยกเว้นปลั๊กอิน Runner ที่จำเป็น โปรดแก้ไขการตั้งค่าส่วนขยายก่อน',
boxScope: boxTemplateInvalid:
'ไม่สามารถย้ายเทมเพลตเซสชัน Box แบบกำหนดเองได้อย่างปลอดภัย โปรดลบหรือทบทวนข้อกำหนดการแยกเซสชัน', 'รูปแบบเทมเพลตไม่ถูกต้อง ใช้ {ชื่อตัวแปร} โดยไม่ใช้ฟิลด์ตามตำแหน่ง การแปลงรูปแบบ หรือการเข้าถึงแอตทริบิวต์',
pendingInteraction: pendingInteraction:
'มีบทสนทนาที่รอข้อมูลอยู่ โปรดดำเนินการให้เสร็จหรือยกเลิกก่อนย้ายข้อมูล', 'มีบทสนทนาที่รอข้อมูลอยู่ โปรดดำเนินการให้เสร็จหรือยกเลิกก่อนย้ายข้อมูล',
}, },
@@ -1,4 +1,24 @@
export default { export default {
complete: 'Hoàn tất',
checkingPlugin: 'Kiểm tra plugin',
preparingPlugin: 'Chuẩn bị cài đặt',
installDetails: 'Chi tiết cài đặt',
stageProgress: 'Tiến độ từng giai đoạn',
downloaded: 'Đã tải {{size}}',
processed: 'Đã xử lý {{completed}} / {{total}} pipeline',
installErrors: {
plugin_version_unavailable:
'Phiên bản cần thiết chưa có trên chợ. Thử lại sau khi phát hành hoặc chỉ chuyển dữ liệu.',
plugin_download_timeout:
'Tải plugin quá thời gian chờ. Kiểm tra mạng rồi thử lại.',
plugin_marketplace_unavailable:
'Chợ chưa thể cung cấp gói plugin. Vui lòng thử lại sau.',
plugin_download_failed: 'Không thể tải plugin. Vui lòng kiểm tra mạng.',
dependency_prepare_failed:
'Không thể cài các thư viện phụ thuộc. Kiểm tra môi trường chạy.',
plugin_launch_failed:
'Không thể khởi động plugin. Kiểm tra môi trường chạy.',
},
autoDescription: autoDescription:
'Các runner cũ đã chuyển thành plugin. Chuyển đổi tất cả pipeline và giữ lại thiết lập. Cấu hình cũ sẽ được sao lưu; hội thoại sẽ bắt đầu lại.', 'Các runner cũ đã chuyển thành plugin. Chuyển đổi tất cả pipeline và giữ lại thiết lập. Cấu hình cũ sẽ được sao lưu; hội thoại sẽ bắt đầu lại.',
viewPipelines: 'Xem pipeline', viewPipelines: 'Xem pipeline',
@@ -10,12 +30,16 @@ export default {
migrating: 'Đang chuyển đổi pipeline…', migrating: 'Đang chuyển đổi pipeline…',
summary: 'Đã chuyển {{migrated}}; {{remaining}} cần xử lý.', summary: 'Đã chuyển {{migrated}}; {{remaining}} cần xử lý.',
installFailed: installFailed:
'Cài plugin thất bại. Kiểm tra mạng và hạn mức tiện ích rồi thử lại, hoặc chỉ chuyển đổi dữ liệu.', 'Cài đặt thất bại. Mở chi tiết để xem giai đoạn lỗi rồi thử lại, hoặc chỉ chuyển dữ liệu.',
activationRetryHint: activationRetryHint:
'Sau khi kiểm tra môi trường chạy, làm mới, chọn pipeline này và xác nhận để chỉ thử kích hoạt lại. Cấu hình đã lưu sẽ không được di chuyển lần nữa.', 'Sau khi kiểm tra môi trường chạy, làm mới, chọn pipeline này và xác nhận để chỉ thử kích hoạt lại. Cấu hình đã lưu sẽ không được di chuyển lần nữa.',
details: 'Chi tiết chuyển đổi', details: 'Chi tiết chuyển đổi',
notices: { notices: {
runtimeUnavailable:
'Môi trường chạy plugin bị ngắt kết nối. Khôi phục kết nối rồi thử lại, hoặc chỉ di chuyển dữ liệu.',
executionFailed:
'Di chuyển thất bại. Kiểm tra nhật ký máy chủ rồi thử lại.',
pluginRequired: pluginRequired:
'Cài đặt hoặc bật plugin runner ở trên, rồi làm mới bản xem trước.', 'Cài đặt hoặc bật plugin runner ở trên, rồi làm mới bản xem trước.',
legacyArchive: legacyArchive:
@@ -28,7 +52,7 @@ export default {
retrievalDefaults: retrievalDefaults:
'Truy xuất dùng giới hạn top-k và độ dài kết quả mới. Hãy kiểm tra sau khi di chuyển.', 'Truy xuất dùng giới hạn top-k và độ dài kết quả mới. Hãy kiểm tra sau khi di chuyển.',
boxReset: boxReset:
'Trạng thái phiên Box hiện tại không được chuyển; một phiên cách ly mới sẽ được tạo.', 'Giữ nguyên cấu hình tái sử dụng sandbox. Không chuyển trạng thái container cũ; sandbox mới dùng cùng quy tắc tái sử dụng.',
persistentHistory: persistentHistory:
'Hội thoại mới dùng lịch sử bền vững và riêng biệt. Lịch sử từ xa cũ không được nhập.', 'Hội thoại mới dùng lịch sử bền vững và riêng biệt. Lịch sử từ xa cũ không được nhập.',
tweaksDefault: 'Langflow tweaks mặc định là một đối tượng rỗng.', tweaksDefault: 'Langflow tweaks mặc định là một đối tượng rỗng.',
@@ -53,8 +77,8 @@ export default {
'Cấu hình Runner đã cài không khớp với đích di chuyển. Kiểm tra phiên bản plugin rồi làm mới.', 'Cấu hình Runner đã cài không khớp với đích di chuyển. Kiểm tra phiên bản plugin rồi làm mới.',
runnerExcluded: runnerExcluded:
'Pipeline này loại trừ plugin Runner cần thiết. Hãy chỉnh thiết lập tiện ích trước.', 'Pipeline này loại trừ plugin Runner cần thiết. Hãy chỉnh thiết lập tiện ích trước.',
boxScope: boxTemplateInvalid:
'Mẫu phiên Box tùy chỉnh không thể di chuyển an toàn. Hãy xóa mẫu hoặc kiểm tra yêu cầu cách ly.', 'Mẫu tái sử dụng không hợp lệ. Dùng {tên_biến}; không hỗ trợ trường vị trí, chuyển đổi định dạng hoặc truy cập thuộc tính.',
pendingInteraction: pendingInteraction:
'Có hội thoại đang chờ nhập liệu. Hãy hoàn tất hoặc hủy trước khi di chuyển.', 'Có hội thoại đang chờ nhập liệu. Hãy hoàn tất hoặc hủy trước khi di chuyển.',
}, },
@@ -1,4 +1,20 @@
export default { export default {
complete: '完成',
checkingPlugin: '检查插件',
preparingPlugin: '准备安装',
installDetails: '安装详情',
stageProgress: '阶段进度',
downloaded: '已下载 {{size}}',
processed: '已处理 {{completed}} / {{total}} 条流水线',
installErrors: {
plugin_version_unavailable:
'扩展市场尚未提供所需插件版本,请等待上架后重试,或选择仅迁移数据。',
plugin_download_timeout: '下载插件超时,请检查网络后重试。',
plugin_marketplace_unavailable: '扩展市场暂时无法提供插件包,请稍后重试。',
plugin_download_failed: '插件下载失败,请检查网络后重试。',
dependency_prepare_failed: '插件依赖安装失败,请检查运行时环境后重试。',
plugin_launch_failed: '插件启动失败,请检查运行时环境后重试。',
},
autoDescription: autoDescription:
'旧版运行方式已改为插件。迁移全部流水线并保留现有设置,原配置会自动备份;迁移后开始新会话。', '旧版运行方式已改为插件。迁移全部流水线并保留现有设置,原配置会自动备份;迁移后开始新会话。',
viewPipelines: '查看流水线', viewPipelines: '查看流水线',
@@ -8,12 +24,16 @@ export default {
installing: '正在安装所需插件…', installing: '正在安装所需插件…',
migrating: '正在迁移流水线…', migrating: '正在迁移流水线…',
summary: '已迁移 {{migrated}} 条,{{remaining}} 条需要处理。', summary: '已迁移 {{migrated}} 条,{{remaining}} 条需要处理。',
installFailed: '插件安装失败,请检查网络和扩展配额后重试,或选择仅迁移数据。', installFailed:
'插件安装失败。展开详情查看失败阶段,处理后重试,或选择仅迁移数据。',
activationRetryHint: activationRetryHint:
'检查运行环境后刷新,选中此流水线并确认,即可仅重试激活,不会再次迁移已保存的配置。', '检查运行环境后刷新,选中此流水线并确认,即可仅重试激活,不会再次迁移已保存的配置。',
details: '迁移详情', details: '迁移详情',
notices: { notices: {
runtimeUnavailable:
'插件运行时未连接,请恢复连接后重试,或选择仅迁移数据。',
executionFailed: '迁移执行失败,请查看服务日志后重试。',
pluginRequired: '请先安装或启用上方所示的运行器插件,再刷新预览。', pluginRequired: '请先安装或启用上方所示的运行器插件,再刷新预览。',
legacyArchive: legacyArchive:
'活动配置仅保留所选 Runner;全部旧 Runner 配置(包括未启用的配置)保存在迁移备份中。', '活动配置仅保留所选 Runner;全部旧 Runner 配置(包括未启用的配置)保存在迁移备份中。',
@@ -22,7 +42,8 @@ export default {
modelReasoning: '各模型的推理设置会保留,并由主程序应用。', modelReasoning: '各模型的推理设置会保留,并由主程序应用。',
serialTools: '迁移后工具调用仍按顺序执行。', serialTools: '迁移后工具调用仍按顺序执行。',
retrievalDefaults: '检索采用新的 top-k 与结果长度默认值,请在迁移后检查。', retrievalDefaults: '检索采用新的 top-k 与结果长度默认值,请在迁移后检查。',
boxReset: '现有 Box 会话状态不迁移,后续将创建新的隔离会话。', boxReset:
'沙箱复用范围会保留;旧容器状态不迁移,后续按原复用规则创建沙箱。',
persistentHistory: persistentHistory:
'新会话采用持久化、相互隔离的历史记录,不导入原有远端历史。', '新会话采用持久化、相互隔离的历史记录,不导入原有远端历史。',
tweaksDefault: 'Langflow tweaks 默认设为空对象。', tweaksDefault: 'Langflow tweaks 默认设为空对象。',
@@ -40,7 +61,8 @@ export default {
schemaChanged: schemaChanged:
'已安装 Runner 的配置格式与迁移目标不一致,请检查插件版本后刷新。', '已安装 Runner 的配置格式与迁移目标不一致,请检查插件版本后刷新。',
runnerExcluded: '此流水线排除了所需 Runner 插件,请先调整扩展设置。', runnerExcluded: '此流水线排除了所需 Runner 插件,请先调整扩展设置。',
boxScope: '自定义 Box 会话模板无法安全迁移,请先移除或检查隔离要求。', boxTemplateInvalid:
'沙箱复用模板格式有误,请使用 {变量名};不支持位置参数、格式转换或属性访问。',
pendingInteraction: '有会话正在等待输入,请完成或取消后再迁移。', pendingInteraction: '有会话正在等待输入,请完成或取消后再迁移。',
}, },
title: '流水线迁移', title: '流水线迁移',
@@ -1,4 +1,21 @@
export default { export default {
complete: '完成',
checkingPlugin: '檢查外掛',
preparingPlugin: '準備安裝',
installDetails: '安裝詳情',
stageProgress: '階段進度',
downloaded: '已下載 {{size}}',
processed: '已處理 {{completed}} / {{total}} 條流水線',
installErrors: {
plugin_version_unavailable:
'擴充市集尚未提供所需外掛版本,請等待上架後重試,或選擇僅遷移資料。',
plugin_download_timeout: '下載外掛逾時,請檢查網路後重試。',
plugin_marketplace_unavailable:
'擴充市集暫時無法提供外掛套件,請稍後重試。',
plugin_download_failed: '外掛下載失敗,請檢查網路後重試。',
dependency_prepare_failed: '外掛相依套件安裝失敗,請檢查執行環境後重試。',
plugin_launch_failed: '外掛啟動失敗,請檢查執行環境後重試。',
},
autoDescription: autoDescription:
'舊版執行方式已改為外掛。遷移全部流水線並保留現有設定,原設定會自動備份;遷移後開始新對話。', '舊版執行方式已改為外掛。遷移全部流水線並保留現有設定,原設定會自動備份;遷移後開始新對話。',
viewPipelines: '查看流水線', viewPipelines: '查看流水線',
@@ -8,12 +25,16 @@ export default {
installing: '正在安裝所需外掛…', installing: '正在安裝所需外掛…',
migrating: '正在遷移流水線…', migrating: '正在遷移流水線…',
summary: '已遷移 {{migrated}} 條,{{remaining}} 條需要處理。', summary: '已遷移 {{migrated}} 條,{{remaining}} 條需要處理。',
installFailed: '外掛安裝失敗,請檢查網路和擴充配額後重試,或選擇僅遷移資料。', installFailed:
'外掛安裝失敗。展開詳情查看失敗階段,處理後重試,或選擇僅遷移資料。',
activationRetryHint: activationRetryHint:
'檢查執行環境後重新整理,選取此流水線並確認,即可只重試啟用,不會再次遷移已儲存的設定。', '檢查執行環境後重新整理,選取此流水線並確認,即可只重試啟用,不會再次遷移已儲存的設定。',
details: '遷移詳情', details: '遷移詳情',
notices: { notices: {
runtimeUnavailable:
'外掛執行環境未連線,請恢復連線後重試,或選擇僅遷移資料。',
executionFailed: '遷移執行失敗,請查看服務日誌後重試。',
pluginRequired: '請先安裝或啟用上方所示的執行器外掛,再重新整理預覽。', pluginRequired: '請先安裝或啟用上方所示的執行器外掛,再重新整理預覽。',
legacyArchive: legacyArchive:
'作用中的設定僅保留所選 Runner;全部舊 Runner 設定(包括未啟用的設定)保存在遷移備份中。', '作用中的設定僅保留所選 Runner;全部舊 Runner 設定(包括未啟用的設定)保存在遷移備份中。',
@@ -22,7 +43,8 @@ export default {
modelReasoning: '各模型的推理設定會保留,並由主程式套用。', modelReasoning: '各模型的推理設定會保留,並由主程式套用。',
serialTools: '遷移後工具呼叫仍依序執行。', serialTools: '遷移後工具呼叫仍依序執行。',
retrievalDefaults: '檢索採用新的 top-k 與結果長度預設值,請在遷移後檢查。', retrievalDefaults: '檢索採用新的 top-k 與結果長度預設值,請在遷移後檢查。',
boxReset: '現有 Box 工作階段狀態不遷移,後續將建立新的隔離工作階段。', boxReset:
'沙箱複用範圍會保留;舊容器狀態不遷移,後續按原複用規則建立沙箱。',
persistentHistory: persistentHistory:
'新對話採用持久化、相互隔離的歷史記錄,不匯入原有遠端歷史。', '新對話採用持久化、相互隔離的歷史記錄,不匯入原有遠端歷史。',
tweaksDefault: 'Langflow tweaks 預設為空物件。', tweaksDefault: 'Langflow tweaks 預設為空物件。',
@@ -40,7 +62,8 @@ export default {
schemaChanged: schemaChanged:
'已安裝 Runner 的設定格式與遷移目標不一致,請檢查外掛版本後重新整理。', '已安裝 Runner 的設定格式與遷移目標不一致,請檢查外掛版本後重新整理。',
runnerExcluded: '此流水線排除了所需 Runner 外掛,請先調整擴充設定。', runnerExcluded: '此流水線排除了所需 Runner 外掛,請先調整擴充設定。',
boxScope: '自訂 Box 工作階段範本無法安全遷移,請先移除或檢查隔離要求。', boxTemplateInvalid:
'沙箱複用範本格式有誤,請使用 {變數名};不支援位置參數、格式轉換或屬性存取。',
pendingInteraction: '有對話正在等待輸入,請完成或取消後再遷移。', pendingInteraction: '有對話正在等待輸入,請完成或取消後再遷移。',
}, },
title: '流水線遷移', title: '流水線遷移',
+147 -3
View File
@@ -5,6 +5,7 @@ const canonicalConfig = JSON.parse(
readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'), readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'),
); );
import type { import type {
MigrationInstallation,
PipelineMigrationItem, PipelineMigrationItem,
PipelineMigrationResult, PipelineMigrationResult,
PipelineMigrationState, PipelineMigrationState,
@@ -71,6 +72,7 @@ async function setup(
writes: [] as unknown[], writes: [] as unknown[],
previews: 0, previews: 0,
polls: 0, polls: 0,
installations: [] as MigrationInstallation[],
metadataReads: 0, metadataReads: 0,
pipelineReads: 0, pipelineReads: 0,
executeStatus: 200, executeStatus: 200,
@@ -151,7 +153,11 @@ async function setup(
exception: state.taskException ? 'unsafe-upstream-secret' : null, exception: state.taskException ? 'unsafe-upstream-secret' : null,
}, },
task_context: { task_context: {
metadata: { kind: 'pipeline_migration', results: state.results }, metadata: {
kind: 'pipeline_migration',
results: state.results,
installations: state.installations,
},
}, },
}); });
}); });
@@ -254,6 +260,14 @@ for (const install of [true, false]) {
await expect(dialog).toContainText( await expect(dialog).toContainText(
'Install the corresponding runner plugins yourself', 'Install the corresponding runner plugins yourself',
); );
await expect(installButton(page)).toHaveCount(0);
await expect(dataButton(page)).toHaveCount(0);
await Promise.all([
page.waitForEvent('load'),
dialog.getByRole('button', { name: 'Done', exact: true }).click(),
]);
await expect(dialog).toHaveCount(0);
expect(state.posts).toHaveLength(1);
}); });
} }
@@ -291,7 +305,7 @@ test('double clicks do not submit duplicate tasks; close does not cancel running
await expect(dialog).toContainText('2 migrated; 0 need attention.'); await expect(dialog).toContainText('2 migrated; 0 need attention.');
}); });
test('partial failures are reported without exposing upstream errors, and refresh allows retry', async ({ test('partial failures are reported without exposing upstream errors, and reopening allows retry', async ({
page, page,
}) => { }) => {
const state = await setup(page); const state = await setup(page);
@@ -308,7 +322,16 @@ test('partial failures are reported without exposing upstream errors, and refres
); );
await expect(dialog).not.toContainText('unsafe-upstream-secret'); await expect(dialog).not.toContainText('unsafe-upstream-secret');
await expect(installButton(page)).toBeEnabled(); await expect(installButton(page)).toBeEnabled();
await dialog.getByRole('button', { name: 'Refresh preview' }).click(); await expect(
dialog.getByRole('button', { name: 'Refresh preview' }),
).toHaveCount(0);
await dialog
.getByRole('button', { name: 'Close', exact: true })
.first()
.click();
await page
.getByRole('button', { name: 'Review migration', exact: true })
.click();
await expect(installButton(page)).toBeEnabled(); await expect(installButton(page)).toBeEnabled();
expect(state.posts).toHaveLength(1); expect(state.posts).toHaveLength(1);
}); });
@@ -552,3 +575,124 @@ test('SPEC actual legacy on second read remains guarded on the sidebar route', a
).toHaveCount(0); ).toHaveCount(0);
expect(state.writes).toEqual([]); expect(state.writes).toEqual([]);
}); });
test('Box reuse notice stays concise and template validation has an actionable message', async ({
page,
}) => {
const state = await setup(page);
state.items[0].legacy_runner = 'local-agent';
state.items[0].warnings = [
{
code: 'local.box_state_reset',
field: 'ai.local-agent.box-session-id-template',
},
];
state.items[1].state = 'blocked';
state.items[1].blockers = [
{
code: 'local.box_template_invalid',
field: 'ai.local-agent.box-session-id-template',
},
];
const dialog = await open(page);
await expect(
dialog.getByText('Sandbox reuse settings are preserved.', { exact: false }),
).not.toBeVisible();
await dialog.getByRole('button', { name: 'View pipelines' }).click();
await expect(
dialog.getByText('Sandbox reuse settings are preserved.', { exact: false }),
).toBeVisible();
await expect(
dialog.getByText('Invalid sandbox reuse template.', { exact: false }),
).toBeVisible();
await expect(dialog).not.toContainText('ai.local-agent');
await expect(dialog.getByRole('checkbox')).toHaveCount(0);
});
test('installation progress remains inspectable during work and after a missing release failure', async ({
page,
}) => {
const state = await setup(page);
await page.route(
'**/api/v1/marketplace/plugins/langbot-team/DifyAgent',
(route) =>
reply(route, {
plugin: {
author: 'langbot-team',
name: 'DifyAgent',
label: { en_US: 'Dify Agent' },
description: { en_US: 'Connect your Dify workflows.' },
icon: 'https://market.test/dify.svg',
},
}),
);
await page.route('https://market.test/dify.svg', (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32"/></svg>',
}),
);
state.done = false;
state.results = state.items.map((item) => ({
pipeline_uuid: item.pipeline_uuid,
state: 'pending',
code: null,
}));
state.installations = [
{
author: 'langbot-team',
name: 'DifyAgent',
version: '1.0.0',
status: 'installing',
stage: 'downloading',
code: null,
progress_percent: 15,
download_current: 1024,
download_total: 2048,
started_at: 100,
updated_at: 103,
finished_at: null,
steps: [
{ stage: 'checking', started_at: 100, finished_at: 101 },
{ stage: 'downloading', started_at: 101, finished_at: null },
],
},
];
const dialog = await open(page);
await installButton(page).click();
await expect(dialog.getByRole('progressbar')).toHaveAttribute(
'aria-valuenow',
'15',
);
await expect(dialog.getByText('Dify Agent', { exact: true })).toBeVisible();
await expect(
dialog.getByText('Connect your Dify workflows.', { exact: true }),
).toBeVisible();
await expect(dialog.getByRole('img', { name: 'Dify Agent' })).toBeVisible();
await dialog.getByRole('button', { name: 'Installation details' }).click();
await expect(dialog.getByText('Downloaded 1.0 KB / 2.0 KB')).toBeVisible();
await dialog.getByRole('button', { name: 'View pipelines' }).click();
await expect(dialog.getByText('Legacy one', { exact: true })).toBeVisible();
state.installations[0] = {
...state.installations[0],
status: 'failed',
code: 'plugin_version_unavailable',
finished_at: 104,
updated_at: 104,
};
state.results = state.items.map((item) => ({
pipeline_uuid: item.pipeline_uuid,
state: 'blocked',
code: 'plugin_version_unavailable',
}));
state.done = true;
await expect(
dialog
.getByTestId('migration-installations')
.getByText(
'The required plugin version is not available in the marketplace. Retry after it is published, or migrate data only.',
),
).toBeVisible();
await expect(dialog.getByRole('progressbar')).toHaveCount(0);
await expect(dialog.getByText('Downloaded 1.0 KB / 2.0 KB')).toBeVisible();
});
@@ -47,6 +47,12 @@ test('every converter warning has a specific explanation in all eight locales',
} }
assert.ok(catalog.notices.pluginVersion); assert.ok(catalog.notices.pluginVersion);
assert.ok(catalog.notices.pendingInteraction); assert.ok(catalog.notices.pendingInteraction);
for (const code of ['plugin_runtime_unavailable', 'migration_failed']) {
const key = migrationIssueKey(code);
assert.ok(key);
assert.equal(typeof catalog.notices[key], 'string');
assert.notEqual(catalog.notices[key], catalog.blockerFallback);
}
} }
}); });