Feat/sandbox (#2072)

* feat: add mcp and skills

* feat: add filter

* feat: modify frontend

* feat(box): add sandbox_exec tool loop for local-agent calculations

* feat(box): add host workspace mounting and sandbox_exec guidance

* feat(box): add BoxProfile with resource limits and improved output truncation

  - Implement head+tail output truncation (60/40 split) so LLM sees both
    beginning and final results; add streaming byte-limited reads in backend
    to prevent unbounded memory usage (_MAX_RAW_OUTPUT_BYTES = 1MB)
  - Define BoxProfile model with locked fields and max_timeout_sec clamping
  - Add four built-in profiles: default, offline_readonly, network_basic,
    network_extended with differentiated resource and security constraints
  - Add resource limit fields to BoxSpec (cpus, memory_mb, pids_limit,
    read_only_rootfs) and pass corresponding container CLI flags
    (--cpus, --memory, --pids-limit, --read-only, --tmpfs)
  - Profile loaded from config (box.profile), applied in service layer
    before BoxSpec validation; locked fields cannot be overridden by
    tool-call parameters

* feat(box): add obs

* refactor(box): unify box service lifecycle and local runtime
  management

* refactor(box): remove legacy in-process runtime code and clean up smells

After the architecture settled on always using an independent Box Runtime
service, several pieces of compatibility code and design shortcuts were
left behind. This commit cleans them up:

- Remove `LocalBoxRuntimeClient` and `create_box_runtime_client` from
  production code (moved to test-only helper).
- Remove unused `_clip_bytes` method from backend.
- Remove `__langbot_session_placeholder__` hack by making `BoxSpec.cmd`
  default to empty and validating non-empty only in `runtime.execute()`.
- Extract `get_box_config()` helper to eliminate 5× duplicated config
  access boilerplate.
- Remove `session_id`/`host_path`/`host_path_mode` from the LLM-facing
  tool schema to enforce request-scoped session isolation.
- Fix dual shutdown path: `NativeToolLoader.shutdown()` no longer calls
  `box_service.shutdown()` (handled by `Application.dispose()`).
- Simplify `_assert_session_compatible` with a loop.
- Inline client creation in `BoxRuntimeConnector`.
- Remove redundant `BOX__RUNTIME_URL` env var from docker-compose
  (auto-detected by code).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add test

* fix: fix box intergration test

* feat(box/mcp): integrate MCP stdio with Box sandbox — auto-isolation, dep install, security

  ## Summary

  When Podman/Docker is available, all stdio-mode MCP servers now automatically
  run inside Box containers with dependency installation, path rewriting, and
  lifecycle management. When no container runtime exists, LangBot starts normally
  and stdio MCP falls back to host-direct execution.

  ## What changed

  ### MCP stdio → Box integration (mcp.py)
  - Add `MCPServerBoxConfig` pydantic model for structured box configuration
    with validation and defaults (network, host_path_mode, timeouts, resources)
  - Auto-infer `host_path` from command/args with venv detection: recognizes
    `.venv/bin/python` patterns and walks up to the project root
  - Rewrite host paths to container `/workspace` paths transparently
  - Replace venv python commands with container-native `python`
  - Auto-detect `pyproject.toml`/`setup.py`/`requirements.txt` and run
    `pip install` inside the container before starting the MCP server
  - Copy project to `/tmp` before install to handle read-only mounts
  - Add retry with exponential backoff (3 retries, 2s/4s/8s delays)
  - Add Box managed process health monitoring (poll every 5s)
  - Fix session leak: `_cleanup_box_stdio_session()` now runs in `finally`
    block of `_lifecycle_loop`, covering all exit paths
  - Fix retry logic: `_ready_event` is only set after all retries exhaust
    or on success, not on first failure
  - Enhance `get_runtime_info_dict()` with `box_session_id` and `box_enabled`

  ### Box security (security.py — new)
  - `validate_sandbox_security()` blocks dangerous host paths:
    `/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/boot`, `/run`,
    docker.sock, podman socket
  - Called at the start of `CLISandboxBackend.start_session()`

  ### Box models (models.py)
  - Add `BoxHostMountMode.NONE` — skips volume mount entirely
  - Adjust `validate_host_mount_consistency` to allow arbitrary workdir
    when `host_path_mode=NONE`

  ### Box backend (backend.py)
  - Add `validate_sandbox_security()` call in `start_session()`
  - Add `langbot.box.config_hash` label on containers for drift detection
  - Handle `BoxHostMountMode.NONE` — skip `-v` mount arg
  - Add `cleanup_orphaned_containers()` to base class (no-op default) and
    CLI implementation (single batched `rm -f` command)

  ### Box runtime (runtime.py)
  - Call `cleanup_orphaned_containers()` during `initialize()` to remove
    lingering containers from previous runs

  ### Box service (service.py)
  - Graceful degradation: `initialize()` catches runtime errors and sets
    `available=False` instead of crashing LangBot startup
  - Add `available` property and guard on `execute_sandbox_tool()`
  - Add `skip_host_mount_validation` parameter to `build_spec()` and
    `create_session()` — MCP paths are admin-configured and trusted,
    bypassing `allowed_host_mount_roots` restrictions meant for
    LLM-generated sandbox_exec commands

  ### Default behavior
  - stdio MCP servers automatically use Box when `box_service.available`
    is True (Podman/Docker detected); no explicit `box` config needed
  - When no container runtime exists, falls back to host-direct stdio
  - MCP Box defaults: `network=on` (for pip install), `read_only_rootfs=false`
    (for site-packages), `host_path_mode=ro`, `startup_timeout=120s`

  ### Tests
  - `test_box_security.py`: blocked paths, safe paths, subpath rejection
  - `test_mcp_box_integration.py`: config model, path rewriting, venv
    unwrap, host_path inference, payload building, runtime info, box
    availability check
  - `test_box_service.py`: `BoxHostMountMode.NONE` validation tests

* feat(box/mcp): instance-based orphan cleanup, error classification, session API, and integration tests

  ## Changes

  ### Precise orphan container cleanup
  - Runtime generates a unique instance_id on startup
  - Every container gets a `langbot.box.instance_id` label
  - `cleanup_orphaned_containers()` only removes containers from
    previous instances, preserving containers owned by the current one
  - Containers from older versions (no label) are also cleaned up
  - `cleanup_orphaned_containers` added to `BaseSandboxBackend` as
    a no-op default method, removing hasattr duck-typing

  ### Fine-grained MCP error classification
  - New `MCPSessionErrorPhase` enum with 7 phases: session_create,
    dep_install, process_start, relay_connect, mcp_init, runtime,
    tool_call
  - Each phase in `_init_box_stdio_server()` sets the error phase
    before re-raising, enabling precise failure diagnosis
  - `retry_count` tracked across retry attempts
  - `get_runtime_info_dict()` exposes `error_phase` and `retry_count`

  ### GET /v1/sessions/{id} API
  - `BoxRuntime.get_session()` returns session details including
    managed process info when present
  - `handle_get_session` HTTP handler + route in server.py
  - `BoxRuntimeClient.get_session()` abstract method + remote impl

  ### stdio defaults to Box when runtime is available
  - `_uses_box_stdio()` checks `box_service.available` instead of
    requiring explicit `box` key in server_config
  - `BoxService.initialize()` catches runtime errors gracefully,
    sets `available=False` instead of crashing LangBot startup
  - When no container runtime exists, stdio MCP falls back to
    host-direct execution

  ### Code quality (from /simplify review)
  - Extracted `_VENV_DIRS` / `_VENV_BIN_DIRS` module-level constants
  - Removed dead `_box_network_mode()` method and unused `bc` variable
  - Fixed broken import `from ....box.models` → `from ...box.models`
  - Cached `_resolve_host_path()` result — computed once, passed through
  - Config hash now includes `host_path` field
  - Batched orphan cleanup into single `rm -f` command

  ### Session leak fix
  - `_cleanup_box_stdio_session()` now runs in `_lifecycle_loop`'s
    finally block, covering all exit paths (normal shutdown, error,
    retry, final failure)

  ### Integration tests
  - 6 end-to-end tests covering managed process lifecycle, WebSocket
    stdio bidirectional IO, session cleanup verification, single
    session query, process exit detection, and orphan cleanup safety

* refactor: use rpc

* fix: import

* refactor(box): clean up sandbox subsystem code quality and efficiency

  - Fix O(n²) stderr trimming in runtime.py with running length tracker
  - Remove dead code: RESERVED_CONTAINER_PATHS, _subprocess_wait_task,
    unused config_hash computation, unused imports
  - Deduplicate connection callback in BoxRuntimeConnector, parse URL once
  - Use enum comparison instead of stringly-typed spec.network.value check
  - Replace manual _result_to_dict/_session_to_dict with model_dump()
  - Cache NativeToolLoader tool definition and sandbox system guidance
  - Extract _is_path_under() helper to eliminate duplicated path checks
  - Import SANDBOX_EXEC_TOOL_NAME from native.py instead of redefining
  - Add JSON startswith guard in logging_utils to skip futile json.loads
  - Fix ruff lint errors (F401 unused imports, F841 unused variables)

* fix: ruff

* refactor(sandbox): keep box logic out of pipeline and localagent

  - Move sandbox system-prompt guidance from LocalAgentRunner into
    BoxService.get_system_guidance() so all box domain knowledge stays
    in the box module.
  - Remove standalone logging_utils.py; merge format_result_log() into
    MessageHandler base class alongside cut_str().
  - Strip sandbox-specific JSON parsing from log formatting; tool
    results now use generic truncation.
  - Revert TYPE_CHECKING changes in stage.py and runner.py that were
    unrelated to this feature.
  - Skip two test files affected by a pre-existing circular import
    (runner ↔ app) until the import cycle is resolved in a separate PR.

* fix: ruff

* refactor(box): move box runtime to langbot-plugin-sdk

  Extract self-contained box runtime modules (actions, backend, client,
  errors, models, runtime, security, server) to langbot-plugin-sdk and
  update all imports to use `langbot_plugin.box.*`. Keep only service
  and
  connector in LangBot core as they depend on the Application context.

  - Update docker-compose to use `langbot_plugin.box.server` entry
  point
  - Update pyproject.toml to use local SDK via `tool.uv.sources`
  - Remove migrated source files and their unit/integration tests
  - Update remaining test imports to match new module paths

* fix: ruff

* feat: enhance sandbox api

* refactor(box): derive paths from shared host root

* fix(box): tighten sandbox exposure and restore box integration coverage

* refactor(types): remove quoted annotations under postponed evaluation

* feat(box): unify native agent tools around exec/read/write/edit

* chore(sandbox): move MCP loader changes to follow-up branch

* feat(box): add session workspace quota enforcement and SDK quota metadata

* feat(skills): add Agent Skills management system (#1917)

* feat(skills): add Agent Skills management system

Implement comprehensive skills management feature inspired by agentskills spec:

Backend:
- Add Skill and SkillPipelineBinding database entities
- Add database migration (dbm018) for skills tables
- Implement SkillManager for skill loading, matching, and resolution
- Implement SkillService for CRUD operations
- Add skills API endpoints for skill and pipeline binding management
- Integrate skill index injection into pipeline preprocessor
- Add skill activation detection in LocalAgentRunner

Frontend:
- Add Skills page with listing, search, and type filter
- Add SkillDetailDialog for create/edit with preview
- Add SkillCard and SkillForm components
- Add skills API methods to BackendClient
- Add skills entry to sidebar navigation
- Add i18n translations (en-US, zh-Hans)

Features:
- Support skill and workflow types
- Sub-skill composition via {{INVOKE_SKILL: name}} syntax
- Progressive disclosure (index in prompt, full instructions on activation)
- Pipeline-specific skill bindings with priority

* fix: resolve cherry-pick conflicts for agentskills onto sandbox

- Remove non-existent external_kb service import
- Add skill_mgr mock to localagent sandbox_exec tests
- Keep database version at 24 (sandbox branch's latest)

* feat(skills): upgrade to package-backed skills with sandbox execution

  Evolve the skills system from pure prompt-based to package-backed with
  sandbox tool execution support:

  - Add source_type/package_root/entry_file/skill_tools fields to Skill entity
  - SkillManager loads SKILL.md from local package directories
  - SkillToolLoader as 4th dispatch layer in ToolManager (query-scoped)
  - LocalAgent injects skill tools into use_funcs on skill activation
  - BoxService.execute_skill_tool() runs scripts in sandbox (ro mount, env params)
  - Skill tool names auto-namespaced as skill__{skill}__{tool}
  - API validation for package_root allowlist and entry path traversal
  - Frontend source_type toggle, package_root input, skill_tools editor
  - Migration renumbered to 025 with ALTER TABLE fallback for existing DBs
  - Fix unclosed limitation section in i18n files
  - Fix skills API methods misplaced outside BackendClient class

* fix: test info

* feat(skills): switch skills to package-backed storage and add import tooling
  - skills 从 inline/package 双轨收敛成 package-first
  - instructions 改为写入并读取 SKILL.md
  - 新增本地目录扫描和 GitHub 安装 skill
  - 前端把 skills 整合进 plugins 页,新增 SkillsComponent 和 GitHub 导入弹窗
  - skill form 去掉 source_type / type 筛选,改成目录扫描驱动
  - Box skill tool 挂载模式从 ro 改成 rw
  - 测试和中英文文案同步更新

* feat: simplify langbot skill create and import

* refactor(skills): clean up legacy skill API and harden activation flow

* refactor(skills): remove skill dependency expansion and add skill_get

* fix: lint

* fix: delete

* fix(skills): align tool manager loader initialization

* refactor: remove sandbox execute skill

* fix(skills): hide activation markers and isolate skill activation flow

* refactor(skills): switch skill model to filesystem-backed packages

* refactor(skills): switch skill model to filesystem-backed packages

* refactor(skills): unify runtime skill access around filesystem paths

* refactor(skills): unify runtime skill access around filesystem paths

* feat(skills): align rw package design and fix skill activation, visibility, and lint issues

* refactor(skills): replace rich authoring API with import/reload flow and update
  Box design doc

* feat(box): add sandbox_exec tool loop for local-agent calculations

* feat(box): add host workspace mounting and sandbox_exec guidance

* feat(box): add BoxProfile with resource limits and improved output truncation

  - Implement head+tail output truncation (60/40 split) so LLM sees both
    beginning and final results; add streaming byte-limited reads in backend
    to prevent unbounded memory usage (_MAX_RAW_OUTPUT_BYTES = 1MB)
  - Define BoxProfile model with locked fields and max_timeout_sec clamping
  - Add four built-in profiles: default, offline_readonly, network_basic,
    network_extended with differentiated resource and security constraints
  - Add resource limit fields to BoxSpec (cpus, memory_mb, pids_limit,
    read_only_rootfs) and pass corresponding container CLI flags
    (--cpus, --memory, --pids-limit, --read-only, --tmpfs)
  - Profile loaded from config (box.profile), applied in service layer
    before BoxSpec validation; locked fields cannot be overridden by
    tool-call parameters

* feat(box): add obs

* refactor(box): unify box service lifecycle and local runtime
  management

* refactor(box): remove legacy in-process runtime code and clean up smells

After the architecture settled on always using an independent Box Runtime
service, several pieces of compatibility code and design shortcuts were
left behind. This commit cleans them up:

- Remove `LocalBoxRuntimeClient` and `create_box_runtime_client` from
  production code (moved to test-only helper).
- Remove unused `_clip_bytes` method from backend.
- Remove `__langbot_session_placeholder__` hack by making `BoxSpec.cmd`
  default to empty and validating non-empty only in `runtime.execute()`.
- Extract `get_box_config()` helper to eliminate 5× duplicated config
  access boilerplate.
- Remove `session_id`/`host_path`/`host_path_mode` from the LLM-facing
  tool schema to enforce request-scoped session isolation.
- Fix dual shutdown path: `NativeToolLoader.shutdown()` no longer calls
  `box_service.shutdown()` (handled by `Application.dispose()`).
- Simplify `_assert_session_compatible` with a loop.
- Inline client creation in `BoxRuntimeConnector`.
- Remove redundant `BOX__RUNTIME_URL` env var from docker-compose
  (auto-detected by code).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(box/mcp): integrate MCP stdio with Box sandbox — auto-isolation, dep install, security

  ## Summary

  When Podman/Docker is available, all stdio-mode MCP servers now automatically
  run inside Box containers with dependency installation, path rewriting, and
  lifecycle management. When no container runtime exists, LangBot starts normally
  and stdio MCP falls back to host-direct execution.

  ## What changed

  ### MCP stdio → Box integration (mcp.py)
  - Add `MCPServerBoxConfig` pydantic model for structured box configuration
    with validation and defaults (network, host_path_mode, timeouts, resources)
  - Auto-infer `host_path` from command/args with venv detection: recognizes
    `.venv/bin/python` patterns and walks up to the project root
  - Rewrite host paths to container `/workspace` paths transparently
  - Replace venv python commands with container-native `python`
  - Auto-detect `pyproject.toml`/`setup.py`/`requirements.txt` and run
    `pip install` inside the container before starting the MCP server
  - Copy project to `/tmp` before install to handle read-only mounts
  - Add retry with exponential backoff (3 retries, 2s/4s/8s delays)
  - Add Box managed process health monitoring (poll every 5s)
  - Fix session leak: `_cleanup_box_stdio_session()` now runs in `finally`
    block of `_lifecycle_loop`, covering all exit paths
  - Fix retry logic: `_ready_event` is only set after all retries exhaust
    or on success, not on first failure
  - Enhance `get_runtime_info_dict()` with `box_session_id` and `box_enabled`

  ### Box security (security.py — new)
  - `validate_sandbox_security()` blocks dangerous host paths:
    `/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/boot`, `/run`,
    docker.sock, podman socket
  - Called at the start of `CLISandboxBackend.start_session()`

  ### Box models (models.py)
  - Add `BoxHostMountMode.NONE` — skips volume mount entirely
  - Adjust `validate_host_mount_consistency` to allow arbitrary workdir
    when `host_path_mode=NONE`

  ### Box backend (backend.py)
  - Add `validate_sandbox_security()` call in `start_session()`
  - Add `langbot.box.config_hash` label on containers for drift detection
  - Handle `BoxHostMountMode.NONE` — skip `-v` mount arg
  - Add `cleanup_orphaned_containers()` to base class (no-op default) and
    CLI implementation (single batched `rm -f` command)

  ### Box runtime (runtime.py)
  - Call `cleanup_orphaned_containers()` during `initialize()` to remove
    lingering containers from previous runs

  ### Box service (service.py)
  - Graceful degradation: `initialize()` catches runtime errors and sets
    `available=False` instead of crashing LangBot startup
  - Add `available` property and guard on `execute_sandbox_tool()`
  - Add `skip_host_mount_validation` parameter to `build_spec()` and
    `create_session()` — MCP paths are admin-configured and trusted,
    bypassing `allowed_host_mount_roots` restrictions meant for
    LLM-generated sandbox_exec commands

  ### Default behavior
  - stdio MCP servers automatically use Box when `box_service.available`
    is True (Podman/Docker detected); no explicit `box` config needed
  - When no container runtime exists, falls back to host-direct stdio
  - MCP Box defaults: `network=on` (for pip install), `read_only_rootfs=false`
    (for site-packages), `host_path_mode=ro`, `startup_timeout=120s`

  ### Tests
  - `test_box_security.py`: blocked paths, safe paths, subpath rejection
  - `test_mcp_box_integration.py`: config model, path rewriting, venv
    unwrap, host_path inference, payload building, runtime info, box
    availability check
  - `test_box_service.py`: `BoxHostMountMode.NONE` validation tests

* feat(box/mcp): instance-based orphan cleanup, error classification, session API, and integration tests

  ## Changes

  ### Precise orphan container cleanup
  - Runtime generates a unique instance_id on startup
  - Every container gets a `langbot.box.instance_id` label
  - `cleanup_orphaned_containers()` only removes containers from
    previous instances, preserving containers owned by the current one
  - Containers from older versions (no label) are also cleaned up
  - `cleanup_orphaned_containers` added to `BaseSandboxBackend` as
    a no-op default method, removing hasattr duck-typing

  ### Fine-grained MCP error classification
  - New `MCPSessionErrorPhase` enum with 7 phases: session_create,
    dep_install, process_start, relay_connect, mcp_init, runtime,
    tool_call
  - Each phase in `_init_box_stdio_server()` sets the error phase
    before re-raising, enabling precise failure diagnosis
  - `retry_count` tracked across retry attempts
  - `get_runtime_info_dict()` exposes `error_phase` and `retry_count`

  ### GET /v1/sessions/{id} API
  - `BoxRuntime.get_session()` returns session details including
    managed process info when present
  - `handle_get_session` HTTP handler + route in server.py
  - `BoxRuntimeClient.get_session()` abstract method + remote impl

  ### stdio defaults to Box when runtime is available
  - `_uses_box_stdio()` checks `box_service.available` instead of
    requiring explicit `box` key in server_config
  - `BoxService.initialize()` catches runtime errors gracefully,
    sets `available=False` instead of crashing LangBot startup
  - When no container runtime exists, stdio MCP falls back to
    host-direct execution

  ### Code quality (from /simplify review)
  - Extracted `_VENV_DIRS` / `_VENV_BIN_DIRS` module-level constants
  - Removed dead `_box_network_mode()` method and unused `bc` variable
  - Fixed broken import `from ....box.models` → `from ...box.models`
  - Cached `_resolve_host_path()` result — computed once, passed through
  - Config hash now includes `host_path` field
  - Batched orphan cleanup into single `rm -f` command

  ### Session leak fix
  - `_cleanup_box_stdio_session()` now runs in `_lifecycle_loop`'s
    finally block, covering all exit paths (normal shutdown, error,
    retry, final failure)

  ### Integration tests
  - 6 end-to-end tests covering managed process lifecycle, WebSocket
    stdio bidirectional IO, session cleanup verification, single
    session query, process exit detection, and orphan cleanup safety

* refactor: use rpc

* fix: import

* refactor(box): clean up sandbox subsystem code quality and efficiency

  - Fix O(n²) stderr trimming in runtime.py with running length tracker
  - Remove dead code: RESERVED_CONTAINER_PATHS, _subprocess_wait_task,
    unused config_hash computation, unused imports
  - Deduplicate connection callback in BoxRuntimeConnector, parse URL once
  - Use enum comparison instead of stringly-typed spec.network.value check
  - Replace manual _result_to_dict/_session_to_dict with model_dump()
  - Cache NativeToolLoader tool definition and sandbox system guidance
  - Extract _is_path_under() helper to eliminate duplicated path checks
  - Import SANDBOX_EXEC_TOOL_NAME from native.py instead of redefining
  - Add JSON startswith guard in logging_utils to skip futile json.loads
  - Fix ruff lint errors (F401 unused imports, F841 unused variables)

* fix: ruff

* refactor(sandbox): keep box logic out of pipeline and localagent

  - Move sandbox system-prompt guidance from LocalAgentRunner into
    BoxService.get_system_guidance() so all box domain knowledge stays
    in the box module.
  - Remove standalone logging_utils.py; merge format_result_log() into
    MessageHandler base class alongside cut_str().
  - Strip sandbox-specific JSON parsing from log formatting; tool
    results now use generic truncation.
  - Revert TYPE_CHECKING changes in stage.py and runner.py that were
    unrelated to this feature.
  - Skip two test files affected by a pre-existing circular import
    (runner ↔ app) until the import cycle is resolved in a separate PR.

* refactor(box): move box runtime to langbot-plugin-sdk

  Extract self-contained box runtime modules (actions, backend, client,
  errors, models, runtime, security, server) to langbot-plugin-sdk and
  update all imports to use `langbot_plugin.box.*`. Keep only service
  and
  connector in LangBot core as they depend on the Application context.

  - Update docker-compose to use `langbot_plugin.box.server` entry
  point
  - Update pyproject.toml to use local SDK via `tool.uv.sources`
  - Remove migrated source files and their unit/integration tests
  - Update remaining test imports to match new module paths

* fix: ruff

* fix(box): tighten sandbox exposure and restore box integration coverage

* refactor(types): remove quoted annotations under postponed evaluation

* chore(sandbox): move MCP loader changes to follow-up branch

* refactor(plugins): simplify GitHub install flow to default master archive

* revert(api): restore plugin GitHub import flow in plugins controller

* Improve data-root handling and skill install previews

* Add managed skill authoring tools for local agents

* Refactor the skills UI around sidebar detail pages

* Document why managed skill authoring tools bypass box

* fix: lint

* feat(web): refactor plugin/skill install flows and fix skills page

- Fix sidebar skill icon
- Add skills route and error page component
- Refactor plugin GitHub install from dialog modal to inline card
- Add skill install dropdown menu (create/upload/github) in sidebar
- Wire sidebar → skills page communication via pendingSkillInstallAction context
- Add i18n keys for error page and skill install actions

* fix(web): persist sidebar collapsible section open state on navigation

Sections opened via sub-item navigation now retain their expanded state
when the user switches to a different section, instead of collapsing
because the isActive fallback becomes false.

---------

Co-authored-by: youhuanghe <1051233107@qq.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>

* feat(sandbox): add MCP box integration on top of sandbox base (#2083)

* refactor(mcp): extract box stdio runtime helper

* refactor(box): introduce reusable workspace session helper

* refactor(box): run Box Runtime as subprocess inside LangBot container

  Remove the separate langbot_box_runtime Docker service. Box Runtime
  now always launches as a local stdio subprocess, regardless of whether
  LangBot runs in Docker or not. The WebSocket transport path is kept
  only for explicit runtime_url configuration (remote deployment).

  This simplifies deployment by eliminating cross-container path mapping
  and network hops. Box Runtime is a pure scheduling process (talks to
  Docker socket / nsjail), it does not execute user code or touch the
  filesystem, so container isolation is unnecessary — unlike Plugin
  Runtime.

* fix(web): prevent first-emission snapshot from swallowing unsaved changes in pipeline editor

When switching runner (e.g. local-agent → n8n), the newly mounted stage's
first emit would re-capture the saved snapshot, erasing the dirty state
caused by the runner change. The save button would incorrectly go dim.

- Skip snapshot re-capture in handleDynamicFormEmit when form is already dirty
- Add mount-time emit to N8nAuthFormComponent (matching DynamicFormComponent)
- Use stable onSubmitRef to prevent useEffect subscription churn
- Add previousInitialValues guard to prevent initialValues echo loops

* style(web): align plugin list header button heights

* docs(review): update Box architecture review documents

Replace old review docs with 5 focused documents:
- box-architecture.md: deep architecture analysis (LangBot + SDK)
- box-issues.md: 22 issues rated P0/P1/P2
- box-test-coverage.md: test coverage analysis
- box-tob-analysis.md: toB commercialization analysis
- box-vs-plugin-runtime.md: Box vs Plugin runtime comparison

* feat(web): improve login error layout and add Terms of Service link

- Improve backend connection error display with bordered container,
  inline icon, and better visual hierarchy
- Extract actual error message from axios response object
- Add Terms of Service link (https://langbot.app/terms) to login footer
- Add termsOfService i18n key for all 7 locales

* refactor(web): replace all hardcoded SVG icons with lucide-react

Unify icon usage across the entire frontend by replacing 67 hardcoded
SVG icons with lucide-react components across ~25 files. This improves
consistency, maintainability, and reduces bundle duplication.

Key replacements:
- Sidebar nav: Zap, LayoutDashboard, Bot, Workflow, BookMarked, etc.
- MCP forms: Loader2, XCircle, Trash2
- Monitoring: Sparkles, MessageSquare, CheckCircle2, RefreshCw, etc.
- Cards: Clock, Star, Workflow, Hexagon, Puzzle, Github, etc.
- Misc: Paperclip, AudioLines, CloudUpload, Layers, Heart, Smile

Zero hardcoded <svg> tags remain in .tsx files.

* fix(web): stop polling plugin tasks when no active installs

The PluginInstallTaskProvider was unconditionally polling
getAsyncTasks every 3s on all /home/* routes. Now it only
syncs once on mount and starts periodic polling only when
there are active (non-terminal) install tasks.

* fix(deps): update langbot-plugin version and add new dependencies

* refactor: use Space API for release checks and stop idle polling

- version.py: switch release list API from GitHub to space.langbot.app,
  remove unused in-place update logic (update_all, compare_version_str),
  translate all comments/logs to English
- PluginInstallTaskContext: only poll when active install tasks exist

* feat(box): add --standalone-box flag and 3-way transport decision for Box runtime

Align Box runtime connection logic with Plugin runtime's pattern:
- Docker: WebSocket to langbot_box container (ws://langbot_box:5411)
- --standalone-box: WebSocket to external Box process (ws://localhost:5411)
- Windows: subprocess + WebSocket (workaround for async stdio limitation)
- Unix/macOS: subprocess + stdio pipe (unchanged)

BoxRuntimeConnector now inherits ManagedRuntimeConnector for subprocess
lifecycle reuse. Add langbot_box service to docker-compose.yaml.

* refactor(box): use single port with path-based routing for Box WS

Update connector to use ws://host:5410/rpc/ws instead of ws://host:5411.
Update review docs to reflect the single-port architecture.

* feat(web): show Box runtime status in plugin debug info popover

Add Box status section to the debug info popover on the plugin list page,
displaying connection status, backend info, profile, active sessions,
and recent error count. Fetched from GET /api/v1/box/status in parallel
with plugin debug info. Includes i18n for all 8 supported languages.

* fix(web): remove ephemeral sandbox count from Box status display

The active_sessions count reflects transient sandbox containers that
expire after 5 minutes of inactivity, making it misleading in the UI.
Keep only connection status, backend, profile, and error count.

* feat(box): configurable sandbox scope and unified skill containers

Replace the per-message session_id with a template-based system
configurable per pipeline via 'Sandbox Scope' in the local-agent panel.
Default scope is per-chat ({launcher_type}_{launcher_id}).

Unify skill exec into the same container as default exec — skills are
mounted at /workspace/.skills/{name}/ via extra_mounts instead of
getting separate containers. All pipeline-bound skills are injected
at container creation time.

- Add box-session-id-template to pipeline metadata (select, 4 options, 8 languages)
- Add resolve_box_session_id() and build_skill_extra_mounts() to BoxService
- Rewrite native.py skill exec path to use execute_tool with shared session
- Update tests for new session_id format
- Add design doc: docs/review/box-session-scope.md

* feat(web): show active sandbox details in Box status popover

Display sandbox count and a detailed list of active sessions including
session ID, image, backend, resources (CPU/memory), network mode, and
last used time. Fetched from GET /api/v1/box/sessions in parallel.
Includes i18n for all 8 supported languages.

* feat(box): add startup and availability logging for sandbox tools

Log Box runtime initialization result (success with profile info, or
failure warning). Log native tool availability status at ToolManager
startup so it's immediately clear whether exec/read/write/edit tools
are registered for the LLM.

* feat(box): support custom sandbox container image via config.yaml

Add 'image' field to box config section. When set, it overrides the
profile default image (python:3.11-slim) for all sandbox containers.
Priority: caller-specified > config.yaml image > profile default.

* feat(box): add heartbeat and reconnection for Box runtime connector

Add 20-second heartbeat ping loop to detect silent Box runtime
disconnections. On disconnect, set available=false and attempt
reconnection after 3 seconds via the disconnect callback chain.

- BoxRuntimeConnector: heartbeat loop, disconnect callback parameter,
  disconnect detection in connection callback and WS failure handler
- BoxService: wire disconnect callback to toggle available state and
  re-initialize the connector on reconnection

* feat(web): move runtime status to dashboard, clean up plugin debug popover

Add SystemStatusCards component to the monitoring dashboard showing
Plugin Runtime and Box Runtime connection status with details (backend,
profile, sandbox count). Remove all Box/session status from the plugin
page debug popover — it now only shows debug URL and key.

Includes i18n for all 8 supported languages.

* refactor(web): compact system status into a single card alongside metrics

Replace the separate two-card row with a single compact 'System Status'
card placed as the 5th column in the metrics grid. Shows green/red dots
for Plugin Runtime and Box Runtime. Click to expand a popover with
connection details (backend, profile, sandbox count).

* feat: show connector error details for Plugin and Box runtime status

Record Box connector error in BoxService and expose it as
'connector_error' in GET /api/v1/box/status when unavailable.
Display error messages in the dashboard System Status popover
for both Plugin Runtime (plugin_connector_error) and Box Runtime
(connector_error) when they are disconnected.

* fix(web): auto-refresh system status and show disconnect errors in real time

Poll Plugin Runtime and Box Runtime status every 30 seconds so the
dashboard reflects disconnections without a manual page refresh.
Also re-fetch when the popover is opened for immediate feedback.

* fix(box): handle RPC failure in get_status/get_sessions gracefully

When the Box runtime disconnects, there is a race between the heartbeat
flipping _available=false and the frontend polling get_status(). If the
poll arrives first, client.get_status() throws a ConnectionClosedError
which propagated as a 500, causing the frontend to show a grey dot
(null status) instead of a red dot with error details.

Now get_status() catches RPC errors and returns available=false with
the exception message as connector_error. get_sessions() returns an
empty list when unavailable or on RPC failure.

* fix(box): add persistent reconnection loop with exponential backoff

The previous disconnect handler only retried once and then gave up.
Now spawns a background task that retries with exponential backoff
(3s, 6s, 12s, ... up to 60s) until the Box runtime is reachable again.
Uses a _reconnecting guard to prevent duplicate loops. Calls
connector.dispose() before each retry to clean up stale tasks.

* fix(box): detect disconnect when handler.run() returns normally

The generic Handler.run() catches ConnectionClosedError and breaks out
of its loop (normal return) instead of raising, because it has no
disconnect_callback. The old code only triggered reconnection in the
except branch, so a clean WebSocket close was never detected.

Now treat handler.run() returning normally (after successful handshake)
as a disconnect event, triggering the reconnection callback.

* fix(web): refresh system status card when clicking Refresh Data button

Pass a refreshKey prop through OverviewCards to SystemStatusCard that
increments on each Refresh Data click, triggering a re-fetch of Plugin
and Box runtime status alongside the monitoring data refresh.

* fix(web): fix system status card stuck in loading state

fetchStatus(showLoading=false) never called setLoading(false), so the
initial loading=true was never cleared. Simplify to always setLoading
in the finally block — the spinner only shows on the very first load
since subsequent fetches complete near-instantly.

* feat(web): show active sandbox details in dashboard Box status popover

Fetch box sessions alongside status and display each active sandbox
in the popover with session ID, image, resources (CPU/memory), and
last used time.

* feat(box): add global sandbox scope option

Add a 'Global (shared by all)' option to the sandbox scope selector.
Uses a constant '{global}' template variable that always resolves to
'global', so all users and chats share one sandbox container.

* refactor(web): replace popover with dialog for system status details

Replace the dropdown popover with a proper Dialog for runtime status
details. Add a small info button on the System Status card that opens
the dialog. Session details now show in a spacious 2-column grid layout
with full image name, backend, CPU/memory, network, mount path, and
created/last-used timestamps.

* fix(web): widen system status dialog and fix scroll border issue

Use max-w-2xl (matching other dialogs) instead of max-w-lg. Move
overflow-y-auto to an inner container with overflow-hidden on
DialogContent to prevent padding bleed at scroll edges.

* feat(web): add tooltips for truncated fields in system status dialog

Wrap session_id, image, and mount path fields with Tooltip components
so hovering over truncated text shows the full value.

* feat: add download button

* feat: successfully install

* feat: delete old filter

* feat: youhua frontend

* fix: align box runtime launch args

* feat: translate

* feat: refactor market

* feat: youhua qianduan

* chore: rename extension zh translation

* feat(extensions): unify extensions endpoint and refresh extensions page UX

- Rename /home/plugins route to /home/extensions and update all sidebar links.
- Add unified GET /api/v1/extensions returning plugins, MCP servers and skills,
  sorted by name; replace the three separate frontend fetches with this single call.
- Migrate the extensions page to shadcn primitives (Tabs/Card/Alert/Badge/Skeleton/
  Switch/Label) and clean up hardcoded color tokens on the extension card.
- Add a localStorage-persisted "Group by type" switch that, when enabled in the
  All Types tab, renders extensions grouped by type with a compact section header.
- Show a spinner while loading and rename the empty-state copy from
  "No plugins installed" to "No extensions installed".
- Rename the "格式 / Formats" filter label to "类型 / Types" across all 8 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(extensions): fallback lucide icon when extension icon is missing

Render a tinted lucide icon (Puzzle / Server / Sparkles) on the extension
card when the icon URL is empty or the image fails to load. Picked icons
distinct from EventListener (AudioWaveform) and KnowledgeEngine (Book) to
avoid visual collision with plugin component badges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): unify installed-extensions list with plugins, MCP and skills

- Render plugins, MCP servers and skills together under the "Installed
  Extensions" sidebar entry, alphabetically sorted to match the list page.
- Resolve per-item routes by extension type (plugin -> /home/extensions,
  mcp -> /home/mcp, skill -> /home/skills) and gate the plugin-only hover
  context menu on extensionType === 'plugin'.
- Lift the "group by type" toggle into SidebarDataContext (still persisted
  in localStorage) so the sidebar groups items with section headers
  whenever the list page has the toggle enabled.
- Show lucide fallback icons (Server / Sparkles / Puzzle) tinted in the
  LangBot blue for MCP, skill, and missing-icon plugin items, overriding
  the SidebarMenuSubButton svg color rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(extensions): mobile-friendly layout for extensions and add-extension pages

- Stack the extensions page header vertically on small screens, let the
  filter Tabs scroll horizontally if they overflow, hide the debug
  button label below sm and let the install/debug controls wrap.
- Constrain the debug popover and its inputs to the viewport width so
  they no longer overflow on phone-sized screens.
- Drop the card grid from a fixed 30rem column to a min(100%, 22rem)
  column at base / 28rem at sm, and reduce the gap, so cards render
  cleanly at 360px+ widths in both flat and grouped views.
- Make the add-extension header actions wrap on lg- viewports and the
  install dialog responsive instead of a hard 500px box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: change ui

* feat: delete version for mcp and skills

* fix: constrain home page content width

* fix: preserve monitoring card borders under sticky filters

* fix(box): restore sandbox config and shared mcp runtime

* fix(box): harden sandbox session isolation

* fix(skill): remove auto activation setting

* feat(skill): align skill system with Claude Code's Tool Call design

- Replace text marker activation with `activate` tool (Tool Call mechanism)
- Replace 7 authoring tools with 2: `activate` + `register_skill`
- Add builtin skills loading from templates/skills/
- Add create-skill as first builtin skill
- Remove SKILL_ACTIVATION_MARKER and text detection methods
- Tool Result returns SKILL.md content (protects KV Cache)

This aligns with Claude Code's progressive disclosure pattern:
- Metadata (name+description) always visible in tool description
- SKILL.md body loaded on activate via Tool Call
- Bundled resources accessible through virtual path mapping

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(tools): add glob and grep native sandbox tools

Add file discovery and content search capabilities to the sandbox:
- glob: Find files by pattern (supports ** recursive matching)
- grep: Search file contents with regex patterns

Both tools respect skill package paths and include safety limits
(max 100 files for glob, max 200 matches for grep).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(skill): add skill file browsing capability

- Add API endpoints for listing/reading/writing skill files
- Add FileTree component in SkillForm for directory browsing
- Users can now view scripts/, references/, assets/ directories
- Files can be selected and edited in the instructions textarea
- Add translations for new file browsing features

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(skill): copy builtin skills to data/skills on startup

- Builtin skills (templates/skills/) are now copied to data/skills/
- Users can view and manage builtin skills in the UI
- Rename SkillAuthoringToolLoader to SkillToolLoader

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(skill): improve file browsing and fix path handling

- Fix nested directory display in skill file tree (preserve root entries)
- Fix file content display when clicking files in skill browser
- Add skill manager and tool manager as proper package modules
- Separate fileContent state to allow editing non-SKILL.md files

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(toolmgr): correct skill_tool_loader attribute name

Rename skill_authoring_tool_loader to skill_tool_loader in execute_func_call
and shutdown methods to match the attribute defined in initialize().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(native): update tool descriptions to use register_skill

Replace references to removed import_skill_from_directory with
register_skill in exec/write/edit tool descriptions.

* feat(toolmgr): enhance tool initialization with backend availability checks

* refactor: remove unused imports and clean up code in various files

* feat: polish extension detail pages

* feat: persist sidebar list expansion

* fix: refine extension ui and backend errors

* fix: align add extension marketplace ui

* feat: manage skills through box runtime

* feat: support github skill installation

* fix: import github skill directories

* feat: install market extensions from card click

* feat(web): improve skill import flow

* feat: polish extension import flow

* fix(mcp): stabilize shared box managed processes

* fix(web): improve backend retry and sidebar scrolling

* docs(review): refresh box architecture review for feat/sandbox

Sync the docs/review/ suite to the current state of the feat/sandbox branch
(both LangBot and langbot-plugin-sdk), ~30 commits ahead of the prior review.

- box-architecture.md: rewrite for the new box.{backend,runtime,local,e2b}
  config schema, add E2B backend, 6 native tools (incl. glob/grep), Skill
  Tool Call activation, shared multi-process MCP container, SkillManager,
  BoxSkillStore (SDK), 25 actions, 9 error types, heartbeat/reconnect
- box-issues.md: move resolved items (reconnect, heartbeat, Windows, nsjail
  image conflict, frontend monitoring card) into a Resolved section; add
  new P0 (INIT/backend ordering), P1 (extra_mounts immutability after
  container creation), P2 (skill_store test gap, integration tests not in CI)
- box-session-scope.md: add §0 Implementation Status — Phase 1 shipped,
  MCP unification landed earlier than originally scoped
- box-test-coverage.md: realign file inventory (4,400 -> 6,500 LOC),
  add 7 new test files including SDK backend_selection/e2b/skill_store
- box-tob-analysis.md: connection recovery now满足基本要求; add E2B and
  backend self-heal to capabilities; tick off Phase 1 reconnect/heartbeat
- box-vs-plugin-runtime.md: heartbeat/reconnect/Windows support now aligned
  with Plugin Runtime; revise remaining gaps (WS auth, shared base class)

* refactor(box): use unified env-override mechanism for box.local config

The box module hand-rolled its own LANGBOT_BOX_LOCAL_* env parsing in two
places (connector._get_box_config and service._local_config), duplicating
logic that LoadConfigStage._apply_env_overrides_to_config already provides
generically via the SECTION__SUBSECTION__KEY convention.

- Drop the bespoke LANGBOT_BOX_LOCAL_* parsing; read box.local straight
  from instance_config (the unified BOX__LOCAL__* overrides are already
  applied before BoxService initializes)
- Harden _load_allowed_mount_roots to accept a comma-separated string,
  since the generic mechanism stores a freshly-created key as a raw
  string when config.yaml has no box.local.allowed_mount_roots entry
- docker-compose: rename the langbot container env vars to
  BOX__LOCAL__* (the canonical convention); remove them entirely from
  the langbot_box container — the Box runtime never reads box.local from
  env/config.yaml, it is configured via the INIT RPC action

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: repair stale skill/sandbox tests for feat/sandbox

The skill subsystem moved to Tool-Call activation and a Box-managed
skill store; several tests still asserted removed APIs and a sys.modules
stub leaked across the suite. Full unit suite now green (was 23 failing).

- test_skill_tools: drop TestSkillManagerActivation (text-marker API
  removed); rewrite TestSkillActivationHelper around the current
  skill.activation.register_activated_skill; replace the CRUD
  TestSkillAuthoringToolLoader with TestSkillToolLoader covering the
  current activate/register_skill tools and sandbox-availability gating
- test_tool_manager_native: ToolManager attr is skill_tool_loader (not
  skill_authoring_tool_loader); native loader now exposes 6 tools
  (exec/read/write/edit/glob/grep) and requires initialize() with a
  backend-available get_status()
- test_localagent_sandbox_exec: remove obsolete activation-marker
  leakage tests and their helper providers
- test_model_service / pipeline conftest: give the mocks skill_mgr=None
  so PreProcessor's local-agent skill-binding guard short-circuits
- test_n8nsvapi: stop permanently overwriting sys.modules
  ('langbot.pkg.provider.runner' etc.); save and restore around the
  import so other modules get the real LocalAgentRunner base class

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(tests): run unit tests on every push to feat/** branches

- Add feat/** to push branches so long-lived feature branches are
  tested on every push (they accumulate large changes before a PR)
- Drop the push path filter entirely: every push to master/develop/
  feat/** now runs the full unit suite (the old 'pkg/**' filter never
  matched the real source path 'src/langbot/pkg/**', so backend-only
  pushes silently skipped tests)
- Fix the same broken path glob on the pull_request trigger
  ('pkg/**' -> 'src/langbot/pkg/**')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skill): harden mount/reload paths and HTTP errors against stale skill cache

The Box backends behave inconsistently when extra_mounts reference a
missing host directory (nsjail aborts the entire sandbox start, Docker
silently creates a root-owned empty dir on the host, E2B silently skips
the upload). The cache in skill_mgr.skills is only refreshed on
in-process mutations, so out-of-band changes — container rebuilds,
manual rm in the box volume, anything the LangBot API didn't drive —
leave a stale skill that later produces one of those bad mount paths.

- box/service.py: build_skill_extra_mounts now filters skills whose
  package_root is not isdir on the LangBot-visible filesystem and logs
  a warning, instead of passing the bad mount through to the backend
- skill/manager.py: reload_skills (Box path) drops skills whose
  package_root is missing on the LangBot-side filesystem before they
  reach the in-memory cache, with a summary warning
- api/http/controller/groups/skills.py: file/CRUD handlers now also
  catch BoxError (RuntimeError subclass, previously slipping past
  ``except ValueError`` and surfacing as 500); list/get handlers gain
  a try/except so a transient Box RPC failure becomes a clean 400
  instead of a stack trace

Tests added for build_skill_extra_mounts (skip missing, skip empty,
no skill manager) and SkillManager.reload_skills (drop missing on Box
path). Full unit suite: 279 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(box): add box.enabled toggle and gate consumers on availability

Make the Box sandbox runtime optional. When ``box.enabled`` is false in
config (or when an enabled Box fails to connect), every dependent feature
degrades to the same disabled-state UX rather than crashing or silently
falling back to less safe code paths.

Backend:

- config.yaml: new top-level ``box.enabled: true`` flag (default true)
- BoxService:
  - Read box.enabled on construction
  - initialize() short-circuits when disabled — no remote WS connect, no
    stdio subprocess fork
  - _on_runtime_disconnect is a no-op when disabled (no reconnect loop
    on a deliberately-off service)
  - get_status() now exposes ``enabled`` so the frontend can tell
    "disabled in config" from "configured but failed"
- MCP stdio loader (mcp_stdio.uses_box_stdio): requires box_service to
  be available, not just installed
- MCP _init_stdio_python_server: when ap.box_service exists but is
  unavailable, refuse the stdio server with an actionable error instead
  of silently falling through to host-stdio (which bypasses the sandbox
  the operator asked for). Setups without ap.box_service installed at
  all keep the legacy host-stdio fallback for pre-Box dev mode
- SkillService._require_box_for_write: refuses create/update/install/
  write_skill_file when ap.box_service is installed but unavailable.
  Distinguishes disabled vs failed in the error message so the UI can
  surface the right hint. Legacy setups (no ap.box_service) keep the
  local fallback path — that distinction is what keeps the existing
  local-skills tests valid

Tests:
- Box disabled-state behavior (4 cases)
- Skill write refusal in disabled & failed states (7 cases)
- MCP stdio runtime info policy updated to match new refuse-when-down
  behavior

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web): surface Box disabled/unavailable state across consumers

When Box is disabled in config (``box.enabled = false``) or fails to
connect, every dependent UI surface now degrades visibly:

- ``useBoxStatus`` hook: shared, polled 30s, exposes ``available``,
  ``disabled`` (config-off) and a single ``hint`` key so callers don't
  have to re-derive the three states
- ``BoxUnavailableNotice`` reusable Alert banner driven by that hint
- Dashboard SystemStatusCards: three-state dot + label
  (connected / disabled-gray / disconnected-red); disabled state shows
  the ``boxDisabled`` hint, failed state continues to show the connector
  error. Plugin block kept untouched
- Skills page (create view) and SkillDetailContent (edit view):
  Save button disabled and banner inserted above the form when Box is
  unavailable — matches the backend gate added in the previous commit
- PipelineExtension skill section: ``enable_all_skills`` switch, Add
  Skill button and Remove buttons all gate on Box availability;
  banner inline under the section header
- PipelineFormComponent: banner above the ``local-agent`` stage card
  when Box is unavailable, since that stage carries the sandbox-bound
  ``box-session-id-template`` field
- Box status payload type (``ApiRespBoxStatus.enabled``) and 8 locale
  files updated with ``boxDisabled`` / ``boxUnavailable`` /
  ``boxRequiredHint`` strings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(box): document the box.enabled toggle and gate behavior matrix

- docker-compose: move ``langbot_box`` under compose profiles
  (``box`` and ``all``) so ``docker compose up`` no longer requires
  the sandbox container. Inline comment explains how to pair the
  profile choice with ``box.enabled`` so the langbot service does not
  thrash trying to reach a runtime that was never started
- docs/review/box-architecture.md:
  - Annotate ``box.enabled`` in the config.yaml example, listing the
    exact side effects (no remote/stdio connect; tools/skills/MCP
    stdio off; reads still work)
  - Replace the bare compose snippet with the actual profile-driven
    invocation and the BOX__ENABLED pairing
  - New "关闭/连接失败时的行为矩阵" section: a single table mapping
    every consumer (native tools, activate/register_skill, stdio MCP,
    skill list/CRUD, pipeline AI config, extensions page, dashboard)
    to its disabled-state behavior, plus the legacy ``ap.box_service``
    distinguisher note

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(pipeline-form): swap Box banner for field-level disable_if + tooltip

The previous commit hard-coded a BoxUnavailableNotice banner above the
``local-agent`` stage card. That works, but it shouts at the user about
every field in that stage when in reality only one field —
``box-session-id-template`` — depends on the sandbox.

Use the dynamic-form schema's existing variable-injection mechanism
(``__system.*`` references via ``systemContext``) and add a sibling to
``show_if``: ``disable_if`` + ``disabled_tooltip``. The field stays
visible, becomes inert, and an info icon next to its label exposes the
reason on hover. The rest of the AI tab is left untouched.

- entities/form/dynamic.ts: extend IDynamicFormItemSchema with
  ``disable_if: IShowIfCondition`` and ``disabled_tooltip: I18nObject``
- DynamicFormComponent: evaluate disable_if with the same resolver as
  show_if; OR the result into isFieldDisabled; render an Info tooltip
  trigger next to the label when the condition matches
- ai.yaml metadata: attach disable_if (__system.box_available eq false)
  and a localized disabled_tooltip to box-session-id-template
- PipelineFormComponent: drop the BoxUnavailableNotice import and the
  per-stage banner; pass ``systemContext={ box_available: boxAvailable }``
  only for the local-agent stage so other stages aren't paying the
  re-render cost

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp): friendly UI message when stdio MCP refused by Box state

Previously the MCP detail dialog dumped the raw RuntimeError text from
``_init_stdio_python_server`` — English-only, prefixed with "Failed
after 4 attempts", and exposing internal config names. The retry
wrapper also kept retrying a refusal that is deterministically going
to fail again, polluting logs.

Replace the raw text with a structured signal:

- New ``MCPSessionErrorPhase.BOX_UNAVAILABLE`` enum value. The stdio
  refusal path sets it before raising and uses a short opaque
  discriminator (``box_disabled_in_config`` / ``box_unavailable``) as
  the message body — never user-facing
- ``_lifecycle_loop_with_retry`` short-circuits on
  ``BOX_UNAVAILABLE``: surfaces the error immediately, no retries,
  no "Failed after N attempts" prefix. Silences the warning storm
  seen during smoke-testing
- ``MCPServerRuntimeInfo`` (TS type) now declares ``error_phase``,
  ``retry_count``, ``box_session_id``, ``box_enabled`` to match what
  the backend already returns in get_runtime_info_dict()
- Both MCP detail forms (``mcp/components/mcp-form/MCPForm.tsx`` and
  ``plugins/mcp-server/mcp-form/MCPFormDialog.tsx``) detect
  ``error_phase === 'box_unavailable'`` and render a two-line
  localized notice: state line ("Box disabled / unreachable") plus
  remediation line ("enable Box or switch to http/sse")
- 8 locale files (en/zh-Hans/zh-Hant/ja/ru/vi/th/es) get
  ``mcp.boxDisabledStdioRefused``, ``mcp.boxUnavailableStdioRefused``,
  ``mcp.boxStdioRefusedSuggestion``

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp-web): block stdio MCP creation at the form when Box is unavailable

When Box is disabled in config (``box.enabled = false``) or unreachable,
saving a new MCP server in stdio mode produced one that could never
start — the user would only learn that from the runtime error on the
detail page. Stop the user before they save instead.

Both MCP forms (the page-level ``MCPForm.tsx`` and the older dialog
``MCPFormDialog.tsx``) now:

- Disable the ``stdio`` option in the mode select when Box is
  unavailable, with a small "(requires Box)" suffix so the reason is
  obvious. Existing stdio configs still display their current value
- Show ``BoxUnavailableNotice`` inline under the mode select when the
  currently-selected mode is stdio and Box is unavailable, so editing
  a stale stdio config makes the cause visible
- Disable the Save / Submit button while stdio is selected under that
  condition. ``MCPForm`` exposes a new ``onSaveBlockedChange`` prop
  so the parent ``MCPDetailContent`` can disable both its Submit and
  Save buttons. ``MCPFormDialog`` disables its Save button locally
- Refuse the submit handler too (Enter-key path) with a toast carrying
  the same i18n message

i18n: ``mcp.boxRequired`` (short tag in the disabled option) and
``mcp.stdioBlockedByBoxToast`` added to all 8 locales.

Backend runtime gate (``_init_stdio_python_server`` refusal +
``BOX_UNAVAILABLE`` error_phase + retry short-circuit) stays in place
as the last line of defence for API bypass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): prevent plugin config form overflow

* refactor(skill): remove all local-filesystem fallbacks; Box is the sole source

Skills now flow exclusively through the Box runtime. Every read and write
method funnels through ``_box_service()``; when Box is unavailable
(disabled in config, connection failed, or simply not installed) the
operation either returns an empty surface (``list_skills`` → []) or
raises with a clear ``Box runtime ... not initialised / disabled /
unavailable: ...`` message via the new ``_require_box(action)`` helper.

Why: the legacy local-fallback path scanned ``data/skills/``, but Box
manages its own ``box.local.skills_root`` (default ``data/box/skills/``).
The two diverging directories caused stale / phantom skill lists when
Box flapped, and the local-fallback writes silently bypassed all the
sandboxing the operator had configured.

SkillService (``api/http/service/skill.py``):
- New ``_require_box(action)`` returns the box service or raises a
  structured ValueError. ``_require_box_for_write`` kept as alias
- ``list_skills`` → returns [] when Box is down so the UI can render
  the disabled banner cleanly
- ``get_skill`` / ``get_skill_by_name`` → return None
- All read-file / write-file / scan-dir / create / update / delete /
  install / preview methods → ``_require_box`` then box delegate.
  Local fallback bodies (shutil.copytree, tempfile.mkdtemp, preview
  pipelines) removed entirely

SkillManager (``pkg/skill/manager.py``):
- ``reload_skills`` returns early with empty cache when Box is down.
  data/skills/ discovery loop removed
- ``refresh_skill_from_disk`` now just reports cache presence; the
  on-disk re-parse is gone since Box is the only writer

Tests:
- Drop 11 obsolete test_skill_service.py tests that exercised the
  removed local-fallback paths (create/install/file/delete/update)
- Add list-empty + read-refused tests; flip the legacy-allow test to
  legacy-refuses-too
- Rewrite refresh_skill_from_disk test to match the new behaviour

Several helper methods (_managed_skill_path, _resolve_skill_path,
_preview_skill_candidates, _install_preview_candidates, etc.) are now
unreachable; a follow-up commit will prune them so this diff stays
reviewable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(skill): prune dead local-filesystem helpers left over from Box migration

Follow-up to the Box-only refactor. The previous commit removed the
local-fallback BRANCHES from every public method; this one removes the
HELPERS those branches called, which are now unreachable.

SkillService (service/skill.py): 787 → 449 lines
  Removed: scan_directory (sync), _read_skill_package, _write_skill_md,
  _resolve_create_field, _managed_skill_path,
  _managed_install_root_for_package, _normalize_package_root,
  _resolve_skill_path, _find_skill_entry, _discover_skill_directories,
  _safe_extract_zip, _extract_uploaded_skill_to_temp,
  _download_github_skill_to_temp, _resolve_github_source_root,
  _build_preview_target_dir, _preview_skill_candidates,
  _select_preview_candidates, _install_preview_candidates,
  _preview_source_root, _resolve_installed_skills, plus the
  module-level _FRONTMATTER_FIELDS and _build_skill_md.
  Kept (still needed by the surviving GitHub-import path):
  _download_github_asset, _download_github_skill_directory_as_zip,
  _find_github_skill_archive_entry, _copy_github_skill_directory_to_zip,
  _is_github_skill_md_url, _parse_github_skill_md_url,
  _resolve_github_skill_md_package_name, _validate_github_asset_url,
  _uploaded_skill_target_stem, _validate_skill_name.
  Imports dropped: shutil, tempfile, yaml, ....utils.paths.

SkillManager (skill/manager.py): 187 → 88 lines
  Removed: get_managed_skills_root, _discover_skill_directories,
  _find_skill_entry, _load_skill_file, _normalize_package_root.
  Imports dropped: datetime, parse_frontmatter, paths.

Tests:
  - test_skill_service.py: drop the 3 sync scan_directory tests +
    skill_service fixture + _create_skill_file helper
  - test_skill_tools.py: drop test_load_skill_file_success; rename
    TestSkillManagerPackageLoading → TestSkillManagerCache

Full unit suite: 277 passed, 1 skipped. ``ruff check`` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skill): re-inject skill index into local-agent system prompt

The contributor's original PR (#1917) appended an ``Available Skills``
index to the system prompt before the LLM saw the user message, so the
LLM could decide whether to activate a skill. ``7145447b`` removed the
text-marker activation flow and, together with it, the entire system
prompt injection — but the Tool Call replacement only put the available
skills inside the ``activate`` tool's description. In practice the LLM
ignores tool descriptions for selection and goes straight to native
tools, so user-visible skill activation silently broke.

Restore the injection, adapted for the Tool Call era:

- SkillManager regains ``get_skill_index(bound_skills)`` and
  ``build_skill_aware_prompt_addition(bound_skills)``. The addendum
  carries only ``name (display_name): description`` for each
  pipeline-visible skill plus one instruction line pointing at the
  ``activate`` tool. No SKILL.md contents — KV cache stays clean
- PreProcessor appends the addendum to the first system message (or
  inserts a new one) of ``query.prompt.messages`` for the local-agent
  runner. Handles plain-string and ContentElement[] bodies. Skips
  cleanly when no skills are visible
- 3 new test_preproc cases: injection happens, bound-skills subset
  honoured, empty addendum touches nothing. 280 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(box): downgrade get_status.available when backend probed unavailable

Until now ``BoxService.get_status`` returned ``available: true`` whenever
the runtime connector was healthy, even if the runtime itself reported
``backend: { available: false }`` (operator selected nsjail without the
binary, Docker daemon crashed mid-session, E2B credentials wrong, ...).
The dashboard / ``useBoxStatus`` hook / skill_service gate consumed the
top-level flag and showed "connected" while every actual call to native
exec or skill management would fail.

The native-tool loader already polled ``status.backend.available``
independently and hid its tools correctly, but every other consumer
(dashboard banner, the disabled-state hint, the LLM-facing message)
disagreed with it.

Combine the two in the payload: ``available = self._available AND
status.backend.available``. When ``backend.available`` is false we now
also surface a ``connector_error`` that names the backend
("Configured sandbox backend \"nsjail\" is unavailable") so the dialog
shows the actionable reason instead of an empty error pane. The
detailed ``backend`` object is preserved unchanged for the dialog.

Internal ``box_service.available`` (used by ``skill_service`` writes,
``mcp_stdio.uses_box_stdio``, the reconnect callback) is intentionally
NOT changed — it still tracks connector health only, so a backend blip
does not trigger spurious reconnect loops.

Tests:
- ``test_get_status_downgrades_available_when_backend_dead`` — exercise
  the new branch (connector OK, backend.available=false → top-level
  available=false, connector_error mentions the backend name)
- ``test_get_status_keeps_available_true_when_backend_ok`` — guard
  against regressing the happy path

Live-verified with ``box.backend: nsjail`` on macOS (no nsjail binary):
``GET /api/v1/box/status`` now returns ``available: false`` with the
named connector_error, instead of the previous misleading
``available: true``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web): surface the specific Box failure reason in unavailable banner

When Box is configured but the runtime reports its backend is dead
(e.g. ``box.backend = nsjail`` but the binary is missing, or Docker
daemon crashed), the backend now returns a structured
``connector_error`` like ``Configured sandbox backend "nsjail" is
unavailable``. The previous notice only said "Box sandbox is
unavailable" + a generic "enable Box" hint, hiding the actionable
detail.

- ``useBoxStatus``: derive ``reason`` from ``status.connector_error``.
  Only exposed for the failed-state (``hint === 'boxUnavailable'``),
  since the disabled-by-config message already carries its reason
- ``BoxUnavailableNotice``: insert the reason as a small monospaced
  line between the state message and the action hint. The disabled
  variant is unchanged (operator chose the state)
- Wire ``reason`` through every existing call site (Skills page +
  detail, PipelineExtension, both MCP forms). Old unused ``context``
  prop dropped

Net layout (3 lines, still compact):

  ⚠ Box sandbox is unavailable — sandbox tools, skill add/edit, ...
    Configured sandbox backend "nsjail" is unavailable
    This feature requires the Box runtime. Enable it in config ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: reconcile master's unit tests with feat/sandbox refactors

The merge from master brought in new unit tests that target pre-refactor
APIs on feat/sandbox. Reconcile each:

- factories/app.py: FakeApp now exposes a Mock skill_mgr (with empty .skills
  dict + inert prompt-addition builder) and a Mock pipeline_service so the
  PreProcessor skill-index injection branch can run end-to-end in tests.

- pipeline/conftest.py: eagerly import langbot.pkg.pipeline.pipelinemgr so
  pipeline.stage is fully initialised before any individual stage test
  (preproc, longtext, ...) tries to lazy-load it. Without this preload,
  running test_preproc.py in isolation hit a circular-import error via the
  stage -> app -> pipelinemgr -> stage chain.

- provider/test_tool_manager.py: ToolManager now probes four loaders
  (native -> plugin -> mcp -> skill). Inject inert native + skill mocks in
  the execute_func_call fixture and assert all four shutdowns fire.

- utils/test_paths.py: drop the three cwd-dependent _check_if_source_install
  cases. The refactor walks Path(__file__).resolve().parents looking for
  pyproject.toml + main.py, so cwd no longer factors in and there's no
  file read to mock-fail. The positive case and caching test still apply.

- utils/test_version.py: delete entirely. is_newer and compare_version_str
  were removed when VersionManager was refactored to use the Space API for
  release checks (1b4107a9); the tests targeted a surface that no longer
  exists.

* refactor(box): launch box runtime via the lbp CLI subcommand

Mirror the plugin runtime: box is now started through the same CLI entry
point (langbot_plugin.cli) instead of the box module directly.

- docker-compose.yaml: langbot_box command runs `langbot_plugin.cli ... box`
  (WebSocket is the default transport, no flag needed — matches `rt`).
- box/connector.py: both subprocess launch sites (_start_local_stdio and
  the Windows _start_subprocess_then_ws path) invoke
  `langbot_plugin.cli.__init__ box`, using `-s` for the stdio transport.
- docs/review: update stale `-m langbot_plugin.box[.server]` references.

Pairs with the SDK change that removes box's direct-launch entry points
(python -m langbot_plugin.box / .box.server) and the legacy --mode flag.

* chore: bump langbot-plugin beta 1

* fix(ci): resolve langbot-plugin from PyPI and clear lint failures

CI on feat/sandbox failed across Unit Tests, Lint and Build Dev Image.
Root causes and fixes:

- pyproject.toml had a [tool.uv.sources] editable override pinning
  langbot-plugin to ../langbot-plugin-sdk. That path only exists in a
  paired local checkout, so `uv sync` failed on every CI runner
  ("Distribution not found"). Remove the override and regenerate uv.lock
  so langbot-plugin==0.4.0b1 resolves from PyPI, matching master.

- tests/integration/api/test_pipelines.py: the pipeline extensions
  endpoint now calls ap.skill_service.list_skills(); add the missing
  skill_service mock to the fake_pipeline_app fixture (the test came
  from master, the endpoint change from feat/sandbox).

- Apply ruff format to three src files and prettier to three web files
  that had committed formatting drift, failing `ruff format --check`
  and `pnpm lint`.

* chore: bump beta version

* docs: remove BOX_BACKEND override reference

* fix(pipelines): stop attributing dashboard debug WS to bound web_page_bot

The dashboard pipeline-debug WebSocket
(/api/v1/pipelines/<uuid>/ws/connect) and the embed widget WebSocket
(/api/v1/embed/<bot_uuid>/ws/connect) already live on separate paths,
but the debug handler ran `_find_owner_bot(pipeline_uuid)` and, when
the same pipeline happened to be bound to a web_page_bot, passed that
bot as `owner_bot` into `handle_websocket_message`. The adapter then
used the page bot's listeners + adapter for the request, so debug
sessions were logged as "page bot" activity in the dashboard.

Debug sessions must always run under the built-in websocket_proxy_bot.
Remove `_find_owner_bot`, drop the `owner_bot` parameter from the
debug-path `_handle_receive`, and call `handle_websocket_message`
without it so the adapter takes its default proxy-bot branch. The
embed handler still resolves and passes its `runtime_bot` for the
page-bot path, so attribution there is unchanged.

* fix(plugin): install marketplace MCP from canonical mode + extra_args

_install_mcp_from_marketplace read the dropped `mcp_data.config` field
and reconstructed mode/extra_args by guessing from the URL — which lost
stdio's command/args/env/box entirely, so stdio MCP installs from the
marketplace always failed.

Use the Space record's canonical `mode` and `extra_args` directly (the
same shape stored in mcp_servers), and gate the install on `mode`
instead of the removed `config`. After a successful install, best-effort
POST to the marketplace install endpoint to bump install_count.

* feat(web): show recommendation lists in plugin market; mixed-type icons

The marketplace recommendation lists (curated rows from Space) were never
mounted in the plugin market page. Wire them in:
- fetch recommendation lists on mount and render them above the extension
  grid, only when no search/filter is active.

Recommendation lists now mix plugins, MCPs and skills, so resolve each
card's icon by type (plugin / mcp / skill marketplace icon URL) instead of
always using the plugin icon endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): auto-open install dialog from one-click deep link

Accept a deep link from LangBot Space's one-click install:
/home/add-extension?install=1&extension_type=<plugin|mcp|skill>&author=&name=&version=
On mount, populate the install info, open the confirm dialog directly, and
strip the params from the URL. Reuses the existing marketplace install flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: push marketplace URL to runtime; fix market client base race

- On connecting to the plugin runtime, push the configured space.url via the
  new SET_RUNTIME_CONFIG action so the runtime downloads plugins from the same
  Space, instead of relying on its own CLOUD_SERVICE_URL env/default. Wrapped
  in try/except so an older SDK without the action degrades gracefully.
- web: the plugin market fetched recommendation lists (and listings) via the
  sync cloud client before its baseURL was resolved from system info, so it
  hit the default space.langbot.app. Await getCloudServiceClient() before the
  initial fetches and for the recommendation list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): don't show MCP "connection failed" while still connecting

The MCP status UI rendered "连接失败" for any non-connected state, so during a
normal connection attempt the subtitle showed "连接失败" while the status pill
below it showed "连接中..." — contradictory.

Only treat an explicit ERROR (or box-unavailable) status as failed; a
CONNECTING or initial/unresolved status now shows "连接中". Applied to the MCP
detail form (subtitle + StatusDisplay) and the MCP server card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): type-aware install dialog + refresh sidebar after install

The marketplace install confirm dialog was hardcoded to "安装插件 / 确定要安装
插件 X 吗" for every type. Make it type-aware (plugin / MCP / skill) and show
more info: type chip, author/name id, and version when present.

Also refresh all sidebar extension lists (plugins, MCP servers, skills) when
an install task completes, so the newly-installed extension appears
immediately regardless of type (previously only refreshPlugins ran).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): richer install dialog (icon + name + description), drop redundant type row

The install dialog already states the type in its title, so the "类型" row was
redundant. Replace the info box with the extension's icon (avatar), display
name, author/name id + version, and description — built from the PluginV4 for
in-app installs and from the icon endpoint by type for the one-click deep link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): TDZ crash in add-extension (installIconURL before installInfo)

installIconURL was computed above the useState declaration of installInfo,
causing "Cannot access 'installInfo' before initialization" (500) on the
add-extension page. Move the computation below the state declarations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): redesign install-progress dialog for MCP/skill

The progress dialog showed plugin-only stages (download + dependency install)
for every type. MCP/skill have no such steps, so show a single
"installing → done/failed" row for them (MCP: adding & connecting the server;
skill: installing the package) while keeping the detailed download/deps
stages for plugins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): add missing market.componentName i18n keys

The marketplace component filter (and component badges) used
market.componentName.{Tool,Command,EventListener,KnowledgeEngine,Parser,Page}
but those keys only existed under plugins.componentName, so the market UI
showed raw keys. Add a componentName block to the market namespace (zh-Hans +
en-US; other locales fall back to zh-Hans).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): sidebar extensions refresh button + full-name tooltip

- Add a refresh button to the installed-extensions category header in the
  sidebar; it re-fetches plugins + MCP servers + skills and spins while
  loading.
- The sidebar item tooltip now shows the extension's full name (with the
  description below when present), so truncated MCP/extension names are
  readable on hover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugin-market): rename component filter to "插件组件" with hint tooltip + persist filters

- Rename the in-app plugin market component filter label to "插件组件" /
  "Plugin Component"
- Add an Info icon tooltip explaining what plugin components are (Tool /
  Command / EventListener, etc.)
- Persist filter selections (type / component / tags / sort) in localStorage
  so they survive reloads; restored on mount (URL type param still wins)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugin-market): restore missing "页面"(Page) component filter option

The market component-filter list on this branch was a diverged rewrite that
dropped the Page component kind master had added. The i18n key
(market.componentName.Page) already existed; re-add the Page entry to the
componentOptions list so plugins providing Page components can be filtered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(i18n): reword plugin component filter hint

Drop the redundant "插件组件是" lead-in and mention that components extend
LangBot's capabilities; mirror the wording in en-US.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(i18n): backfill missing market/addExtension keys in 6 locales

check-i18n surfaced that market.componentName.*, market.filterByComponentHint
and the addExtension.install* keys existed only in en-US/zh-Hans. Backfill
them for es-ES, ja-JP, ru-RU, th-TH, vi-VN and zh-Hant (reusing each locale's
existing component-name translations) and align the filterByComponent label
with the new "Plugin Component" wording. check-i18n now passes for all locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(plugins): relabel "group by type" as "group by format"

The installed-extensions grouping is by extension format (plugin / MCP / skill),
so rename the toggle label accordingly across all 8 locales (key unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugin-market): cursor-pointer on tag filter trigger

The TagsFilter Select trigger used the default cursor; add cursor-pointer so the
tag filter is clearly clickable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sidebar): show edition badge (Community / Cloud) in logo area

Add a small badge next to the LangBot name in the sidebar header that reflects
systemInfo.edition: a neutral "Community" badge for the community edition and a
blue "Cloud" badge for the cloud edition. Adds sidebar.editionCommunity /
sidebar.editionCloud across all 8 locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(sidebar): unify zh-Hans cloud edition label to 云端版

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sidebar): edition badge - drop hover, use "Cloud" in all locales

The edition badge is not interactive, so remove the hover background on the
cloud badge. Also use the literal "Cloud" label uniformly across all locales
instead of localized variants (云端版/クラウド版/...).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(box): cap tool-call loop and run workspace-quota walk off the event loop

Two robustness fixes that bite under normal sandbox usage (not just attack),
hardening the self-hosted community edition before release:

- localagent: cap the tool-call loop at MAX_TOOL_CALL_ROUNDS (128). A looping
  or adversarial model could otherwise emit tool calls indefinitely (each
  potentially a sandbox exec), producing a non-terminating request and runaway
  cost. The cap is generous enough not to interrupt legitimate multi-step
  agentic workflows.
- box.service: make _enforce_workspace_quota async and run the recursive
  workspace scan via asyncio.to_thread. It ran on every quota-enforced exec and
  a large workspace would block the whole asyncio runtime (all bots/pipelines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(review): refresh box docs; trim issue list to SaaS blockers only

Community self-hosted edition is release-ready, so the box review docs are
updated to current state (date 2026-06-02 + status note) and box-issues.md is
rewritten to keep only the SaaS / multi-tenant / network-exposed release
blockers (S1-S8): unauthenticated control plane, no per-pipeline exec
authorization, unbounded sessions + no reaper, no kernel-level quota, mount
validation gaps (/ + extra_mounts), missing container hardening, lock-around-
cold-start, and the lower-severity follow-ups. Resolved items (tool-call loop
cap, async quota scan, host_path mount allowlist, _is_path_under dedup) moved to
a short "resolved before community release" record; community-only and
pure-cleanup items dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(deps): pin langbot-plugin to 0.4.0

Track the stable SDK release (0.4.0b1 -> 0.4.0); regenerate uv.lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: WangCham <651122857@qq.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: fdc310 <82008029+fdc310@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
huanghuoguoguo
2026-06-03 11:12:39 +08:00
committed by GitHub
parent 4054ba2a76
commit 96b041846d
161 changed files with 22518 additions and 4029 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { Switch } from '@/components/ui/switch';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { MessageSquare, Workflow } from 'lucide-react';
export default function BotCard({
botCardVO,
@@ -42,28 +43,14 @@ export default function BotCard({
</div>
<div className={`${styles.basicInfoAdapterContainer}`}>
<svg
className={`${styles.basicInfoAdapterIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 8.99374C2 5.68349 4.67654 3 8.00066 3H15.9993C19.3134 3 22 5.69478 22 8.99374V21H8.00066C4.68659 21 2 18.3052 2 15.0063V8.99374ZM20 19V8.99374C20 6.79539 18.2049 5 15.9993 5H8.00066C5.78458 5 4 6.78458 4 8.99374V15.0063C4 17.2046 5.79512 19 8.00066 19H20ZM14 11H16V13H14V11ZM8 11H10V13H8V11Z"></path>
</svg>
<MessageSquare className={`${styles.basicInfoAdapterIcon}`} />
<span className={`${styles.basicInfoAdapterLabel}`}>
{botCardVO.adapterLabel}
</span>
</div>
<div className={`${styles.basicInfoPipelineContainer}`}>
<svg
className={`${styles.basicInfoPipelineIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z"></path>
</svg>
<Workflow className={`${styles.basicInfoPipelineIcon}`} />
<span className={`${styles.basicInfoPipelineLabel}`}>
{botCardVO.usePipelineName}
</span>

View File

@@ -0,0 +1,53 @@
import { useTranslation } from 'react-i18next';
import { Info, ShieldAlert } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
/**
* Banner shown when a feature depends on the Box sandbox runtime but it is
* currently disabled in config or otherwise unavailable. Pass the ``hint``
* key returned by ``useBoxStatus`` (``'boxDisabled' | 'boxUnavailable'``).
*
* Renders nothing when there is no hint — safe to drop at the top of any
* page that may or may not need to surface the notice.
*/
export interface BoxUnavailableNoticeProps {
hint: 'boxDisabled' | 'boxUnavailable' | null;
/** Specific failure reason from the backend (``connector_error``). Shown
* on a dedicated line so the user sees WHY (e.g. ``Configured sandbox
* backend "nsjail" is unavailable``) instead of just the generic
* "unavailable" wording. Ignored when ``hint === 'boxDisabled'``
* because the disabled-by-config message already carries the reason. */
reason?: string | null;
className?: string;
}
export function BoxUnavailableNotice({
hint,
reason,
className,
}: BoxUnavailableNoticeProps) {
const { t } = useTranslation();
if (!hint) return null;
const variant = hint === 'boxDisabled' ? 'default' : 'destructive';
const Icon = hint === 'boxDisabled' ? Info : ShieldAlert;
const showReason = hint === 'boxUnavailable' && reason;
return (
<Alert variant={variant} className={className}>
<Icon className="h-4 w-4" />
<AlertDescription className="space-y-1">
<div>{t(`monitoring.${hint}`)}</div>
{showReason && (
<div className="text-xs font-mono opacity-80 break-all">{reason}</div>
)}
<div className="text-xs opacity-80">
{t('monitoring.boxRequiredHint')}
</div>
</AlertDescription>
</Alert>
);
}
export default BoxUnavailableNotice;

View File

@@ -20,7 +20,7 @@ import {
} from '@/components/ui/item';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound } from 'lucide-react';
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
interface AccountSettingsDialogProps {
@@ -136,34 +136,7 @@ export default function AccountSettingsDialog({
{/* Space Account Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2L2 7L12 12L22 7L12 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 17L12 22L22 17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 12L12 17L22 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<Layers className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>

View File

@@ -20,8 +20,14 @@ import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Copy, Check, Globe, QrCode } from 'lucide-react';
import { Copy, Check, Globe, Info, QrCode } from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { systemInfo } from '@/app/infra/http';
/**
@@ -123,13 +129,13 @@ function WebhookUrlField({
};
return (
<FormItem>
<FormLabel>{label}</FormLabel>
<div className="flex items-center gap-2">
<FormItem className="min-w-0">
<FormLabel className="break-words">{label}</FormLabel>
<div className="flex min-w-0 items-center gap-2">
<Input
value={url}
readOnly
className="flex-1 bg-muted"
className="min-w-0 flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
@@ -146,11 +152,11 @@ function WebhookUrlField({
</Button>
</div>
{extraUrl && (
<div className="flex items-center gap-2 mt-2">
<div className="mt-2 flex min-w-0 items-center gap-2">
<Input
value={extraUrl}
readOnly
className="flex-1 bg-muted"
className="min-w-0 flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
@@ -168,12 +174,14 @@ function WebhookUrlField({
</div>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
<p className="text-sm break-words text-muted-foreground">
{description}
</p>
)}
{systemInfo.edition === 'community' && (
<div className="flex items-start gap-2.5 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 mt-1 max-w-2xl">
<div className="mt-1 flex max-w-full min-w-0 items-start gap-2.5 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5">
<Globe className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground leading-relaxed">
<p className="text-sm leading-relaxed break-words text-muted-foreground">
{t('bots.webhookSaasHint')}{' '}
<a
href="https://space.langbot.app/cloud?utm_source=local_webui&utm_medium=webhook_alert&utm_campaign=saas_conversion"
@@ -462,7 +470,7 @@ export default function DynamicFormComponent({
return (
<Form {...form}>
<div className="space-y-4">
<div className="min-w-0 max-w-full space-y-4 overflow-x-hidden">
{/* QR code login dialog */}
<QrCodeLoginDialog
open={qrDialogOpen}
@@ -507,8 +515,53 @@ export default function DynamicFormComponent({
}
}
// All fields are disabled when editing (creation_settings are immutable)
const isFieldDisabled = !!isEditing;
// ``disable_if`` mirrors ``show_if``'s evaluator but instead of
// hiding the field, leaves it visible and inert. Use it when the
// operator needs to see that the field exists yet cannot edit it
// under the current runtime state (e.g. sandbox-bound fields when
// Box is disabled).
let isDisabledByCondition = false;
if (config.disable_if) {
const dependValue = resolveShowIfValue(
config.disable_if.field,
watchedValues as Record<string, unknown>,
externalDependentValues,
systemContext,
);
const cond = config.disable_if;
if (cond.operator === 'eq' && dependValue === cond.value) {
isDisabledByCondition = true;
} else if (cond.operator === 'neq' && dependValue !== cond.value) {
isDisabledByCondition = true;
} else if (
cond.operator === 'in' &&
Array.isArray(cond.value) &&
cond.value.includes(dependValue)
) {
isDisabledByCondition = true;
}
}
// All fields are disabled when editing (creation_settings are
// immutable) or when ``disable_if`` matches.
const isFieldDisabled = !!isEditing || isDisabledByCondition;
const disabledTooltip =
isDisabledByCondition && config.disabled_tooltip
? extractI18nObject(config.disabled_tooltip)
: '';
const renderDisabledTooltipIcon = () =>
disabledTooltip ? (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{disabledTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null;
// Webhook URL fields are display-only; render outside of form binding
if (config.type === 'webhook-url') {
@@ -631,19 +684,20 @@ export default function DynamicFormComponent({
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
<FormItem>
<FormItem className="min-w-0">
<div
className={cn(
'flex flex-row items-center justify-between rounded-lg border p-4 max-w-2xl',
'flex w-full min-w-0 max-w-full flex-row items-center justify-between rounded-lg border p-4',
isFieldDisabled && 'pointer-events-none opacity-60',
)}
>
<div className="space-y-0.5">
<FormLabel className="text-base">
<div className="min-w-0 space-y-0.5">
<FormLabel className="flex min-w-0 items-center gap-1.5 text-base">
{extractI18nObject(config.label)}
{renderDisabledTooltipIcon()}
</FormLabel>
{config.description && (
<p className="text-sm text-muted-foreground">
<p className="text-sm break-words text-muted-foreground">
{extractI18nObject(config.description)}
</p>
)}
@@ -669,16 +723,22 @@ export default function DynamicFormComponent({
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
<FormItem>
<FormLabel>
{extractI18nObject(config.label)}{' '}
{config.required && <span className="text-red-500">*</span>}
<FormItem className="min-w-0">
<FormLabel className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 break-words">
{extractI18nObject(config.label)}{' '}
{config.required && (
<span className="text-red-500">*</span>
)}
</span>
{renderDisabledTooltipIcon()}
</FormLabel>
<FormControl>
<div
className={
isFieldDisabled ? 'pointer-events-none opacity-60' : ''
}
className={cn(
'min-w-0 max-w-full overflow-x-hidden',
isFieldDisabled && 'pointer-events-none opacity-60',
)}
>
<DynamicFormItemComponent
config={config}
@@ -688,7 +748,7 @@ export default function DynamicFormComponent({
</div>
</FormControl>
{config.description && (
<p className="text-sm text-muted-foreground">
<p className="text-sm break-words text-muted-foreground">
{extractI18nObject(config.description)}
</p>
)}

View File

@@ -69,7 +69,6 @@ export default function DynamicFormItemComponent({
onFileUploaded,
}: {
config: IDynamicFormItemSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
field: ControllerRenderProps<any, any>;
onFileUploaded?: (fileKey: string) => void;
}) {
@@ -251,7 +250,7 @@ export default function DynamicFormItemComponent({
return (
<Input
type="number"
className="max-w-xs"
className="w-full max-w-xs"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
@@ -260,8 +259,8 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.STRING:
if (config.options && config.options.length > 0) {
return (
<div className="flex items-center gap-1.5 max-w-md">
<Input className="flex-1" {...field} />
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
<Input className="min-w-0 flex-1" {...field} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -292,21 +291,26 @@ export default function DynamicFormItemComponent({
</div>
);
}
return <Input className="max-w-md" {...field} />;
return <Input className="w-full max-w-md" {...field} />;
case DynamicFormItemType.TEXT:
return <Textarea {...field} className="min-h-[120px] max-w-2xl" />;
return (
<Textarea
{...field}
className="min-h-[120px] w-full max-w-full resize-y overflow-x-hidden break-all"
/>
);
case DynamicFormItemType.BOOLEAN:
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
case DynamicFormItemType.STRING_ARRAY:
return (
<div className="space-y-2 max-w-md">
<div className="w-full max-w-md min-w-0 space-y-2">
{field.value.map((item: string, index: number) => (
<div key={index} className="flex gap-1.5 items-center">
<div key={index} className="flex min-w-0 items-center gap-1.5">
<Input
className="flex-1"
className="min-w-0 flex-1"
value={item}
onChange={(e) => {
const newValue = [...field.value];
@@ -347,7 +351,7 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.SELECT:
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="w-full max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('common.select')} />
</SelectTrigger>
<SelectContent>
@@ -409,10 +413,10 @@ export default function DynamicFormItemComponent({
];
return (
<div className="max-w-md flex items-center gap-1.5">
<div className="flex-1">
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
<div className="min-w-0 flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
@@ -577,9 +581,9 @@ export default function DynamicFormItemComponent({
);
return (
<div className="max-w-md">
<div className="w-full max-w-md min-w-0">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
</SelectTrigger>
<SelectContent>
@@ -612,12 +616,12 @@ export default function DynamicFormItemComponent({
);
return (
<div className="max-w-md">
<div className="w-full max-w-md min-w-0">
<Select
value={field.value || '__none__'}
onValueChange={(v) => field.onChange(v === '__none__' ? '' : v)}
>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.rerank')} />
</SelectTrigger>
<SelectContent>
@@ -713,7 +717,7 @@ export default function DynamicFormItemComponent({
placeholder: string,
) => (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
@@ -879,14 +883,14 @@ export default function DynamicFormItemComponent({
};
return (
<div className="space-y-3">
<div className="w-full min-w-0 space-y-3">
{/* Primary model selector */}
<div>
<p className="text-xs text-muted-foreground mb-1">
{t('models.fallback.primary')}
</p>
<div className="flex items-center gap-1.5">
<div className="flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<div className="min-w-0 flex-1">
{renderModelSelect(
modelValue.primary,
(val) => updateValue({ primary: val }),
@@ -918,16 +922,16 @@ export default function DynamicFormItemComponent({
{/* Fallback models */}
{modelValue.fallbacks.length > 0 && (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
<p className="text-xs text-muted-foreground">
{t('models.fallback.fallbackList')}
</p>
{modelValue.fallbacks.map((fbUuid: string, index: number) => (
<div key={index} className="flex items-center gap-2">
<div key={index} className="flex min-w-0 items-center gap-2">
<span className="text-xs text-muted-foreground w-4 shrink-0">
{index + 1}.
</span>
<div className="flex-1">
<div className="min-w-0 flex-1">
{renderModelSelect(
fbUuid,
(val) => updateFallbackModel(index, val),
@@ -1003,20 +1007,22 @@ export default function DynamicFormItemComponent({
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
{field.value && field.value !== '__none__' ? (
(() => {
const selectedKb = knowledgeBases.find(
(kb) => kb.uuid === field.value,
);
return (
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
{selectedKb?.emoji && (
<span className="text-sm shrink-0">
{selectedKb.emoji}
</span>
)}
<span>{selectedKb?.name ?? field.value}</span>
<span className="truncate">
{selectedKb?.name ?? field.value}
</span>
</div>
);
})()
@@ -1066,9 +1072,9 @@ export default function DynamicFormItemComponent({
return (
<>
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{field.value && field.value.length > 0 ? (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{field.value.map((kbId: string) => {
const currentKb = knowledgeBases.find(
(base) => base.uuid === kbId,
@@ -1078,17 +1084,17 @@ export default function DynamicFormItemComponent({
return (
<div
key={kbId}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
className="flex min-w-0 items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex items-center gap-2 flex-1">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex-1 min-w-0">
<div className="font-medium flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2 font-medium">
{currentKb.emoji && (
<span className="text-sm shrink-0">
{currentKb.emoji}
</span>
)}
{currentKb.name}
<span className="truncate">{currentKb.name}</span>
{currentKb.knowledge_engine?.name && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
{extractI18nObject(
@@ -1098,7 +1104,7 @@ export default function DynamicFormItemComponent({
)}
</div>
{currentKb.description && (
<div className="text-sm text-muted-foreground">
<div className="text-sm break-words text-muted-foreground">
{currentKb.description}
</div>
)}
@@ -1221,7 +1227,7 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.BOT_SELECTOR:
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('bots.selectBot')} />
</SelectTrigger>
<SelectContent>
@@ -1239,9 +1245,9 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.TOOLS_SELECTOR:
return (
<>
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{field.value && field.value.length > 0 ? (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{field.value.map((toolName: string) => {
const currentTool = tools.find(
(tool) => tool.name === toolName,
@@ -1250,12 +1256,12 @@ export default function DynamicFormItemComponent({
return (
<div
key={toolName}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
className="flex min-w-0 items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex items-center gap-2 flex-1">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="font-medium">{toolName}</div>
<div className="truncate font-medium">{toolName}</div>
{currentTool?.human_desc && (
<div className="text-sm text-muted-foreground truncate">
{currentTool.human_desc}
@@ -1379,13 +1385,16 @@ export default function DynamicFormItemComponent({
? field.value
: [{ role: 'system', content: '' }];
return (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{promptItems.map(
(item: { role: string; content: string }, index: number) => (
<div key={index} className="flex gap-2 items-center">
<div
key={index}
className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center"
>
{/* 角色选择 */}
{index === 0 ? (
<div className="w-[120px] px-3 py-2 border rounded bg-gray-50 dark:bg-[#2a292e] text-gray-500 dark:text-white dark:border-gray-600">
<div className="w-full shrink-0 rounded border bg-gray-50 px-3 py-2 text-gray-500 sm:w-[120px] dark:border-gray-600 dark:bg-[#2a292e] dark:text-white">
system
</div>
) : (
@@ -1410,7 +1419,7 @@ export default function DynamicFormItemComponent({
)}
{/* 内容输入 */}
<Textarea
className="w-[300px]"
className="min-h-20 w-full min-w-0 flex-1 resize-y overflow-x-hidden break-all sm:w-[300px]"
value={item.content}
onChange={(e) => {
const newValue = [...(field.value ?? promptItems)];
@@ -1428,20 +1437,12 @@ export default function DynamicFormItemComponent({
className="p-2 hover:bg-gray-100 rounded"
onClick={() => {
const newValue = (field.value ?? promptItems).filter(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_: any, i: number) => i !== index,
);
field.onChange(newValue);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
<Trash2 className="w-5 h-5 text-red-500" />
</button>
)}
</div>
@@ -1492,14 +1493,7 @@ export default function DynamicFormItemComponent({
}}
title={t('common.delete')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 text-destructive"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</CardContent>
</Card>
@@ -1531,14 +1525,7 @@ export default function DynamicFormItemComponent({
document.getElementById(`file-input-${config.name}`)?.click()
}
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
<Plus className="w-4 h-4 mr-2" />
{uploading
? t('plugins.fileUpload.uploading')
: t('plugins.fileUpload.chooseFile')}
@@ -1584,14 +1571,7 @@ export default function DynamicFormItemComponent({
}}
title={t('common.delete')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 text-destructive"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</CardContent>
</Card>
@@ -1626,14 +1606,7 @@ export default function DynamicFormItemComponent({
?.click()
}
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
<Plus className="w-4 h-4 mr-2" />
{uploading
? t('plugins.fileUpload.uploading')
: t('plugins.fileUpload.addFile')}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -102,9 +102,30 @@ export default function N8nAuthFormComponent({
}, {} as FormValues),
});
const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues);
// Stable ref for onSubmit to avoid re-triggering the effect when the
// parent passes a new closure on every render (matches DynamicFormComponent pattern).
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
// 当 initialValues 变化时更新表单值
useEffect(() => {
if (initialValues) {
// Skip the first mount — defaultValues already handles it
if (isInitialMount.current) {
isInitialMount.current = false;
previousInitialValues.current = initialValues;
return;
}
// Deep compare to avoid reacting to parent re-renders that pass
// the same values back (e.g. after our own onSubmit emission).
const hasRealChange =
JSON.stringify(previousInitialValues.current) !==
JSON.stringify(initialValues);
if (initialValues && hasRealChange) {
// 合并默认值和初始值
const mergedValues = itemConfigList.reduce(
(acc, item) => {
@@ -120,11 +141,28 @@ export default function N8nAuthFormComponent({
// 更新认证类型
setAuthType((mergedValues['auth-type'] as string) || 'none');
previousInitialValues.current = initialValues;
}
}, [initialValues, form, itemConfigList]);
// 监听表单值变化
useEffect(() => {
// Emit initial form values on mount so the parent form's
// initializedStagesRef registers this stage (matches DynamicFormComponent).
const formValues = form.getValues();
const initialFinalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, string>,
);
onSubmitRef.current?.(initialFinalValues);
previousInitialValues.current = initialFinalValues as Record<
string,
string
>;
const subscription = form.watch((value, { name }) => {
// 如果认证类型变化,更新状态
if (name === 'auth-type') {
@@ -141,10 +179,11 @@ export default function N8nAuthFormComponent({
{} as Record<string, string>,
);
onSubmit?.(finalValues);
onSubmitRef.current?.(finalValues);
previousInitialValues.current = finalValues as Record<string, string>;
});
return () => subscription.unsubscribe();
}, [form, onSubmit, itemConfigList]);
}, [form, itemConfigList]);
// 根据认证类型过滤表单项
const filteredConfigList = itemConfigList.filter((config) => {

File diff suppressed because it is too large Load Diff

View File

@@ -26,11 +26,10 @@ export interface SidebarEntityItem {
installInfo?: Record<string, unknown>;
hasUpdate?: boolean;
debug?: boolean;
// Set when this item appears in the unified extensions list
extensionType?: 'plugin' | 'mcp' | 'skill';
}
// Install action types that can be triggered from sidebar
export type PluginInstallAction = 'local' | 'github' | null;
// Plugin page registered by a plugin
export interface PluginPageItem {
id: string; // "author/name/pageId"
@@ -50,19 +49,21 @@ export interface SidebarDataContextValue {
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
mcpServers: SidebarEntityItem[];
skills: SidebarEntityItem[];
pluginPages: PluginPageItem[];
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>;
refreshPlugins: () => Promise<void>;
refreshMCPServers: () => Promise<void>;
refreshSkills: () => Promise<void>;
refreshAll: () => Promise<void>;
// Breadcrumb: entity name shown when viewing a detail page
detailEntityName: string | null;
setDetailEntityName: (name: string | null) => void;
// Pending plugin install action triggered from sidebar
pendingPluginInstallAction: PluginInstallAction;
setPendingPluginInstallAction: (action: PluginInstallAction) => void;
// Whether the extensions list is grouped by type (shared between page and sidebar)
extensionsGroupByType: boolean;
setExtensionsGroupByType: (enabled: boolean) => void;
}
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
@@ -77,10 +78,22 @@ export function SidebarDataProvider({
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
useState<PluginInstallAction>(null);
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('extensions_group_by_type') === 'true';
});
const setExtensionsGroupByType = useCallback((enabled: boolean) => {
setExtensionsGroupByTypeState(enabled);
try {
localStorage.setItem('extensions_group_by_type', String(enabled));
} catch {
// ignore
}
}, []);
const refreshBots = useCallback(async () => {
try {
@@ -224,8 +237,8 @@ export function SidebarDataProvider({
const resp = await httpClient.getMCPServers();
setMCPServers(
resp.servers.map((server) => ({
id: server.name,
name: server.name,
id: server.name, // Keep __ for API calls
name: server.name.replace(/__/g, '/'), // Display with / for readability
enabled: server.enable,
runtimeStatus: server.runtime_info?.status,
})),
@@ -235,6 +248,22 @@ export function SidebarDataProvider({
}
}, []);
const refreshSkills = useCallback(async () => {
try {
const resp = await httpClient.getSkills();
setSkills(
resp.skills.map((skill) => ({
id: skill.name,
name: skill.display_name || skill.name,
description: skill.description,
updatedAt: skill.updated_at,
})),
);
} catch (error) {
console.error('Failed to fetch skills for sidebar:', error);
}
}, []);
const refreshAll = useCallback(async () => {
await Promise.all([
refreshBots(),
@@ -242,6 +271,7 @@ export function SidebarDataProvider({
refreshKnowledgeBases(),
refreshPlugins(),
refreshMCPServers(),
refreshSkills(),
]);
}, [
refreshBots,
@@ -249,6 +279,7 @@ export function SidebarDataProvider({
refreshKnowledgeBases,
refreshPlugins,
refreshMCPServers,
refreshSkills,
]);
// Fetch all entity lists on mount
@@ -264,17 +295,19 @@ export function SidebarDataProvider({
knowledgeBases,
plugins,
mcpServers,
skills,
pluginPages,
refreshBots,
refreshPipelines,
refreshKnowledgeBases,
refreshPlugins,
refreshMCPServers,
refreshSkills,
refreshAll,
detailEntityName,
setDetailEntityName,
pendingPluginInstallAction,
setPendingPluginInstallAction,
extensionsGroupByType,
setExtensionsGroupByType,
}}
>
{children}

View File

@@ -1,5 +1,14 @@
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import i18n from '@/i18n';
import {
Zap,
LayoutDashboard,
Bot,
Workflow,
BookMarked,
Puzzle,
PlusCircle,
} from 'lucide-react';
const t = (key: string) => {
return i18n.t(key);
@@ -10,16 +19,7 @@ export const sidebarConfigList = [
new SidebarChildVO({
id: 'wizard',
name: t('sidebar.quickStart'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M13 9H21L11 24V15H4L13 0V9ZM11 11V7.22063L7.53238 13H13V17.3944L17.263 11H11Z"></path>
</svg>
),
icon: <Zap className="text-blue-500" />,
route: '/wizard',
description: t('wizard.sidebarDescription'),
helpLink: {
@@ -33,16 +33,7 @@ export const sidebarConfigList = [
new SidebarChildVO({
id: 'monitoring',
name: t('monitoring.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM4 5V19H20V5H4ZM6 7H18V9H6V7ZM6 11H18V13H6V11ZM6 15H12V17H6V15Z"></path>
</svg>
),
icon: <LayoutDashboard className="text-blue-500" />,
route: '/home/monitoring',
description: t('monitoring.description'),
helpLink: {
@@ -54,16 +45,7 @@ export const sidebarConfigList = [
new SidebarChildVO({
id: 'bots',
name: t('bots.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M13.5 2C13.5 2.44425 13.3069 2.84339 13 3.11805V5H18C19.6569 5 21 6.34315 21 8V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V8C3 6.34315 4.34315 5 6 5H11V3.11805C10.6931 2.84339 10.5 2.44425 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2ZM6 7C5.44772 7 5 7.44772 5 8V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V8C19 7.44772 18.5523 7 18 7H13H11H6ZM2 10H0V16H2V10ZM22 10H24V16H22V10ZM9 14.5C9.82843 14.5 10.5 13.8284 10.5 13C10.5 12.1716 9.82843 11.5 9 11.5C8.17157 11.5 7.5 12.1716 7.5 13C7.5 13.8284 8.17157 14.5 9 14.5ZM15 14.5C15.8284 14.5 16.5 13.8284 16.5 13C16.5 12.1716 15.8284 11.5 15 11.5C14.1716 11.5 13.5 12.1716 13.5 13C13.5 13.8284 14.1716 14.5 15 14.5Z"></path>
</svg>
),
icon: <Bot className="text-blue-500" />,
route: '/home/bots',
description: t('bots.description'),
helpLink: {
@@ -76,16 +58,7 @@ export const sidebarConfigList = [
new SidebarChildVO({
id: 'pipelines',
name: t('pipelines.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z"></path>
</svg>
),
icon: <Workflow className="text-blue-500" />,
route: '/home/pipelines',
description: t('pipelines.description'),
helpLink: {
@@ -98,16 +71,7 @@ export const sidebarConfigList = [
new SidebarChildVO({
id: 'knowledge',
name: t('knowledge.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z"></path>
</svg>
),
icon: <BookMarked className="text-blue-500" />,
route: '/home/knowledge',
description: t('knowledge.description'),
helpLink: {
@@ -117,22 +81,12 @@ export const sidebarConfigList = [
},
section: 'home',
}),
// ── Extensions section ──
new SidebarChildVO({
id: 'plugins',
name: t('sidebar.installedPlugins'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H18C18.5523 5 19 5.44772 19 6V9C21.2091 9 23 10.7909 23 13C23 15.2091 21.2091 17 19 17V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H17V15.8293C17 15.5047 17.1576 15.2003 17.4226 15.0128C17.6877 14.8254 18.0272 14.7783 18.3332 14.8865C18.5405 14.9597 18.7645 15 19 15C20.1046 15 21 14.1046 21 13C21 11.8954 20.1046 11 19 11C18.7645 11 18.5405 11.0403 18.3332 11.1135C18.0272 11.2217 17.6877 11.1746 17.4226 10.9872C17.1576 10.7997 17 10.4953 17 10.1707V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
</svg>
),
route: '/home/plugins',
icon: <Puzzle className="text-blue-500" />,
route: '/home/extensions',
description: t('plugins.description'),
helpLink: {
en_US: 'https://link.langbot.app/en/docs/plugins',
@@ -142,19 +96,10 @@ export const sidebarConfigList = [
section: 'extensions',
}),
new SidebarChildVO({
id: 'market',
name: t('sidebar.pluginMarket'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M21 13.242V20H22V22H2V20H3V13.242C1.79401 12.435 1 11.0602 1 9.5C1 8.67286 1.25027 7.90335 1.67755 7.2612L4.5547 2.36088C4.80513 1.93859 5.26028 1.67578 5.76 1.67578H18.24C18.7397 1.67578 19.1949 1.93859 19.4453 2.36088L22.3225 7.2612C22.7497 7.90335 23 8.67286 23 9.5C23 11.0602 22.206 12.435 21 13.242ZM19 13.972C18.4511 14.0706 17.8794 14.0706 17.3305 13.972C16.1644 13.7566 15.1377 13.0712 14.5 12.1C13.8623 13.0712 12.8356 13.7566 11.6695 13.972C11.1206 14.0706 10.5489 14.0706 10 13.972C9.45108 14.0706 8.87938 14.0706 8.33053 13.972C7.16437 13.7566 6.13771 13.0712 5.5 12.1C4.86229 13.0712 3.83563 13.7566 2.66947 13.972C2.44883 14.0124 2.22434 14.0352 2 14.0404V20H5V15H10V20H19V13.972Z"></path>
</svg>
),
route: '/home/market',
id: 'add-extension',
name: t('sidebar.addExtension'),
icon: <PlusCircle className="text-blue-500" />,
route: '/home/add-extension',
description: t('plugins.description'),
helpLink: {
en_US: 'https://link.langbot.app/en/docs/plugins',
@@ -163,25 +108,4 @@ export const sidebarConfigList = [
},
section: 'extensions',
}),
new SidebarChildVO({
id: 'mcp',
name: t('sidebar.mcpServers'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z"></path>
</svg>
),
route: '/home/mcp',
description: t('mcp.title'),
helpLink: {
en_US: '',
zh_Hans: '',
},
section: 'extensions',
}),
];

View File

@@ -1,6 +1,7 @@
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
import { useTranslation } from 'react-i18next';
import styles from './KBCard.module.css';
import { Clock } from 'lucide-react';
export default function KBCard({ kbCardVO }: { kbCardVO: KnowledgeBaseVO }) {
const { t } = useTranslation();
@@ -27,14 +28,7 @@ export default function KBCard({ kbCardVO }: { kbCardVO: KnowledgeBaseVO }) {
</div>
<div className={`${styles.basicInfoLastUpdatedTimeContainer}`}>
<svg
className={`${styles.basicInfoUpdateTimeIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z"></path>
</svg>
<Clock className={`${styles.basicInfoUpdateTimeIcon}`} />
<div className={`${styles.basicInfoUpdateTimeText}`}>
{t('knowledge.updateTime')}
{kbCardVO.lastUpdatedTimeAgo}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { CloudUpload } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
@@ -221,7 +222,7 @@ export default function FileUploadZone({
{t('knowledge.documentsTab.noParserAvailable')}
</p>
<Link
to="/home/market?category=Parser"
to="/home/add-extension"
className="text-sm text-primary hover:underline mt-1 inline-block"
>
{t('knowledge.documentsTab.installParserHint')}
@@ -297,19 +298,7 @@ export default function FileUploadZone({
<label htmlFor="file-upload" className="cursor-pointer block">
<div className="space-y-2">
<div className="mx-auto w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<CloudUpload className="w-5 h-5 text-gray-400" />
</div>
<div>

View File

@@ -324,7 +324,7 @@ export default function KBForm({
{t('knowledge.noEnginesAvailable')}
</p>
<Link
to="/home/market?category=KnowledgeEngine"
to="/home/add-extension"
className="text-sm text-primary hover:underline"
>
{t('knowledge.installEngineHint')}

View File

@@ -45,9 +45,10 @@ import {
// Routes that belong to the "Extensions" section
const EXTENSIONS_ROUTES = [
'/home/plugins',
'/home/market',
'/home/extensions',
'/home/add-extension',
'/home/mcp',
'/home/skills',
'/home/plugin-pages',
];
@@ -57,12 +58,17 @@ function isExtensionsRoute(pathname: string): boolean {
);
}
const HOME_CONTENT_MAX_WIDTH = 'max-w-[1360px]';
const BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY =
'langbot_backend_unavailable_return_to';
export default function HomeLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const navigate = useNavigate();
const location = useLocation();
// Initialize user info if not already initialized
useEffect(() => {
@@ -73,19 +79,35 @@ export default function HomeLayout({
// Auto-redirect to wizard on first visit (wizard not yet completed on this instance)
useEffect(() => {
let cancelled = false;
const checkWizard = async () => {
try {
// Always re-fetch to ensure we have the latest wizard_status from backend
await initializeSystemInfo();
if (systemInfo.wizard_status === 'none') {
navigate('/wizard');
await initializeSystemInfo({ throwOnError: true });
if (!cancelled && systemInfo.wizard_status === 'none') {
navigate('/wizard', { replace: true });
}
} catch {
// If fetching system info fails, don't redirect
if (!cancelled) {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
sessionStorage.setItem(
BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY,
returnTo,
);
navigate('/backend-unavailable', {
replace: true,
state: { from: returnTo },
});
}
}
};
checkWizard();
}, [navigate]);
return () => {
cancelled = true;
};
}, [location.hash, location.pathname, location.search, navigate]);
return (
<SidebarDataProvider>
@@ -123,7 +145,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
const sectionLabel = isExtensions
? t('sidebar.extensions')
: t('sidebar.home');
const sectionLink = isExtensions ? '/home/plugins' : '/home/monitoring';
const sectionLink = isExtensions ? '/home/extensions' : '/home/monitoring';
return (
<SidebarProvider>
@@ -133,7 +155,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<div className="flex w-full items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
@@ -177,9 +199,13 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
</div>
</header>
<div className="flex-1 overflow-hidden p-4 pt-0 min-w-0">
{mainContent}
</div>
<main className="flex-1 overflow-hidden min-w-0 px-4 pb-4 pt-0">
<div
className={`mx-auto h-full w-full min-w-0 ${HOME_CONTENT_MAX_WIDTH}`}
>
{mainContent}
</div>
</main>
<SurveyWidget />
</SidebarInset>

View File

@@ -1,202 +0,0 @@
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react';
import React, { useState, useCallback, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
enum PluginInstallStatus {
ASK_CONFIRM = 'ask_confirm',
INSTALLING = 'installing',
ERROR = 'error',
}
export default function MarketplacePage() {
const { t } = useTranslation();
if (!systemInfo?.enable_marketplace) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
<p className="text-muted-foreground">{t('plugins.marketplace')}</p>
</div>
);
}
return <MarketplaceContent />;
}
function MarketplaceContent() {
const { t } = useTranslation();
const { refreshPlugins } = useSidebarData();
const {
addTask,
setSelectedTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
const [modalOpen, setModalOpen] = useState(false);
const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
const [installError, setInstallError] = useState<string | null>(null);
async function checkExtensionsLimit(): Promise<boolean> {
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
if (maxExtensions < 0) return true;
try {
const [pluginsResp, mcpResp] = await Promise.all([
httpClient.getPlugins(),
httpClient.getMCPServers(),
]);
const total =
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
if (total >= maxExtensions) {
toast.error(
t('limitation.maxExtensionsReached', { max: maxExtensions }),
);
return false;
}
} catch {
// If we can't check, let backend handle it
}
return true;
}
// Register task completion callback for toast and plugin list refresh
useEffect(() => {
const onComplete = (_taskId: number, success: boolean, error?: string) => {
if (success) {
toast.success(t('plugins.installSuccess'));
refreshPlugins();
} else {
toast.error(error || t('plugins.installFailed'));
}
};
registerOnTaskComplete(onComplete);
return () => {
unregisterOnTaskComplete(onComplete);
};
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
const handleInstallPlugin = useCallback(
async (plugin: PluginV4) => {
if (!(await checkExtensionsLimit())) return;
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
});
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setModalOpen(true);
},
[t],
);
function handleModalConfirm() {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
httpClient
.installPluginFromMarketplace(
installInfo.plugin_author,
installInfo.plugin_name,
installInfo.plugin_version,
)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `marketplace-${taskId}`;
addTask({
taskId,
pluginName: pluginDisplayName,
source: 'marketplace',
});
setSelectedTaskId(taskKey);
setModalOpen(false);
})
.catch((err) => {
setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
}
return (
<>
<div className="h-full overflow-y-auto">
<MarketPage installPlugin={handleInstallPlugin} />
</div>
<Dialog
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) {
setInstallError(null);
}
}}
>
<DialogContent className="w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<Download className="size-6" />
<span>{t('plugins.installPlugin')}</span>
</DialogTitle>
</DialogHeader>
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<p className="mb-2">
{t('plugins.askConfirm', {
name: installInfo.plugin_name,
version: installInfo.plugin_version,
})}
</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installing')}</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installFailed')}</p>
<p className="mb-2 text-red-500">{installError}</p>
</div>
)}
<DialogFooter>
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleModalConfirm}>
{t('common.confirm')}
</Button>
</>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<Button variant="default" onClick={() => setModalOpen(false)}>
{t('common.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
@@ -23,31 +24,43 @@ import type { MCPFormHandle } from '@/app/home/mcp/components/mcp-form/MCPForm';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Trash2 } from 'lucide-react';
import { Server, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
type MCPRuntimeState = 'connected' | 'connecting' | 'error';
type MCPConnectionState =
| 'connected'
| 'connecting'
| 'error'
| 'disabled'
| 'disconnected';
export default function MCPDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const { refreshMCPServers, mcpServers, setDetailEntityName } =
useSidebarData();
const server = mcpServers.find((s) => s.id === id);
const displayName = (server?.name ?? id).replace(/__/g, '/');
// Set breadcrumb entity name
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('mcp.createServer'));
} else {
const server = mcpServers.find((s) => s.id === id);
setDetailEntityName(server?.name ?? id);
setDetailEntityName(displayName);
}
return () => setDetailEntityName(null);
}, [id, isCreateMode, mcpServers, setDetailEntityName, t]);
}, [displayName, isCreateMode, setDetailEntityName, t]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Track whether the form has unsaved changes
const [formDirty, setFormDirty] = useState(false);
// True when the form picked stdio mode but Box is disabled/unreachable —
// saving would create a server that can never start, so block it.
const [saveBlockedByBox, setSaveBlockedByBox] = useState(false);
// Ref to MCPForm for triggering test from header
const formRef = useRef<MCPFormHandle>(null);
@@ -56,13 +69,55 @@ export default function MCPDetailContent({ id }: { id: string }) {
// Enable state managed here so the header switch works
const [serverEnabled, setServerEnabled] = useState(true);
const [enableLoaded, setEnableLoaded] = useState(false);
const [detailRuntimeStatus, setDetailRuntimeStatus] =
useState<MCPRuntimeState | null>(null);
const runtimeStatus = detailRuntimeStatus ?? server?.runtimeStatus;
const currentConnectionState: MCPConnectionState =
(enableLoaded ? serverEnabled : server?.enabled) === false
? 'disabled'
: runtimeStatus === 'connected' ||
runtimeStatus === 'connecting' ||
runtimeStatus === 'error'
? runtimeStatus
: 'disconnected';
const connectionStatusLabel: Record<MCPConnectionState, string> = {
connected: t('mcp.statusConnected'),
connecting: t('mcp.connecting'),
error: t('mcp.statusError'),
disabled: t('mcp.statusDisabled'),
disconnected: t('mcp.statusDisconnected'),
};
const connectionStatusClassName: Record<MCPConnectionState, string> = {
connected:
'border-green-200 bg-green-50 text-green-700 dark:border-green-900/70 dark:bg-green-950/40 dark:text-green-300',
connecting:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-300',
error:
'border-red-200 bg-red-50 text-red-700 dark:border-red-900/70 dark:bg-red-950/40 dark:text-red-300',
disabled: 'border-muted-foreground/20 bg-muted text-muted-foreground',
disconnected: 'border-muted-foreground/20 bg-muted text-muted-foreground',
};
const connectionDotClassName: Record<MCPConnectionState, string> = {
connected: 'bg-green-500',
connecting: 'bg-amber-500',
error: 'bg-red-500',
disabled: 'bg-muted-foreground/50',
disconnected: 'bg-muted-foreground/50',
};
// Fetch server enable state
useEffect(() => {
if (!isCreateMode) {
setDetailRuntimeStatus(null);
httpClient.getMCPServer(id).then((res) => {
const server = res.server ?? res;
setServerEnabled(server.enable ?? true);
setDetailRuntimeStatus(server.runtime_info?.status ?? null);
setEnableLoaded(true);
});
}
@@ -142,10 +197,24 @@ export default function MCPDetailContent({ id }: { id: string }) {
if (isCreateMode) {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('mcp.createServer')}</h1>
<div className="flex items-center gap-2">
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<h1 className="truncate text-xl font-semibold">
{t('mcp.createServer')}
</h1>
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
<Server className="size-3.5" />
{t('mcp.title')}
</Badge>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => navigate('/home/add-extension')}
>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="outline"
@@ -157,6 +226,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
<Button
type="submit"
form="mcp-form"
disabled={saveBlockedByBox}
onClick={async (e) => {
if (!(await checkExtensionsLimit())) {
e.preventDefault();
@@ -168,47 +238,99 @@ export default function MCPDetailContent({ id }: { id: string }) {
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl pb-8">
<MCPForm
ref={formRef}
initServerName={undefined}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onTestingChange={setMcpTesting}
/>
</div>
<div className="min-h-0 flex-1">
<MCPForm
ref={formRef}
initServerName={undefined}
layout="split"
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
/>
</div>
</div>
);
}
const enableControl = enableLoaded && (
<Card>
<CardHeader>
<CardTitle>{t('common.enable')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<Label
htmlFor="mcp-enable-switch"
className="cursor-pointer text-sm font-medium"
>
{t('common.enable')}
</Label>
<Switch
id="mcp-enable-switch"
checked={serverEnabled}
onCheckedChange={handleEnableToggle}
/>
</div>
</CardContent>
</Card>
);
const editActions = (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('mcp.dangerZone')}
</CardTitle>
<CardDescription>{t('mcp.dangerZoneDescription')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{t('mcp.deleteMCPAction')}</p>
<p className="text-sm text-muted-foreground">
{t('mcp.deleteMCPHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="shrink-0"
>
<Trash2 className="mr-1.5 size-4" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
);
// ==================== Edit Mode ====================
return (
<>
<div className="flex h-full flex-col">
{/* Header: title + enable switch + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">{t('mcp.editServer')}</h1>
{enableLoaded && (
<div className="flex items-center gap-2">
<Switch
id="mcp-enable-switch"
checked={serverEnabled}
onCheckedChange={handleEnableToggle}
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 items-center gap-3">
<h1 className="truncate text-xl font-semibold">{displayName}</h1>
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
<Server className="size-3.5" />
{t('mcp.title')}
</Badge>
<Badge
variant="outline"
className={`shrink-0 gap-1.5 text-[0.7rem] ${connectionStatusClassName[currentConnectionState]}`}
>
<span
className={`size-1.5 rounded-full ${connectionDotClassName[currentConnectionState]}`}
/>
<Label
htmlFor="mcp-enable-switch"
className="text-sm text-muted-foreground cursor-pointer"
>
{t('common.enable')}
</Label>
</div>
)}
{connectionStatusLabel[currentConnectionState]}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
@@ -217,57 +339,32 @@ export default function MCPDetailContent({ id }: { id: string }) {
>
{t('common.test')}
</Button>
<Button type="submit" form="mcp-form" disabled={!formDirty}>
<Button
type="submit"
form="mcp-form"
disabled={!formDirty || saveBlockedByBox}
>
{t('common.save')}
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<MCPForm
ref={formRef}
initServerName={id}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onDirtyChange={setFormDirty}
onTestingChange={setMcpTesting}
/>
{/* Card: Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('mcp.dangerZone')}
</CardTitle>
<CardDescription>
{t('mcp.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('mcp.deleteMCPAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('mcp.deleteMCPHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</div>
<div className="min-h-0 flex-1">
<MCPForm
ref={formRef}
initServerName={id}
layout="split"
sideHeader={enableControl}
sideFooter={editActions}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onDirtyChange={setFormDirty}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
onRuntimeInfoChange={(runtimeInfo) =>
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
}
/>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@ import {
TrendingUp,
TrendingDown,
Minus,
Heart,
Smile,
} from 'lucide-react';
interface FeedbackCardProps {
@@ -133,11 +135,7 @@ export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
{
title: t('monitoring.feedback.totalFeedback'),
value: stats?.totalFeedback ?? 0,
icon: (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
),
icon: <Heart className="w-6 h-6" />,
variant: 'default' as const,
},
{
@@ -155,11 +153,7 @@ export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
{
title: t('monitoring.feedback.satisfactionRate'),
value: stats ? `${stats.satisfactionRate}%` : '0%',
icon: (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
</svg>
),
icon: <Smile className="w-6 h-6" />,
variant: (stats && stats.satisfactionRate >= 80
? 'success'
: stats && stats.satisfactionRate >= 50

View File

@@ -6,6 +6,7 @@ import {
ChevronRight,
ChevronDown,
ExternalLink,
Heart,
} from 'lucide-react';
import { FeedbackRecord } from '../types/monitoring';
import { Button } from '@/components/ui/button';
@@ -40,19 +41,7 @@ export function FeedbackList({
if (!feedback || feedback.length === 0) {
return (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<Heart className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-base font-medium mb-2">
{t('monitoring.feedback.noFeedback')}
</p>

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { Paperclip, AudioLines } from 'lucide-react';
import {
MessageChainComponent,
Image as ImageComponent,
@@ -104,13 +105,7 @@ export function MessageContentRenderer({
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
>
<svg
className="w-3.5 h-3.5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" />
</svg>
<Paperclip className="w-3.5 h-3.5 mr-1" />
{file.name || 'File'}
</span>
);
@@ -123,13 +118,7 @@ export function MessageContentRenderer({
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
>
<svg
className="w-3.5 h-3.5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
<AudioLines className="w-3.5 h-3.5 mr-1" />
Voice{voice.length ? ` ${voice.length}s` : ''}
</span>
);

View File

@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Info, Clock, AlertCircle, Braces } from 'lucide-react';
import { MessageDetails } from '../types/monitoring';
interface MessageDetailsCardProps {
@@ -25,14 +26,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{details.message && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 7H13V9H11V7ZM11 11H13V17H11V11Z"></path>
</svg>
<Info className="w-4 h-4 mr-2" />
{t('monitoring.messageList.viewDetails')}
</h4>
@@ -92,14 +86,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{details.llmCalls && details.llmCalls.length > 0 && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 2C17.52 2 22 6.48 22 12C22 17.52 17.52 22 12 22C6.48 22 2 17.52 2 12C2 6.48 6.48 2 12 2ZM12 20C16.42 20 20 16.42 20 12C20 7.58 16.42 4 12 4C7.58 4 4 7.58 4 12C4 16.42 7.58 20 12 20ZM13 12V7H11V14H17V12H13Z"></path>
</svg>
<Clock className="w-4 h-4 mr-2" />
{t('monitoring.llmCalls.title')} ({details.llmCalls.length})
</h4>
@@ -183,14 +170,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{details.errors && details.errors.length > 0 && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM11 7H13V13H11V7Z"></path>
</svg>
<AlertCircle className="w-4 h-4 mr-2" />
{t('monitoring.errors.title')} ({details.errors.length})
</h4>
<div className="space-y-2">
@@ -227,14 +207,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
details.message?.runnerName !== 'local-agent' && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V9.7C6 10.7065 5.41099 11.5849 4.55132 12C5.41099 12.4151 6 13.2935 6 14.3V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V14.3C18 13.2935 18.589 12.4151 19.4487 12C18.589 11.5849 18 10.7065 18 9.7V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z"></path>
</svg>
<Braces className="w-4 h-4 mr-2" />
{t('monitoring.queryVariables.title')}
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { TrendingUp, TrendingDown } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
interface MetricCardProps {
@@ -61,21 +62,11 @@ export default function MetricCard({
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
{trend.direction === 'up' ? (
<path
fillRule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
)}
</svg>
{trend.direction === 'up' ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{Math.abs(trend.value)}%
</span>
<span className="text-xs text-muted-foreground">

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MessageSquare, Sparkles, Check, Users } from 'lucide-react';
import MetricCard from './MetricCard';
import SystemStatusCard from './SystemStatusCards';
import TrafficChart from './TrafficChart';
import {
OverviewMetrics,
@@ -13,6 +15,7 @@ interface OverviewCardsProps {
messages?: MonitoringMessage[];
llmCalls?: LLMCall[];
loading?: boolean;
refreshKey?: number;
}
export default function OverviewCards({
@@ -20,6 +23,7 @@ export default function OverviewCards({
messages = [],
llmCalls = [],
loading,
refreshKey,
}: OverviewCardsProps) {
const { t } = useTranslation();
@@ -27,15 +31,7 @@ export default function OverviewCards({
{
title: t('monitoring.totalMessages'),
value: metrics?.totalMessages || 0,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM4 18.3851L5.76282 17H20V5H4V18.3851Z"></path>
</svg>
),
icon: <MessageSquare />,
trend: metrics?.trends
? {
value: metrics.trends.messages,
@@ -48,15 +44,7 @@ export default function OverviewCards({
{
title: t('monitoring.modelCallsCount'),
value: metrics?.modelCalls || 0,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899Z"></path>
</svg>
),
icon: <Sparkles />,
trend: metrics?.trends
? {
value: metrics.trends.llmCalls,
@@ -69,15 +57,7 @@ export default function OverviewCards({
{
title: t('monitoring.successRate'),
value: metrics ? `${metrics.successRate}%` : '0%',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10 15.172L19.192 5.979L20.607 7.393L10 18L3.636 11.636L5.05 10.222L10 15.172Z"></path>
</svg>
),
icon: <Check />,
trend: metrics?.trends
? {
value: metrics.trends.successRate,
@@ -90,15 +70,7 @@ export default function OverviewCards({
{
title: t('monitoring.activeSessions'),
value: metrics?.activeSessions || 0,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.7519 23 22H21C21 19.3742 19.4041 17.1096 17.1582 16.2466L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
</svg>
),
icon: <Users />,
trend: metrics?.trends
? {
value: metrics.trends.sessions,
@@ -112,8 +84,8 @@ export default function OverviewCards({
return (
<div className="space-y-6">
{/* Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
{/* Metric Cards + System Status */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-6">
{cards.map((card, index) => (
<MetricCard
key={index}
@@ -124,6 +96,7 @@ export default function OverviewCards({
loading={loading}
/>
))}
<SystemStatusCard refreshKey={refreshKey} />
</div>
{/* Traffic Chart */}

View File

@@ -0,0 +1,399 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Plug,
Box,
CircleCheck,
CircleX,
Loader2,
Info,
Container,
Clock,
Cpu,
HardDrive,
Network,
Image,
FolderOpen,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
ApiRespPluginSystemStatus,
ApiRespBoxStatus,
BoxSessionInfo,
} from '@/app/infra/entities/api';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { httpClient } from '@/app/infra/http/HttpClient';
type StatusState = 'ok' | 'disabled' | 'failed' | null;
function StatusDot({ state }: { state: StatusState }) {
if (state === null)
return <span className="w-2 h-2 rounded-full bg-muted-foreground/40" />;
if (state === 'ok')
return <span className="w-2 h-2 rounded-full bg-green-500" />;
if (state === 'disabled')
return <span className="w-2 h-2 rounded-full bg-muted-foreground/60" />;
return <span className="w-2 h-2 rounded-full bg-red-500" />;
}
interface SystemStatusCardProps {
refreshKey?: number;
}
export default function SystemStatusCard({
refreshKey,
}: SystemStatusCardProps) {
const { t } = useTranslation();
const [pluginStatus, setPluginStatus] =
useState<ApiRespPluginSystemStatus | null>(null);
const [boxStatus, setBoxStatus] = useState<ApiRespBoxStatus | null>(null);
const [boxSessions, setBoxSessions] = useState<BoxSessionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const fetchStatus = useCallback(async () => {
try {
const [plugin, box, sessions] = await Promise.all([
httpClient.getPluginSystemStatus().catch(() => null),
httpClient.getBoxStatus().catch(() => null),
httpClient.getBoxSessions().catch(() => [] as BoxSessionInfo[]),
]);
setPluginStatus(plugin);
setBoxStatus(box);
setBoxSessions(sessions);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 30_000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchStatus, refreshKey]);
const pluginOk = pluginStatus
? pluginStatus.is_enable && pluginStatus.is_connected
: null;
const pluginState: StatusState = pluginStatus
? pluginStatus.is_enable && pluginStatus.is_connected
? 'ok'
: !pluginStatus.is_enable
? 'disabled'
: 'failed'
: null;
const boxOk = boxStatus ? boxStatus.available : null;
// Box has three observable states: connected (ok), disabled by config
// (enabled = false → distinct gray dot + "disabled" hint), and configured
// but failed (red dot + connector_error). The dashboard must distinguish
// them so operators can tell intentional-off from misconfigured.
const boxState: StatusState = boxStatus
? boxStatus.available
? 'ok'
: boxStatus.enabled === false
? 'disabled'
: 'failed'
: null;
const handleOpenDialog = (e: React.MouseEvent) => {
e.stopPropagation();
fetchStatus();
setDialogOpen(true);
};
if (loading) {
return (
<Card className="transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('monitoring.systemStatus')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<>
<Card className="transition-all duration-300 group">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('monitoring.systemStatus')}
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={handleOpenDialog}
>
<Info className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center gap-2">
<StatusDot state={pluginState} />
<Plug className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-sm">{t('monitoring.pluginRuntime')}</span>
</div>
<div className="flex items-center gap-2">
<StatusDot state={boxState} />
<Box className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-sm">{t('monitoring.boxRuntime')}</span>
</div>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('monitoring.systemStatus')}</DialogTitle>
</DialogHeader>
<TooltipProvider>
<div className="space-y-5 overflow-y-auto flex-1 pr-1">
{/* Plugin Runtime */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Plug className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold">
{t('monitoring.pluginRuntime')}
</span>
</div>
<div className="ml-6 text-sm space-y-1">
<div className="flex items-center gap-1.5">
{pluginOk ? (
<CircleCheck className="w-4 h-4 text-green-600" />
) : (
<CircleX className="w-4 h-4 text-red-500" />
)}
<span
className={
pluginOk
? 'text-green-600 font-medium'
: 'text-red-500 font-medium'
}
>
{pluginOk
? t('monitoring.connected')
: pluginStatus && !pluginStatus.is_enable
? t('monitoring.disabled')
: t('monitoring.disconnected')}
</span>
</div>
{pluginStatus && !pluginStatus.is_enable && (
<p className="text-muted-foreground text-xs">
{t('monitoring.pluginDisabled')}
</p>
)}
{pluginStatus &&
!pluginOk &&
pluginStatus.is_enable &&
pluginStatus.plugin_connector_error &&
pluginStatus.plugin_connector_error !== 'ok' && (
<p className="text-red-400 text-xs break-all">
{pluginStatus.plugin_connector_error}
</p>
)}
</div>
</div>
<div className="border-t" />
{/* Box Runtime */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Box className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold">
{t('monitoring.boxRuntime')}
</span>
</div>
<div className="ml-6 text-sm space-y-1">
<div className="flex items-center gap-1.5">
{boxState === 'ok' ? (
<CircleCheck className="w-4 h-4 text-green-600" />
) : (
<CircleX
className={
boxState === 'disabled'
? 'w-4 h-4 text-muted-foreground'
: 'w-4 h-4 text-red-500'
}
/>
)}
<span
className={
boxState === 'ok'
? 'text-green-600 font-medium'
: boxState === 'disabled'
? 'text-muted-foreground font-medium'
: 'text-red-500 font-medium'
}
>
{boxState === 'ok'
? t('monitoring.connected')
: boxState === 'disabled'
? t('monitoring.disabled')
: t('monitoring.disconnected')}
</span>
</div>
{boxState === 'disabled' && (
<p className="text-muted-foreground text-xs">
{t('monitoring.boxDisabled')}
</p>
)}
{boxState === 'failed' && boxStatus?.connector_error && (
<p className="text-red-400 text-xs break-all">
{boxStatus.connector_error}
</p>
)}
{boxStatus && (
<div className="text-muted-foreground text-xs space-y-0.5">
{boxStatus.backend && (
<p>
{t('monitoring.boxBackend')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.backend.name}
</span>
</p>
)}
<p>
{t('monitoring.boxProfile')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.profile}
</span>
</p>
{boxOk && boxStatus.active_sessions !== undefined && (
<p>
{t('monitoring.boxSandboxes')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.active_sessions}
</span>
</p>
)}
</div>
)}
{/* Active Sandboxes */}
{boxSessions.length > 0 && (
<div className="mt-3 space-y-2">
{boxSessions.map((session) => (
<div
key={session.session_id}
className="rounded-lg border p-3 space-y-2"
>
<div className="flex items-center gap-1.5 min-w-0">
<Container className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<span className="font-mono font-semibold text-foreground truncate text-sm">
{session.session_id}
</span>
</TooltipTrigger>
<TooltipContent>
{session.session_id}
</TooltipContent>
</Tooltip>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground min-w-0">
<Image className="w-3 h-3 flex-shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-foreground font-mono truncate">
{session.image}
</span>
</TooltipTrigger>
<TooltipContent>{session.image}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<HardDrive className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.backend_name}
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Cpu className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.cpus} CPU / {session.memory_mb} MB
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Network className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.network}
</span>
</div>
{session.host_path && (
<div className="flex items-center gap-1.5 text-muted-foreground col-span-2 min-w-0">
<FolderOpen className="w-3 h-3 flex-shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-foreground font-mono truncate">
{session.host_path} : {session.mount_path}{' '}
<span className="text-muted-foreground">
({session.host_path_mode})
</span>
</span>
</TooltipTrigger>
<TooltipContent>
{session.host_path} : {session.mount_path} (
{session.host_path_mode})
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="w-3 h-3 flex-shrink-0" />
<span>
{t('monitoring.boxSessionCreated')}:{' '}
<span className="text-foreground">
{new Date(
session.created_at,
).toLocaleString()}
</span>
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="w-3 h-3 flex-shrink-0" />
<span>
{t('monitoring.boxSessionLastUsed')}:{' '}
<span className="text-foreground">
{new Date(
session.last_used_at,
).toLocaleString()}
</span>
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</TooltipProvider>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { BarChart3 } from 'lucide-react';
import {
AreaChart,
Area,
@@ -146,14 +147,7 @@ export default function TrafficChart({
{t('monitoring.trafficChart.title')}
</h3>
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 13H8V21H2V13ZM16 8H22V21H16V8ZM9 3H15V21H9V3ZM4 15V19H6V15H4ZM11 5V19H13V5H11ZM18 10V19H20V10H18Z"></path>
</svg>
<BarChart3 className="h-[3rem] w-[3rem]" />
<div className="text-sm">{t('monitoring.trafficChart.noData')}</div>
</div>
</div>

View File

@@ -2,7 +2,15 @@ import React, { Suspense, useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
import {
ChevronRight,
ChevronDown,
ExternalLink,
RefreshCw,
MessageSquare,
Sparkles,
CheckCircle2,
} from 'lucide-react';
import OverviewCards from './components/overview-cards/OverviewCards';
import MonitoringFilters from './components/filters/MonitoringFilters';
import { ExportDropdown } from './components/ExportDropdown';
@@ -259,8 +267,8 @@ function MonitoringPageContent() {
return (
<div className="w-full h-full overflow-y-auto overflow-x-hidden">
{/* Filters and Refresh Button - Sticky */}
<div className="sticky top-[-1.5rem] z-10 -ml-[2rem] -mr-[1.5rem] -mt-[1.5rem] pt-[1.5rem] pb-4 bg-background">
<div className="ml-[2rem] mr-[1.5rem] px-[0.8rem]">
<div className="sticky top-0 z-10 -mt-1 pb-5 pt-1 bg-background">
<div>
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-card rounded-xl border">
<MonitoringFilters
selectedBots={filterState.selectedBots}
@@ -278,14 +286,7 @@ function MonitoringPageContent() {
onClick={handleRefresh}
className="shadow-sm flex-shrink-0"
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path>
</svg>
<RefreshCw className="w-4 h-4 mr-2" />
{t('monitoring.refreshData')}
</Button>
</div>
@@ -294,7 +295,7 @@ function MonitoringPageContent() {
</div>
{/* Content Area */}
<div className="flex flex-col gap-6 px-[0.8rem] pb-4">
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
{/* Overview Section */}
<OverviewCards
metrics={data?.overview || null}
@@ -452,14 +453,7 @@ function MonitoringPageContent() {
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM4 18.3851L5.76282 17H20V5H4V18.3851Z"></path>
</svg>
<MessageSquare className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t('monitoring.messageList.noMessages')}
</div>
@@ -665,14 +659,7 @@ function MonitoringPageContent() {
!data.modelCalls ||
data.modelCalls.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899Z"></path>
</svg>
<Sparkles className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t('monitoring.modelCalls.noData')}
</div>
@@ -867,14 +854,7 @@ function MonitoringPageContent() {
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<svg
className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11.0026 16L6.75999 11.7574L8.17421 10.3431L11.0026 13.1716L16.6595 7.51472L18.0737 8.92893L11.0026 16Z"></path>
</svg>
<CheckCircle2 className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600" />
<div className="text-sm text-green-600 dark:text-green-400">
{t('monitoring.errors.noErrors')}
</div>

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { X } from 'lucide-react';
interface ImagePreviewDialogProps {
open: boolean;
@@ -28,19 +29,7 @@ export default function ImagePreviewDialog({
onClick={onClose}
className="self-end w-9 h-9 rounded-full bg-white hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100 shadow-lg transition-all hover:scale-105 flex items-center justify-center"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<X className="w-4 h-4" />
</button>
{/* 图片 */}

View File

@@ -2,7 +2,15 @@ import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
import {
ChevronRight,
ChevronDown,
ExternalLink,
RefreshCw,
MessageCircle,
CheckCircle2,
Monitor,
} from 'lucide-react';
import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData';
import { MessageContentRenderer } from '@/app/home/monitoring/components/MessageContentRenderer';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
@@ -205,14 +213,7 @@ export default function PipelineMonitoringTab({
onClick={onNavigateToMonitoring}
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600"
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19V6.413L11.2071 14.2071L9.79289 12.7929L17.585 5H13V3H21Z"></path>
</svg>
<ExternalLink className="w-4 h-4 mr-2" />
{t('pipelines.monitoring.detailedLogs')}
</Button>
)}
@@ -222,14 +223,7 @@ export default function PipelineMonitoringTab({
onClick={refetch}
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600"
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path>
</svg>
<RefreshCw className="w-4 h-4 mr-2" />
{t('monitoring.refreshData')}
</Button>
</div>
@@ -431,19 +425,7 @@ export default function PipelineMonitoringTab({
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<MessageCircle className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-base font-medium">
{t('monitoring.messageList.noMessages')}
</p>
@@ -543,19 +525,7 @@ export default function PipelineMonitoringTab({
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<CheckCircle2 className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600" />
<p className="text-base font-medium text-green-600 dark:text-green-400">
{t('monitoring.errors.noErrors')}
</p>
@@ -638,19 +608,7 @@ export default function PipelineMonitoringTab({
{!loading &&
(!data || !data.llmCalls || data.llmCalls.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<Monitor className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-base font-medium">
{t('monitoring.llmCalls.noData')}
</p>

View File

@@ -1,6 +1,7 @@
import styles from './pipelineCard.module.css';
import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO';
import { useTranslation } from 'react-i18next';
import { Clock, Star } from 'lucide-react';
export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
const { t } = useTranslation();
@@ -21,14 +22,7 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
</div>
<div className={`${styles.basicInfoLastUpdatedTimeContainer}`}>
<svg
className={`${styles.basicInfoUpdateTimeIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z"></path>
</svg>
<Clock className={`${styles.basicInfoUpdateTimeIcon}`} />
<div className={`${styles.basicInfoUpdateTimeText}`}>
{t('pipelines.updateTime')}
{cardVO.lastUpdatedTimeAgo}
@@ -39,14 +33,7 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
{cardVO.isDefault && (
<div className={styles.operationContainer}>
<div className={styles.operationDefaultBadge}>
<svg
className={styles.operationDefaultBadgeIcon}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z"></path>
</svg>
<Star className={styles.operationDefaultBadgeIcon} />
<div className={styles.operationDefaultBadgeText}>
{t('pipelines.defaultBadge')}
</div>

View File

@@ -12,13 +12,15 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X, Server, Wrench } from 'lucide-react';
import { Plus, X, Server, Wrench, Sparkles } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plugin } from '@/app/infra/entities/plugin';
import { MCPServer } from '@/app/infra/entities/api';
import { MCPServer, Skill } from '@/app/infra/entities/api';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
export default function PipelineExtension({
pipelineId,
@@ -26,19 +28,31 @@ export default function PipelineExtension({
pipelineId: string;
}) {
const { t } = useTranslation();
const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
const [loading, setLoading] = useState(true);
const [enableAllPlugins, setEnableAllPlugins] = useState(true);
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
const [enableAllSkills, setEnableAllSkills] = useState(true);
const [selectedPlugins, setSelectedPlugins] = useState<Plugin[]>([]);
const [allPlugins, setAllPlugins] = useState<Plugin[]>([]);
const [selectedMCPServers, setSelectedMCPServers] = useState<MCPServer[]>([]);
const [allMCPServers, setAllMCPServers] = useState<MCPServer[]>([]);
const [selectedSkills, setSelectedSkills] = useState<Skill[]>([]);
const [allSkills, setAllSkills] = useState<Skill[]>([]);
const [pluginDialogOpen, setPluginDialogOpen] = useState(false);
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const [skillDialogOpen, setSkillDialogOpen] = useState(false);
const [tempSelectedPluginIds, setTempSelectedPluginIds] = useState<string[]>(
[],
);
const [tempSelectedMCPIds, setTempSelectedMCPIds] = useState<string[]>([]);
const [tempSelectedSkillIds, setTempSelectedSkillIds] = useState<string[]>(
[],
);
useEffect(() => {
loadExtensions();
@@ -57,6 +71,7 @@ export default function PipelineExtension({
setEnableAllPlugins(data.enable_all_plugins ?? true);
setEnableAllMCPServers(data.enable_all_mcp_servers ?? true);
setEnableAllSkills(data.enable_all_skills ?? true);
const boundPluginIds = new Set(
data.bound_plugins.map((p) => `${p.author}/${p.name}`),
@@ -77,6 +92,15 @@ export default function PipelineExtension({
setSelectedMCPServers(selectedMCP);
setAllMCPServers(data.available_mcp_servers);
// Load Skills
const boundSkillNames = new Set(data.bound_skills || []);
const selectedSkill = (data.available_skills || []).filter((skill) =>
boundSkillNames.has(skill.name),
);
setSelectedSkills(selectedSkill);
setAllSkills(data.available_skills || []);
} catch (error) {
console.error('Failed to load extensions:', error);
toast.error(t('pipelines.extensions.loadError'));
@@ -88,8 +112,10 @@ export default function PipelineExtension({
const saveToBackend = async (
plugins: Plugin[],
mcpServers: MCPServer[],
skills: Skill[],
newEnableAllPlugins?: boolean,
newEnableAllMCPServers?: boolean,
newEnableAllSkills?: boolean,
) => {
try {
const boundPluginsArray = plugins.map((plugin) => {
@@ -101,6 +127,7 @@ export default function PipelineExtension({
});
const boundMCPServerIds = mcpServers.map((server) => server.uuid || '');
const boundSkillIds = skills.map((skill) => skill.name);
await backendClient.updatePipelineExtensions(
pipelineId,
@@ -108,6 +135,8 @@ export default function PipelineExtension({
boundMCPServerIds,
newEnableAllPlugins ?? enableAllPlugins,
newEnableAllMCPServers ?? enableAllMCPServers,
boundSkillIds,
newEnableAllSkills ?? enableAllSkills,
);
toast.success(t('pipelines.extensions.saveSuccess'));
} catch (error) {
@@ -123,13 +152,19 @@ export default function PipelineExtension({
(p) => getPluginId(p) !== pluginId,
);
setSelectedPlugins(newPlugins);
await saveToBackend(newPlugins, selectedMCPServers);
await saveToBackend(newPlugins, selectedMCPServers, selectedSkills);
};
const handleRemoveMCPServer = async (serverUuid: string) => {
const newServers = selectedMCPServers.filter((s) => s.uuid !== serverUuid);
setSelectedMCPServers(newServers);
await saveToBackend(selectedPlugins, newServers);
await saveToBackend(selectedPlugins, newServers, selectedSkills);
};
const handleRemoveSkill = async (skillName: string) => {
const newSkills = selectedSkills.filter((s) => s.name !== skillName);
setSelectedSkills(newSkills);
await saveToBackend(selectedPlugins, selectedMCPServers, newSkills);
};
const handleOpenPluginDialog = () => {
@@ -142,6 +177,11 @@ export default function PipelineExtension({
setMcpDialogOpen(true);
};
const handleOpenSkillDialog = () => {
setTempSelectedSkillIds(selectedSkills.map((s) => s.name));
setSkillDialogOpen(true);
};
const handleTogglePlugin = (pluginId: string) => {
setTempSelectedPluginIds((prev) =>
prev.includes(pluginId)
@@ -158,33 +198,45 @@ export default function PipelineExtension({
);
};
const handleToggleSkill = (skillName: string) => {
setTempSelectedSkillIds((prev) =>
prev.includes(skillName)
? prev.filter((id) => id !== skillName)
: [...prev, skillName],
);
};
const handleToggleAllPlugins = () => {
if (tempSelectedPluginIds.length === allPlugins.length) {
// Deselect all
setTempSelectedPluginIds([]);
} else {
// Select all
setTempSelectedPluginIds(allPlugins.map((p) => getPluginId(p)));
}
};
const handleToggleAllMCPServers = () => {
if (tempSelectedMCPIds.length === allMCPServers.length) {
// Deselect all
setTempSelectedMCPIds([]);
} else {
// Select all
setTempSelectedMCPIds(allMCPServers.map((s) => s.uuid || ''));
}
};
const handleToggleAllSkills = () => {
if (tempSelectedSkillIds.length === allSkills.length) {
setTempSelectedSkillIds([]);
} else {
setTempSelectedSkillIds(allSkills.map((s) => s.name));
}
};
const handleConfirmPluginSelection = async () => {
const newSelected = allPlugins.filter((p) =>
tempSelectedPluginIds.includes(getPluginId(p)),
);
setSelectedPlugins(newSelected);
setPluginDialogOpen(false);
await saveToBackend(newSelected, selectedMCPServers);
await saveToBackend(newSelected, selectedMCPServers, selectedSkills);
};
const handleConfirmMCPSelection = async () => {
@@ -193,7 +245,16 @@ export default function PipelineExtension({
);
setSelectedMCPServers(newSelected);
setMcpDialogOpen(false);
await saveToBackend(selectedPlugins, newSelected);
await saveToBackend(selectedPlugins, newSelected, selectedSkills);
};
const handleConfirmSkillSelection = async () => {
const newSelected = allSkills.filter((s) =>
tempSelectedSkillIds.includes(s.name),
);
setSelectedSkills(newSelected);
setSkillDialogOpen(false);
await saveToBackend(selectedPlugins, selectedMCPServers, newSelected);
};
const handleToggleEnableAllPlugins = async (checked: boolean) => {
@@ -201,8 +262,10 @@ export default function PipelineExtension({
await saveToBackend(
selectedPlugins,
selectedMCPServers,
selectedSkills,
checked,
undefined,
undefined,
);
};
@@ -211,6 +274,20 @@ export default function PipelineExtension({
await saveToBackend(
selectedPlugins,
selectedMCPServers,
selectedSkills,
undefined,
checked,
undefined,
);
};
const handleToggleEnableAllSkills = async (checked: boolean) => {
setEnableAllSkills(checked);
await saveToBackend(
selectedPlugins,
selectedMCPServers,
selectedSkills,
undefined,
undefined,
checked,
);
@@ -432,6 +509,88 @@ export default function PipelineExtension({
</Button>
</div>
{/* Skills Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.skillsTitle')}
</h3>
<div className="flex items-center gap-2">
<Label
htmlFor="enable-all-skills"
className="text-sm font-normal cursor-pointer"
>
{t('pipelines.extensions.enableAllSkills')}
</Label>
<Switch
id="enable-all-skills"
checked={enableAllSkills}
onCheckedChange={handleToggleEnableAllSkills}
disabled={!boxAvailable}
/>
</div>
</div>
{!boxAvailable && (
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
)}
<div className="space-y-2">
{enableAllSkills ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.allSkillsEnabled')}
</p>
</div>
) : selectedSkills.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noSkillsSelected')}
</p>
</div>
) : (
<div className="space-y-2">
{selectedSkills.map((skill) => (
<div
key={skill.name}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex-1 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Sparkles className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">
{skill.display_name || skill.name}
</div>
<div className="text-sm text-muted-foreground">
{skill.description}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveSkill(skill.name)}
disabled={!boxAvailable}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
<Button
onClick={handleOpenSkillDialog}
variant="outline"
className="w-full"
disabled={enableAllSkills || !boxAvailable}
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addSkill')}
</Button>
</div>
{/* Plugin Selection Dialog */}
<Dialog open={pluginDialogOpen} onOpenChange={setPluginDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
@@ -620,6 +779,73 @@ export default function PipelineExtension({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Skill Selection Dialog */}
<Dialog open={skillDialogOpen} onOpenChange={setSkillDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('pipelines.extensions.selectSkills')}</DialogTitle>
</DialogHeader>
{allSkills.length > 0 && (
<div
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
onClick={handleToggleAllSkills}
>
<Checkbox
checked={
tempSelectedSkillIds.length === allSkills.length &&
allSkills.length > 0
}
onCheckedChange={handleToggleAllSkills}
/>
<span className="text-sm font-medium">
{t('pipelines.extensions.selectAll')}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allSkills.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noSkillsAvailable')}
</p>
</div>
) : (
allSkills.map((skill) => {
const isSelected = tempSelectedSkillIds.includes(skill.name);
return (
<div
key={skill.name}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleToggleSkill(skill.name)}
>
<Checkbox checked={isSelected} />
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Sparkles className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">
{skill.display_name || skill.name}
</div>
<div className="text-sm text-muted-foreground">
{skill.description}
</div>
</div>
</div>
);
})
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSkillDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirmSkillSelection}>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
} from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { Button } from '@/components/ui/button';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -75,6 +76,7 @@ export default function PipelineFormComponent({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
const { available: boxAvailable } = useBoxStatus();
const formSchema = isEditMode
? z.object({
@@ -185,6 +187,10 @@ export default function PipelineFormComponent({
if (!isEditMode || !savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
}, [isEditMode, watchedValues]);
// Keep a ref so that non-reactive callbacks (handleDynamicFormEmit) can
// read the latest dirty state without stale closures.
const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
hasUnsavedChangesRef.current = hasUnsavedChanges;
// Notify parent when dirty state changes
useEffect(() => {
@@ -304,6 +310,9 @@ export default function PipelineFormComponent({
// Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
// However, if the form is already dirty (the user has made real changes),
// we must NOT re-capture the snapshot — otherwise we would silently absorb
// those real changes and flip hasUnsavedChanges back to false.
function handleDynamicFormEmit(
formName: keyof FormValues,
stageName: string,
@@ -322,9 +331,14 @@ export default function PipelineFormComponent({
if (isFirstEmission) {
initializedStagesRef.current.add(stageKey);
// Synchronously re-capture snapshot so that the useMemo comparison
// in the same render cycle still returns false.
savedSnapshotRef.current = JSON.stringify(form.getValues());
// Only re-capture the snapshot when the form has no other pending
// changes. If the user already modified something (e.g. switched
// runner), the snapshot must remain at the last-saved state so that
// hasUnsavedChanges stays true.
const currentSnapshot = JSON.stringify(form.getValues());
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
savedSnapshotRef.current = currentSnapshot;
}
}
}
@@ -401,6 +415,16 @@ export default function PipelineFormComponent({
}
}
// Box availability is exposed through ``systemContext.__system.box_available``
// so individual yaml-driven fields (e.g. ``box-session-id-template``) can
// opt-in via ``disable_if`` + ``disabled_tooltip`` rather than every page
// hard-coding a banner. Field-level gating keeps unrelated fields
// untouched.
const stageSystemContext =
stage.name === 'local-agent'
? { box_available: boxAvailable }
: undefined;
return (
<Card key={stage.name}>
<CardHeader>
@@ -421,6 +445,7 @@ export default function PipelineFormComponent({
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
systemContext={stageSystemContext}
/>
</CardContent>
</Card>

View File

@@ -1,10 +1,34 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Bug } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Plugin } from '@/app/infra/entities/plugin';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { Bug, Puzzle, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
/**
* Plugin detail page content.
@@ -12,7 +36,11 @@ import { Bug } from 'lucide-react';
*/
export default function PluginDetailContent({ id }: { id: string }) {
const { t } = useTranslation();
const navigate = useNavigate();
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
const [pluginInfo, setPluginInfo] = useState<Plugin | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteData, setDeleteData] = useState(false);
// Parse "author/name" composite key
const slashIndex = id.indexOf('/');
@@ -20,6 +48,23 @@ export default function PluginDetailContent({ id }: { id: string }) {
const pluginName = slashIndex >= 0 ? id.substring(slashIndex + 1) : id;
const plugin = plugins.find((p) => p.id === id);
const title =
pluginInfo?.manifest.manifest.metadata.label &&
extractI18nObject(pluginInfo.manifest.manifest.metadata.label)
? extractI18nObject(pluginInfo.manifest.manifest.metadata.label)
: plugin?.name || `${pluginAuthor}/${pluginName}`;
const description = pluginInfo?.manifest.manifest.metadata.description
? extractI18nObject(pluginInfo.manifest.manifest.metadata.description)
: plugin?.description;
const asyncTask = useAsyncTask({
onSuccess: () => {
toast.success(t('plugins.deleteSuccess'));
setShowDeleteConfirm(false);
void refreshPlugins();
navigate('/home/extensions');
},
});
// Set breadcrumb entity name
useEffect(() => {
@@ -27,6 +72,18 @@ export default function PluginDetailContent({ id }: { id: string }) {
return () => setDetailEntityName(null);
}, [plugin, pluginAuthor, pluginName, setDetailEntityName]);
useEffect(() => {
let cancelled = false;
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
if (!cancelled) {
setPluginInfo(res.plugin);
}
});
return () => {
cancelled = true;
};
}, [pluginAuthor, pluginName]);
function handleFormSubmit(timeout?: number) {
if (timeout) {
setTimeout(() => {
@@ -37,60 +94,199 @@ export default function PluginDetailContent({ id }: { id: string }) {
}
}
function executeDelete() {
httpClient
.removePlugin(pluginAuthor, pluginName, deleteData)
.then((res) => {
asyncTask.startTask(res.task_id);
})
.catch((error) => {
toast.error(t('plugins.deleteError') + error.message);
});
}
const sourceBadge = plugin?.debug ? (
<Badge
variant="outline"
className="shrink-0 border-orange-400 text-[0.7rem] text-orange-400"
>
<Bug className="size-3.5" />
{t('plugins.debugging')}
</Badge>
) : plugin?.installSource === 'github' ? (
<Badge
variant="outline"
className="shrink-0 border-blue-400 text-[0.7rem] text-blue-400"
>
{t('plugins.fromGithub')}
</Badge>
) : plugin?.installSource === 'local' ? (
<Badge
variant="outline"
className="shrink-0 border-green-400 text-[0.7rem] text-green-400"
>
{t('plugins.fromLocal')}
</Badge>
) : plugin?.installSource === 'marketplace' ? (
<Badge
variant="outline"
className="shrink-0 border-purple-400 text-[0.7rem] text-purple-400"
>
{t('plugins.fromMarketplace')}
</Badge>
) : null;
const componentBadges = pluginInfo && (
<PluginComponentList
components={pluginInfo.components.reduce<Record<string, number>>(
(acc, component) => {
const kind = component.manifest.manifest.kind;
acc[kind] = (acc[kind] ?? 0) + 1;
return acc;
},
{},
)}
showComponentName
showTitle={false}
useBadge
t={t}
/>
);
const dangerZone = (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('plugins.dangerZone')}
</CardTitle>
<CardDescription>{t('plugins.dangerZoneDescription')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{t('plugins.deletePlugin')}</p>
<p className="text-sm text-muted-foreground">
{t('plugins.confirmDeletePlugin', {
author: pluginAuthor,
name: pluginName,
})}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="shrink-0"
>
<Trash2 className="mr-1.5 size-4" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
);
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 pb-4 shrink-0">
<h1 className="text-xl font-semibold">
{pluginAuthor}/{pluginName}
</h1>
{plugin?.debug ? (
<Badge
variant="outline"
className="text-[0.7rem] border-orange-400 text-orange-400"
>
<Bug className="size-3.5" />
{t('plugins.debugging')}
</Badge>
) : plugin?.installSource === 'github' ? (
<Badge
variant="outline"
className="text-[0.7rem] border-blue-400 text-blue-400"
>
{t('plugins.fromGithub')}
</Badge>
) : plugin?.installSource === 'local' ? (
<Badge
variant="outline"
className="text-[0.7rem] border-green-400 text-green-400"
>
{t('plugins.fromLocal')}
</Badge>
) : plugin?.installSource === 'marketplace' ? (
<Badge
variant="outline"
className="text-[0.7rem] border-purple-400 text-purple-400"
>
{t('plugins.fromMarketplace')}
</Badge>
) : null}
<>
<div className="flex h-full flex-col">
<div className="flex shrink-0 flex-col gap-2 pb-4">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<h1 className="truncate text-xl font-semibold">{title}</h1>
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
<Puzzle className="size-3.5" />
{t('market.typePlugin')}
</Badge>
{sourceBadge}
{componentBadges}
</div>
{description && (
<p className="line-clamp-2 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
<div className="flex min-h-0 max-w-full flex-1 flex-col gap-6 overflow-y-auto md:flex-row md:overflow-hidden">
<div className="min-w-0 max-w-full space-y-4 pb-6 md:min-h-0 md:w-[380px] md:flex-shrink-0 md:overflow-y-auto md:overflow-x-hidden xl:w-[420px]">
<PluginForm
pluginAuthor={pluginAuthor}
pluginName={pluginName}
onFormSubmit={handleFormSubmit}
/>
{dangerZone}
</div>
<div className="hidden w-px shrink-0 bg-border md:block" />
<div className="min-w-0 flex-1 pb-6 md:min-h-0 md:overflow-y-auto md:overflow-x-hidden">
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />
</div>
</div>
</div>
<div className="flex flex-1 flex-col md:flex-row overflow-hidden min-h-0 gap-6 max-w-full">
{/* Left side - Config */}
<div className="md:w-[380px] md:flex-shrink-0 overflow-y-auto overflow-x-hidden">
<PluginForm
pluginAuthor={pluginAuthor}
pluginName={pluginName}
onFormSubmit={handleFormSubmit}
/>
</div>
{/* Divider */}
<div className="hidden md:block w-px bg-border shrink-0" />
{/* Right side - Readme */}
<div className="flex-1 overflow-y-auto overflow-x-hidden min-w-0">
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />
</div>
</div>
</div>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('plugins.deleteConfirm')}</DialogTitle>
<DialogDescription>
{asyncTask.status === AsyncTaskStatus.RUNNING
? t('plugins.deleting')
: t('plugins.confirmDeletePlugin', {
author: pluginAuthor,
name: pluginName,
})}
</DialogDescription>
</DialogHeader>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div className="flex items-center space-x-2">
<Checkbox
id="delete-plugin-data"
checked={deleteData}
onCheckedChange={(checked) => setDeleteData(checked === true)}
/>
<label
htmlFor="delete-plugin-data"
className="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('plugins.deleteDataCheckbox')}
</label>
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div className="text-sm text-destructive">{asyncTask.error}</div>
)}
<DialogFooter>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button variant="destructive" onClick={executeDelete}>
{t('common.confirmDelete')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<Button variant="destructive" disabled>
{t('plugins.deleting')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<Button
variant="outline"
onClick={() => {
setShowDeleteConfirm(false);
asyncTask.reset();
}}
>
{t('plugins.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,203 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Archive, CheckCircle2, Loader2, Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { httpClient } from '@/app/infra/http/HttpClient';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
type PluginLocalPreview = Awaited<
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
>;
interface PluginLocalPreviewPanelProps {
file: File;
onInstallStarted?: () => void;
onCancel?: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
export default function PluginLocalPreviewPanel({
file,
onInstallStarted,
onCancel,
}: PluginLocalPreviewPanelProps) {
const { t } = useTranslation();
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
const [preview, setPreview] = useState<PluginLocalPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const [installing, setInstalling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const loadPreview = useCallback(async () => {
setPreviewing(true);
setPreview(null);
setErrorMessage(null);
try {
const result = await httpClient.previewPluginInstallFromLocal(file);
setPreview(result);
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('plugins.localPreview.failed'));
} finally {
setPreviewing(false);
}
}, [file, t]);
useEffect(() => {
void loadPreview();
}, [loadPreview]);
async function handleInstall() {
setInstalling(true);
setErrorMessage(null);
try {
const resp = await httpClient.installPluginFromLocal(file);
const taskId = resp.task_id;
const taskKey = `local-${taskId}`;
const pluginName =
preview?.metadata.label && extractI18nObject(preview.metadata.label)
? extractI18nObject(preview.metadata.label)
: preview?.metadata.name || file.name;
addTask({
taskId,
pluginName,
source: 'local',
extensionType: 'plugin',
fileSize: file.size,
});
setSelectedTaskId(taskKey);
toast.success(t('plugins.installSuccess'));
onInstallStarted?.();
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('plugins.installFailed'));
} finally {
setInstalling(false);
}
}
const metadata = preview?.metadata;
const label = metadata?.label ? extractI18nObject(metadata.label) : '';
const description = metadata?.description
? extractI18nObject(metadata.description)
: '';
const componentCounts = preview?.component_counts || {};
return (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-md bg-muted/40 px-3 py-3">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
{previewing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Archive className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{previewing
? t('plugins.localPreview.unpacking')
: t('plugins.localPreview.unpackComplete')}
</div>
<div className="mt-1 break-all text-xs text-muted-foreground">
{file.name} · {formatFileSize(file.size)}
</div>
</div>
</div>
{preview && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Package className="size-4" />
{t('plugins.localPreview.pluginInfo')}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.name')}
</span>
<span className="truncate font-medium">
{label || metadata?.name || '-'}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.author')}
</span>
<span className="truncate">{metadata?.author || '-'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.version')}
</span>
<span>{metadata?.version || '-'}</span>
</div>
</div>
{description && (
<p className="text-sm leading-6 text-muted-foreground">
{description}
</p>
)}
<div className="flex flex-wrap items-center gap-2 text-sm">
<PluginComponentList
components={componentCounts}
showComponentName
showTitle
useBadge
t={t}
/>
</div>
</div>
)}
{preview && (
<div className="flex items-center gap-2 text-sm text-green-700 dark:text-green-300">
<CheckCircle2 className="size-4" />
{t('plugins.localPreview.ready')}
</div>
)}
{errorMessage && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2">
{onCancel && (
<Button variant="outline" onClick={onCancel} disabled={installing}>
{t('common.cancel')}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={!preview || previewing || installing}
>
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
</Button>
</div>
</div>
);
}

View File

@@ -10,8 +10,8 @@ import { Button } from '@/components/ui/button';
import {
Download,
Package,
Settings,
Rocket,
Server,
BookOpen,
CheckCircle2,
XCircle,
Loader2,
@@ -39,16 +39,6 @@ const STAGES: {
icon: Package,
i18nKey: 'plugins.installProgress.installingDeps',
},
{
key: InstallStage.INITIALIZING,
icon: Settings,
i18nKey: 'plugins.installProgress.initializing',
},
{
key: InstallStage.LAUNCHING,
icon: Rocket,
i18nKey: 'plugins.installProgress.launching',
},
];
function getStageIndex(stage: InstallStage): number {
@@ -183,6 +173,15 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
const isDone = task.stage === InstallStage.DONE;
const isError = task.stage === InstallStage.ERROR;
// MCP / Skill don't have the plugin's download + dependency-install stages;
// show a single "installing → done/failed" row instead of plugin steps.
const isPlugin = task.extensionType === 'plugin';
const simpleIcon = task.extensionType === 'mcp' ? Server : BookOpen;
const simpleInstallingLabel =
task.extensionType === 'mcp'
? t('addExtension.installStage.mcpInstalling')
: t('addExtension.installStage.skillInstalling');
/** Build detail node for a stage */
const getStageDetail = (
stageKey: InstallStage,
@@ -319,42 +318,60 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
{/* Stage display */}
<div className="space-y-1.5">
{isDone
? /* When done: show all stages with completed style */
STAGES.map((stageConfig) => (
<StageRow
key={stageConfig.key}
icon={stageConfig.icon}
label={t(stageConfig.i18nKey)}
isActive={false}
isCompleted={true}
isError={false}
detail={getStageDetail(stageConfig.key, true)}
/>
))
: isError
? /* Error: show the failed stage */
currentStageIndex >= 0 && (
<StageRow
icon={STAGES[currentStageIndex].icon}
label={t(STAGES[currentStageIndex].i18nKey)}
isActive={true}
isCompleted={false}
isError={true}
detail={task.error}
/>
)
: /* In progress: only show the current active stage */
currentStageIndex >= 0 && (
<StageRow
icon={STAGES[currentStageIndex].icon}
label={t(STAGES[currentStageIndex].i18nKey)}
isActive={true}
isCompleted={false}
isError={false}
detail={getStageDetail(STAGES[currentStageIndex].key, false)}
/>
)}
{!isPlugin ? (
/* MCP / Skill: single installing → done/failed row */
<StageRow
icon={simpleIcon}
label={
isDone
? t('addExtension.installStage.installed')
: isError
? t('plugins.installProgress.failed')
: simpleInstallingLabel
}
isActive={!isDone}
isCompleted={isDone}
isError={isError}
detail={isError ? task.error : undefined}
/>
) : isDone ? (
/* When done: show all stages with completed style */
STAGES.map((stageConfig) => (
<StageRow
key={stageConfig.key}
icon={stageConfig.icon}
label={t(stageConfig.i18nKey)}
isActive={false}
isCompleted={true}
isError={false}
detail={getStageDetail(stageConfig.key, true)}
/>
))
) : isError ? (
/* Error: show the failed stage */
currentStageIndex >= 0 && (
<StageRow
icon={STAGES[currentStageIndex].icon}
label={t(STAGES[currentStageIndex].i18nKey)}
isActive={true}
isCompleted={false}
isError={true}
detail={task.error}
/>
)
) : (
/* In progress: only show the current active stage */
currentStageIndex >= 0 && (
<StageRow
icon={STAGES[currentStageIndex].icon}
label={t(STAGES[currentStageIndex].i18nKey)}
isActive={true}
isCompleted={false}
isError={false}
detail={getStageDetail(STAGES[currentStageIndex].key, false)}
/>
)
)}
</div>
{/* Done banner */}

View File

@@ -28,6 +28,7 @@ export interface PluginInstallTask {
source: 'github' | 'marketplace' | 'local';
stage: InstallStage;
overallProgress: number; // 0-100
extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed
fileSize?: number; // bytes, if known
// Download progress
downloadCurrent?: number; // bytes downloaded so far
@@ -57,6 +58,7 @@ interface PluginInstallTaskContextValue {
taskId: number;
pluginName: string;
source: 'github' | 'marketplace' | 'local';
extensionType: 'plugin' | 'mcp' | 'skill';
fileSize?: number;
}) => void;
removeTask: (id: string) => void;
@@ -91,8 +93,8 @@ function mapActionToStage(action: string): InstallStage {
if (lower.includes('dependencies') || lower.includes('requirements'))
return InstallStage.INSTALLING_DEPS;
if (lower.includes('initializ') || lower.includes('setting'))
return InstallStage.INITIALIZING;
if (lower.includes('launch')) return InstallStage.LAUNCHING;
return InstallStage.INSTALLING_DEPS;
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
if (lower.includes('installed') || lower.includes('complete'))
return InstallStage.DONE;
return InstallStage.DOWNLOADING;
@@ -106,7 +108,7 @@ function stageToProgress(stage: InstallStage): number {
case InstallStage.DOWNLOADING:
return 10;
case InstallStage.INSTALLING_DEPS:
return 40;
return 70;
case InstallStage.INITIALIZING:
return 70;
case InstallStage.LAUNCHING:
@@ -135,7 +137,11 @@ function extractSourceFromName(
* Check if a backend task name is a plugin install task.
*/
function isPluginInstallTask(name: string): boolean {
return name.startsWith('plugin-install-');
return (
name.startsWith('plugin-install-') ||
name.startsWith('mcp-install-') ||
name.startsWith('skill-install-')
);
}
/**
@@ -169,13 +175,21 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
overallProgress = Math.min(95, stageToProgress(stage));
}
const pluginName = str(md.plugin_name) || task.label || `${source} plugin`;
const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
let extensionType: 'plugin' | 'mcp' | 'skill' = 'plugin';
if (task.name.startsWith('mcp-install-')) {
extensionType = 'mcp';
} else if (task.name.startsWith('skill-install-')) {
extensionType = 'skill';
}
return {
id: `${source}-${task.id}`,
taskId: task.id,
pluginName,
source,
extensionType,
stage,
overallProgress,
downloadCurrent: num(md.download_current),
@@ -212,8 +226,9 @@ export function PluginInstallTaskProvider({
// Cleanup all intervals on unmount
useEffect(() => {
const intervals = intervalRefs.current;
return () => {
intervalRefs.current.forEach((interval) => {
intervals.forEach((interval) => {
clearInterval(interval);
});
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
@@ -395,6 +410,7 @@ export function PluginInstallTaskProvider({
converted.startedAt = existing.startedAt;
converted.pluginName = existing.pluginName;
converted.fileSize = existing.fileSize;
converted.extensionType = existing.extensionType;
updatedTasks[idx] = converted;
}
}
@@ -408,20 +424,39 @@ export function PluginInstallTaskProvider({
}
}, [pollTask]);
// Initial sync on mount + periodic sync every 3s
// Initial sync on mount to recover any orphaned tasks
const syncOnMountRef = useRef(syncTasksFromBackend);
syncOnMountRef.current = syncTasksFromBackend;
useEffect(() => {
syncTasksFromBackend();
syncIntervalRef.current = setInterval(syncTasksFromBackend, 3000);
syncOnMountRef.current();
}, []);
// Only poll periodically when there are active (non-terminal) tasks
useEffect(() => {
const hasActiveTasks = tasks.some(
(t) => t.stage !== InstallStage.DONE && t.stage !== InstallStage.ERROR,
);
if (hasActiveTasks) {
syncIntervalRef.current = setInterval(syncTasksFromBackend, 3000);
} else {
if (syncIntervalRef.current) {
clearInterval(syncIntervalRef.current);
syncIntervalRef.current = null;
}
}
return () => {
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
};
}, [syncTasksFromBackend]);
}, [tasks, syncTasksFromBackend]);
const addTask = useCallback(
(params: {
taskId: number;
pluginName: string;
source: 'github' | 'marketplace' | 'local';
extensionType: 'plugin' | 'mcp' | 'skill';
fileSize?: number;
}) => {
const taskKey = `${params.source}-${params.taskId}`;
@@ -434,6 +469,7 @@ export function PluginInstallTaskProvider({
taskId: params.taskId,
pluginName: params.pluginName,
source: params.source,
extensionType: params.extensionType,
stage: InstallStage.DOWNLOADING,
overallProgress: 5,
fileSize: params.fileSize,

View File

@@ -4,13 +4,14 @@ import { Progress } from '@/components/ui/progress';
import {
Download,
Package,
Settings,
Rocket,
CheckCircle2,
XCircle,
Loader2,
X,
ListTodo,
Wrench,
AudioWaveform,
Book,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -29,12 +30,16 @@ import { cn } from '@/lib/utils';
const STAGE_ICONS: Record<string, React.ElementType> = {
[InstallStage.DOWNLOADING]: Download,
[InstallStage.INSTALLING_DEPS]: Package,
[InstallStage.INITIALIZING]: Settings,
[InstallStage.LAUNCHING]: Rocket,
[InstallStage.DONE]: CheckCircle2,
[InstallStage.ERROR]: XCircle,
};
const EXTENSION_TYPE_ICONS: Record<string, React.ElementType> = {
plugin: Wrench,
mcp: AudioWaveform,
skill: Book,
};
function TaskQueueItem({
task,
onClick,
@@ -49,6 +54,40 @@ function TaskQueueItem({
const isError = task.stage === InstallStage.ERROR;
const isRunning = !isDone && !isError;
const StageIcon = STAGE_ICONS[task.stage] || Download;
const TypeIcon = EXTENSION_TYPE_ICONS[task.extensionType] || Wrench;
const getTypeBadgeClass = () => {
switch (task.extensionType) {
case 'mcp':
return 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300';
case 'skill':
return 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300';
default:
return 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300';
}
};
const getTypeLabel = () => {
switch (task.extensionType) {
case 'mcp':
return 'MCP';
case 'skill':
return t('common.skill');
default:
return t('market.typePlugin');
}
};
const getInstallCompleteMessage = () => {
switch (task.extensionType) {
case 'mcp':
return t('plugins.installProgress.installCompleteMCP');
case 'skill':
return t('plugins.installProgress.installCompleteSkill');
default:
return t('plugins.installProgress.installCompletePlugin');
}
};
const stageLabel = (() => {
switch (task.stage) {
@@ -56,12 +95,10 @@ function TaskQueueItem({
return t('plugins.installProgress.downloading');
case InstallStage.INSTALLING_DEPS:
return t('plugins.installProgress.installingDeps');
case InstallStage.INITIALIZING:
return t('plugins.installProgress.initializing');
case InstallStage.LAUNCHING:
return t('plugins.installProgress.launching');
case InstallStage.DONE:
return t('plugins.installProgress.completed');
return isDone
? getInstallCompleteMessage()
: t('plugins.installProgress.completed');
case InstallStage.ERROR:
return t('plugins.installProgress.failed');
default:
@@ -93,7 +130,19 @@ function TaskQueueItem({
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{task.pluginName}</div>
<div className="flex items-center gap-2">
<div className="text-sm font-medium truncate">{task.pluginName}</div>
<Badge
variant="outline"
className={cn(
'text-[0.6rem] px-1 py-0 flex-shrink-0',
getTypeBadgeClass(),
)}
>
<TypeIcon className="w-3 h-3 mr-0.5" />
{getTypeLabel()}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{stageLabel}</span>
{isRunning && (
@@ -139,7 +188,7 @@ export default function PluginInstallTaskQueue() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="relative px-4 py-5 cursor-pointer">
<Button variant="outline" className="relative px-4 py-4 cursor-pointer">
<ListTodo className="w-4 h-4 mr-2" />
{t('plugins.installProgress.taskQueue')}
{runningCount > 0 && (

View File

@@ -0,0 +1,327 @@
import { ExtensionCardVO, ExtensionType } from './ExtensionCardVO';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import {
BugIcon,
ExternalLink,
Ellipsis,
Trash,
ArrowUp,
Server,
Sparkles,
Puzzle,
} from 'lucide-react';
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
type ExtensionCardComponentProps = {
cardVO: ExtensionCardVO;
onCardClick: () => void;
onDeleteClick: (cardVO: ExtensionCardVO) => void;
onUpgradeClick?: (cardVO: ExtensionCardVO) => void;
};
export default function ExtensionCardComponent({
cardVO,
onCardClick,
onDeleteClick,
onUpgradeClick,
}: ExtensionCardComponentProps) {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [iconFailed, setIconFailed] = useState(false);
const FallbackIcon =
cardVO.type === 'mcp'
? Server
: cardVO.type === 'skill'
? Sparkles
: Puzzle;
const iconSrc =
cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name);
const showFallback = iconFailed || !iconSrc;
const getTypeLabel = (type: ExtensionType) => {
switch (type) {
case 'mcp':
return 'MCP';
case 'skill':
return t('common.skill');
default:
return t('market.typePlugin');
}
};
const getTypeIcon = (type: ExtensionType) => {
switch (type) {
case 'mcp':
return Server;
case 'skill':
return Sparkles;
default:
return Puzzle;
}
};
const renderTypeBadge = (type: ExtensionType) => {
const TypeIcon = getTypeIcon(type);
return (
<Badge
variant="outline"
className="flex-shrink-0 gap-1.5 border-blue-200 bg-blue-50/60 text-[0.7rem] text-blue-700 dark:border-blue-500/40 dark:bg-blue-500/10 dark:text-blue-300"
>
<TypeIcon className="size-3.5" />
{getTypeLabel(type)}
</Badge>
);
};
const renderPluginContent = () => (
<>
<div className="text-[0.7rem] text-muted-foreground truncate w-full">
{cardVO.author} / {cardVO.name}
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
<div className="text-[1.2rem] text-foreground truncate max-w-[10rem]">
{cardVO.label}
</div>
<Badge variant="outline" className="text-[0.7rem] flex-shrink-0">
v{cardVO.version}
</Badge>
{renderTypeBadge(cardVO.type)}
{cardVO.debug && (
<Badge
variant="outline"
className="text-[0.7rem] border-orange-400 text-orange-400 flex-shrink-0"
>
<BugIcon className="w-4 h-4" />
{t('plugins.debugging')}
</Badge>
)}
{!cardVO.debug && (
<>
{cardVO.install_source === 'github' && (
<Badge
variant="outline"
className="text-[0.7rem] border-blue-400 text-blue-400 flex-shrink-0"
>
{t('plugins.fromGithub')}
</Badge>
)}
{cardVO.install_source === 'local' && (
<Badge
variant="outline"
className="text-[0.7rem] border-green-400 text-green-400 flex-shrink-0"
>
{t('plugins.fromLocal')}
</Badge>
)}
{cardVO.install_source === 'marketplace' && (
<Badge
variant="outline"
className="text-[0.7rem] border-purple-400 text-purple-400 flex-shrink-0"
>
{t('plugins.fromMarketplace')}
</Badge>
)}
</>
)}
</div>
<div className="text-[0.8rem] text-muted-foreground line-clamp-2 w-full">
{cardVO.description}
</div>
</>
);
const renderMCPContent = () => (
<>
<div className="text-[0.7rem] text-muted-foreground truncate w-full">
MCP Server
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
<div className="text-[1.2rem] text-foreground truncate max-w-[10rem]">
{cardVO.label}
</div>
{renderTypeBadge('mcp')}
{cardVO.mode && (
<Badge
variant="outline"
className="text-[0.7rem] border-gray-400 text-gray-600 dark:text-gray-300 flex-shrink-0"
>
{cardVO.mode.toUpperCase()}
</Badge>
)}
<Badge
variant="outline"
className={`text-[0.7rem] flex-shrink-0 ${
cardVO.enabled
? 'border-green-400 text-green-600 dark:text-green-400'
: 'border-gray-400 text-gray-600 dark:text-gray-300'
}`}
>
{cardVO.enabled ? t('mcp.statusConnected') : t('mcp.statusDisabled')}
</Badge>
</div>
<div className="text-[0.8rem] text-muted-foreground line-clamp-2 w-full">
{cardVO.description ||
(cardVO.tools !== undefined && cardVO.tools > 0
? t('mcp.toolCount', { count: cardVO.tools })
: t('mcp.noToolsFound'))}
</div>
</>
);
const renderSkillContent = () => (
<>
<div className="text-[0.7rem] text-muted-foreground truncate w-full">
Skill
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
<div className="text-[1.2rem] text-foreground truncate max-w-[10rem]">
{cardVO.label}
</div>
{renderTypeBadge('skill')}
</div>
<div className="text-[0.8rem] text-muted-foreground line-clamp-2 w-full">
{cardVO.description}
</div>
</>
);
return (
<>
<Card
className="w-full h-[10rem] py-5 px-5 cursor-pointer relative gap-0 shadow-xs transition-shadow duration-200 hover:shadow-md"
onClick={() => onCardClick()}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
{showFallback ? (
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center">
<FallbackIcon className="w-12 h-12 text-blue-500" />
</div>
) : (
<img
src={iconSrc}
alt="extension icon"
className="w-16 h-16 rounded-[8%] flex-shrink-0"
onError={() => setIconFailed(true)}
/>
)}
<div className="flex-1 min-w-0 h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start w-full min-w-0 flex-1 overflow-hidden">
{cardVO.type === 'plugin' && renderPluginContent()}
{cardVO.type === 'mcp' && renderMCPContent()}
{cardVO.type === 'skill' && renderSkillContent()}
</div>
</div>
<div
className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-center"></div>
<div className="flex items-center justify-center">
<DropdownMenu
open={dropdownOpen}
onOpenChange={(open) => {
setDropdownOpen(open);
}}
>
<DropdownMenuTrigger asChild>
<div className="relative">
<Button variant="ghost" size="icon">
<Ellipsis className="w-4 h-4" />
</Button>
{cardVO.hasUpdate && (
<div className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-destructive rounded-full border-2 border-card"></div>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{cardVO.type === 'plugin' &&
cardVO.install_source === 'marketplace' && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (onUpgradeClick) {
onUpgradeClick(cardVO);
}
setDropdownOpen(false);
}}
>
<ArrowUp className="w-4 h-4" />
<span>{t('plugins.update')}</span>
{cardVO.hasUpdate && (
<Badge className="ml-auto bg-red-500 hover:bg-red-500 text-white text-[0.6rem] px-1.5 py-0 h-4">
{t('plugins.new')}
</Badge>
)}
</DropdownMenuItem>
)}
{cardVO.type === 'plugin' &&
(cardVO.install_source === 'github' ||
cardVO.install_source === 'marketplace') && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (cardVO.install_source === 'github') {
window.open(
cardVO.install_info?.github_url as string,
'_blank',
);
} else if (cardVO.install_source === 'marketplace') {
window.open(
getCloudServiceClientSync().getPluginMarketplaceURL(
systemInfo.cloud_service_url,
cardVO.author,
cardVO.name,
),
'_blank',
);
}
setDropdownOpen(false);
}}
>
<ExternalLink className="w-4 h-4" />
<span>{t('plugins.viewSource')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer text-red-600 focus:text-red-600"
onClick={(e) => {
e.stopPropagation();
onDeleteClick(cardVO);
setDropdownOpen(false);
}}
>
<Trash className="w-4 h-4" />
<span>
{cardVO.type === 'mcp'
? t('mcp.deleteServer')
: cardVO.type === 'skill'
? t('skills.delete')
: t('plugins.delete')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</Card>
</>
);
}

View File

@@ -0,0 +1,61 @@
export type ExtensionType = 'plugin' | 'mcp' | 'skill';
export interface IExtensionCardVO {
id: string;
author: string;
label: string;
name: string;
description: string;
version: string;
enabled: boolean;
type: ExtensionType;
iconURL?: string;
install_source?: string;
install_info?: Record<string, unknown>;
status?: string;
debug?: boolean;
hasUpdate?: boolean;
runtimeStatus?: 'connecting' | 'connected' | 'error' | 'disabled';
tools?: number;
mode?: 'stdio' | 'sse' | 'http';
}
export class ExtensionCardVO implements IExtensionCardVO {
id: string;
author: string;
label: string;
name: string;
description: string;
version: string;
enabled: boolean;
type: ExtensionType;
iconURL?: string;
install_source?: string;
install_info?: Record<string, unknown>;
status?: string;
debug?: boolean;
hasUpdate?: boolean;
runtimeStatus?: 'connecting' | 'connected' | 'error' | 'disabled';
tools?: number;
mode?: 'stdio' | 'sse' | 'http';
constructor(prop: IExtensionCardVO) {
this.id = prop.id;
this.author = prop.author;
this.label = prop.label;
this.name = prop.name;
this.description = prop.description;
this.version = prop.version;
this.enabled = prop.enabled;
this.type = prop.type;
this.iconURL = prop.iconURL;
this.install_source = prop.install_source;
this.install_info = prop.install_info;
this.status = prop.status;
this.debug = prop.debug;
this.hasUpdate = prop.hasUpdate;
this.runtimeStatus = prop.runtimeStatus;
this.tools = prop.tools;
this.mode = prop.mode;
}
}

View File

@@ -14,6 +14,7 @@ export interface IPluginCardVO {
components: PluginComponent[];
debug: boolean;
hasUpdate?: boolean;
type?: 'plugin' | 'mcp' | 'skill';
}
export class PluginCardVO implements IPluginCardVO {
@@ -30,6 +31,7 @@ export class PluginCardVO implements IPluginCardVO {
status: string;
components: PluginComponent[];
hasUpdate?: boolean;
type?: 'plugin' | 'mcp' | 'skill';
constructor(prop: IPluginCardVO) {
this.author = prop.author;
@@ -45,5 +47,6 @@ export class PluginCardVO implements IPluginCardVO {
this.install_source = prop.install_source;
this.install_info = prop.install_info;
this.hasUpdate = prop.hasUpdate;
this.type = prop.type;
}
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { useNavigate } from 'react-router-dom';
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
import { ExtensionCardVO, ExtensionType } from './ExtensionCardVO';
import ExtensionCardComponent from './ExtensionCardComponent';
import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import { getCloudServiceClientSync } from '@/app/infra/http';
@@ -21,223 +21,331 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { Loader2, Puzzle } from 'lucide-react';
import { Wrench, AudioWaveform, Book } from 'lucide-react';
export interface PluginInstalledComponentRef {
refreshPluginList: () => void;
}
enum PluginOperationType {
enum ExtensionOperationType {
DELETE = 'DELETE',
UPDATE = 'UPDATE',
}
const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
(props, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { refreshPlugins } = useSidebarData();
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<PluginOperationType>(
PluginOperationType.DELETE,
);
const [targetPlugin, setTargetPlugin] = useState<PluginCardVO | null>(null);
const [deleteData, setDeleteData] = useState<boolean>(false);
export type FilterType = 'all' | ExtensionType;
const asyncTask = useAsyncTask({
onSuccess: () => {
const successMessage =
operationType === PluginOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(successMessage);
setShowOperationModal(false);
getPluginList();
refreshPlugins();
},
onError: () => {
// Error is already handled in the hook state
},
});
export const FilterOptions = [
{
value: 'all' as FilterType,
labelKey: 'market.filters.allFormats',
icon: null,
},
{
value: 'plugin' as FilterType,
labelKey: 'market.typePlugin',
icon: Wrench,
},
{
value: 'mcp' as FilterType,
labelKey: 'market.typeMCP',
icon: AudioWaveform,
},
{ value: 'skill' as FilterType, labelKey: 'market.typeSkill', icon: Book },
];
useEffect(() => {
initData();
}, []);
interface PluginInstalledComponentProps {
filterType: FilterType;
groupByType: boolean;
}
function initData() {
getPluginList();
}
const PluginInstalledComponent = forwardRef<
PluginInstalledComponentRef,
PluginInstalledComponentProps
>(({ filterType, groupByType }, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const [extensionList, setExtensionList] = useState<ExtensionCardVO[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<ExtensionOperationType>(
ExtensionOperationType.DELETE,
);
const [targetExtension, setTargetExtension] =
useState<ExtensionCardVO | null>(null);
const [deleteData, setDeleteData] = useState<boolean>(false);
async function getPluginList() {
try {
// 获取已安装插件列表
const installedPluginsResp = await httpClient.getPlugins();
const installedPlugins = installedPluginsResp.plugins;
const asyncTask = useAsyncTask({
onSuccess: () => {
const successMessage =
operationType === ExtensionOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(successMessage);
setShowOperationModal(false);
getExtensionList();
refreshPlugins();
refreshMCPServers();
refreshSkills();
},
onError: () => {},
});
// 获取市场插件列表
const client = getCloudServiceClientSync();
const marketplaceResp = await client.getMarketplacePlugins(1, 100);
const marketplacePlugins = marketplaceResp.plugins;
useEffect(() => {
initData();
}, []);
// 创建市场插件映射,便于快速查找
const marketplacePluginMap = new Map();
marketplacePlugins.forEach((plugin) => {
const key = `${plugin.author}/${plugin.name}`;
marketplacePluginMap.set(key, plugin);
});
function initData() {
getExtensionList();
}
// 转换并比较版本号
const pluginCards = installedPlugins.map((plugin) => {
const cardVO = new PluginCardVO({
author: plugin.manifest.manifest.metadata.author ?? '',
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
description: extractI18nObject(
plugin.manifest.manifest.metadata.description ?? {
en_US: '',
zh_Hans: '',
},
),
debug: plugin.debug,
enabled: plugin.enabled,
name: plugin.manifest.manifest.metadata.name,
version: plugin.manifest.manifest.metadata.version ?? '',
status: plugin.status,
components: plugin.components,
priority: plugin.priority,
install_source: plugin.install_source,
install_info: plugin.install_info,
});
async function getExtensionList() {
setLoading(true);
try {
const client = getCloudServiceClientSync();
// 检查是否来自市场且有更新
if (cardVO.install_source === 'marketplace') {
const marketplaceKey = `${cardVO.author}/${cardVO.name}`;
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
if (marketplacePlugin && marketplacePlugin.latest_version) {
cardVO.hasUpdate = isNewerVersion(
const [extensionsResp, marketplaceResp] = await Promise.all([
httpClient.getExtensions().catch(() => ({ extensions: [] })),
client.getMarketplacePlugins(1, 100).catch(() => ({ plugins: [] })),
]);
const marketplacePluginMap = new Map<string, any>();
marketplaceResp.plugins.forEach((plugin: any) => {
const key = `${plugin.author}/${plugin.name}`;
marketplacePluginMap.set(key, plugin);
});
const extensions: ExtensionCardVO[] = [];
for (const item of extensionsResp.extensions) {
if (item.type === 'plugin') {
const plugin = item.plugin;
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const marketplaceKey = `${author}/${name}`;
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
let hasUpdate = false;
if (plugin.install_source === 'marketplace' && marketplacePlugin) {
if (marketplacePlugin.latest_version) {
hasUpdate = isNewerVersion(
marketplacePlugin.latest_version,
cardVO.version,
meta.version ?? '',
);
}
}
return cardVO;
});
setPluginList(pluginCards);
} catch (error) {
console.error('获取插件列表失败:', error);
// 失败时仍显示已安装插件,不影响用户体验
const installedPluginsResp = await httpClient.getPlugins();
setPluginList(
installedPluginsResp.plugins.map((plugin) => {
return new PluginCardVO({
author: plugin.manifest.manifest.metadata.author ?? '',
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
extensions.push(
new ExtensionCardVO({
id: marketplaceKey,
author,
label: extractI18nObject(meta.label) || name,
name,
description: extractI18nObject(
plugin.manifest.manifest.metadata.description ?? {
en_US: '',
zh_Hans: '',
},
meta.description ?? { en_US: '', zh_Hans: '' },
),
debug: plugin.debug,
version: meta.version ?? '',
enabled: plugin.enabled,
name: plugin.manifest.manifest.metadata.name,
version: plugin.manifest.manifest.metadata.version ?? '',
status: plugin.status,
components: plugin.components,
priority: plugin.priority,
type: marketplacePlugin?.type || 'plugin',
iconURL: httpClient.getPluginIconURL(author, name),
install_source: plugin.install_source,
install_info: plugin.install_info,
});
}),
);
status: plugin.status,
debug: plugin.debug,
hasUpdate,
}),
);
} else if (item.type === 'mcp') {
const server = item.server;
extensions.push(
new ExtensionCardVO({
id: server.name,
author: '',
label: server.name.replace(/__/g, '/'),
name: server.name,
description: '',
version: '',
enabled: server.enable,
type: 'mcp',
iconURL: httpClient.getPluginIconURL('mcp', server.name),
status: server.runtime_info?.status,
runtimeStatus: server.runtime_info?.status,
tools: server.runtime_info?.tool_count || 0,
mode: server.mode,
}),
);
} else if (item.type === 'skill') {
const skill = item.skill;
extensions.push(
new ExtensionCardVO({
id: skill.name,
author: '',
label: skill.display_name || skill.name,
name: skill.name,
description: skill.description || '',
version: '',
enabled: true,
type: 'skill',
iconURL: httpClient.getPluginIconURL('skill', skill.name),
}),
);
}
}
setExtensionList(extensions);
} catch (error) {
console.error('Failed to fetch extension list:', error);
setExtensionList([]);
} finally {
setLoading(false);
}
}
useImperativeHandle(ref, () => ({
refreshPluginList: getPluginList,
}));
useImperativeHandle(ref, () => ({
refreshPluginList: getExtensionList,
}));
function handlePluginClick(plugin: PluginCardVO) {
const pluginId = `${plugin.author}/${plugin.name}`;
navigate(`/home/plugins?id=${encodeURIComponent(pluginId)}`);
function handleExtensionClick(extension: ExtensionCardVO) {
if (extension.type === 'mcp') {
navigate(`/home/mcp?id=${encodeURIComponent(extension.id)}`);
} else if (extension.type === 'skill') {
navigate(`/home/skills?id=${encodeURIComponent(extension.id)}`);
} else {
const extensionId = `${extension.author}/${extension.name}`;
navigate(`/home/extensions?id=${encodeURIComponent(extensionId)}`);
}
}
function handlePluginDelete(plugin: PluginCardVO) {
setTargetPlugin(plugin);
setOperationType(PluginOperationType.DELETE);
setShowOperationModal(true);
setDeleteData(false);
asyncTask.reset();
}
function handleExtensionDelete(extension: ExtensionCardVO) {
setTargetExtension(extension);
setOperationType(ExtensionOperationType.DELETE);
setShowOperationModal(true);
setDeleteData(false);
asyncTask.reset();
}
function handlePluginUpdate(plugin: PluginCardVO) {
setTargetPlugin(plugin);
setOperationType(PluginOperationType.UPDATE);
setShowOperationModal(true);
asyncTask.reset();
}
function handleExtensionUpdate(extension: ExtensionCardVO) {
setTargetExtension(extension);
setOperationType(ExtensionOperationType.UPDATE);
setShowOperationModal(true);
asyncTask.reset();
}
function executeOperation() {
if (!targetPlugin) return;
function executeOperation() {
if (!targetExtension) return;
const apiCall =
operationType === PluginOperationType.DELETE
? httpClient.removePlugin(
targetPlugin.author,
targetPlugin.name,
deleteData,
)
: httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name);
apiCall
.then((res) => {
asyncTask.startTask(res.task_id);
if (targetExtension.type === 'mcp') {
httpClient
.deleteMCPServer(targetExtension.name)
.then(() => {
toast.success(t('mcp.deleteSuccess'));
setShowOperationModal(false);
getExtensionList();
refreshMCPServers();
})
.catch((error) => {
const errorMessage =
operationType === PluginOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
toast.error(t('mcp.deleteError') + error.message);
});
return;
}
return (
<>
<Dialog
open={showOperationModal}
onOpenChange={(open) => {
if (!open) {
setShowOperationModal(false);
setTargetPlugin(null);
asyncTask.reset();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{operationType === PluginOperationType.DELETE
? t('plugins.deleteConfirm')
: t('plugins.updateConfirm')}
</DialogTitle>
</DialogHeader>
<DialogDescription>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div className="flex flex-col gap-4">
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.confirmDeletePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})
: t('plugins.confirmUpdatePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})}
</div>
{operationType === PluginOperationType.DELETE && (
if (targetExtension.type === 'skill') {
httpClient
.deleteSkill(targetExtension.name)
.then(() => {
toast.success(t('skills.deleteSuccess'));
setShowOperationModal(false);
getExtensionList();
refreshSkills();
})
.catch((error) => {
toast.error(t('skills.deleteError') + error.message);
});
return;
}
const apiCall =
operationType === ExtensionOperationType.DELETE
? httpClient.removePlugin(
targetExtension.author,
targetExtension.name,
deleteData,
)
: httpClient.upgradePlugin(
targetExtension.author,
targetExtension.name,
);
apiCall
.then((res) => {
asyncTask.startTask(res.task_id);
})
.catch((error) => {
const errorMessage =
operationType === ExtensionOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
});
}
const filteredExtensions = extensionList.filter((ext) => {
if (filterType === 'all') return true;
return ext.type === filterType;
});
const showGrouped = groupByType && filterType === 'all';
const groupOrder: ExtensionType[] = ['plugin', 'mcp', 'skill'];
const groupedExtensions = groupOrder
.map((type) => ({
type,
labelKey: FilterOptions.find((o) => o.value === type)!.labelKey,
items: filteredExtensions.filter((ext) => ext.type === type),
}))
.filter((g) => g.items.length > 0);
const getDeleteConfirmMessage = () => {
if (!targetExtension) return '';
if (targetExtension.type === 'mcp') {
return t('mcp.confirmDeleteServer');
}
if (targetExtension.type === 'skill') {
return t('skills.deleteConfirmation');
}
return t('plugins.confirmDeletePlugin', {
author: targetExtension.author,
name: targetExtension.name,
});
};
return (
<>
<Dialog
open={showOperationModal}
onOpenChange={(open) => {
if (!open) {
setShowOperationModal(false);
setTargetExtension(null);
asyncTask.reset();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleteConfirm')
: t('plugins.updateConfirm')}
</DialogTitle>
</DialogHeader>
<DialogDescription>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div className="flex flex-col gap-4">
<div>{getDeleteConfirmMessage()}</div>
{operationType === ExtensionOperationType.DELETE &&
targetExtension?.type === 'plugin' && (
<div className="flex items-center space-x-2">
<Checkbox
id="delete-data"
@@ -254,113 +362,147 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</label>
</div>
)}
</div>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.deleteError')
: t('plugins.updateError')}
<div className="text-red-500">{asyncTask.error}</div>
</div>
)}
</DialogDescription>
<DialogFooter>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant="outline"
onClick={() => {
setShowOperationModal(false);
setTargetPlugin(null);
asyncTask.reset();
}}
>
{t('plugins.cancel')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant={
operationType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
onClick={() => {
executeOperation();
}}
>
{operationType === PluginOperationType.DELETE
? t('plugins.confirmDelete')
: t('plugins.confirmUpdate')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<Button
variant={
operationType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
disabled
>
{operationType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<Button
variant="default"
onClick={() => {
setShowOperationModal(false);
asyncTask.reset();
}}
>
{t('plugins.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<div>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleteError')
: t('plugins.updateError')}
<div className="text-red-500">{asyncTask.error}</div>
</div>
)}
</DialogDescription>
<DialogFooter>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant="outline"
onClick={() => {
setShowOperationModal(false);
setTargetExtension(null);
asyncTask.reset();
}}
>
{t('plugins.cancel')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant={
operationType === ExtensionOperationType.DELETE
? 'destructive'
: 'default'
}
onClick={() => {
executeOperation();
}}
>
{operationType === ExtensionOperationType.DELETE
? t('plugins.confirmDelete')
: t('plugins.confirmUpdate')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<Button
variant={
operationType === ExtensionOperationType.DELETE
? 'destructive'
: 'default'
}
disabled
>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<Button
variant="default"
onClick={() => {
setShowOperationModal(false);
asyncTask.reset();
}}
>
{t('plugins.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{pluginList.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H20C20.5523 5 21 5.44772 21 6V10.1707C21 10.4953 20.8424 10.7997 20.5774 10.9872C20.3123 11.1746 19.9728 11.2217 19.6668 11.1135C19.4595 11.0403 19.2355 11 19 11C17.8954 11 17 11.8954 17 13C17 14.1046 17.8954 15 19 15C19.2355 15 19.4595 14.9597 19.6668 14.8865C19.9728 14.7783 20.3123 14.8254 20.5774 15.0128C20.8424 15.2003 21 15.5047 21 15.8293V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H19V17C16.7909 17 15 15.2091 15 13C15 10.7909 16.7909 9 19 9V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
</svg>
<div className="text-lg mb-2">{t('plugins.noPluginInstalled')}</div>
{loading ? (
<div className="flex flex-col items-center justify-center text-muted-foreground min-h-[60vh] w-full gap-2">
<Loader2 className="h-[3rem] w-[3rem] animate-spin" />
<div className="text-lg mb-2">{t('plugins.loadingExtensions')}</div>
</div>
) : filteredExtensions.length === 0 ? (
<div className="flex flex-col items-center justify-center text-muted-foreground min-h-[60vh] w-full gap-2">
<Puzzle className="h-[3rem] w-[3rem]" />
<div className="text-lg mb-2">
{t('plugins.noExtensionInstalled')}
</div>
) : (
<div className={`${styles.pluginListContainer}`}>
{pluginList.map((vo, index) => {
return (
<div key={index}>
<PluginCardComponent
cardVO={vo}
onCardClick={() => handlePluginClick(vo)}
onDeleteClick={() => handlePluginDelete(vo)}
onUpgradeClick={() => handlePluginUpdate(vo)}
/>
</div>
);
})}
</div>
)}
</>
);
},
);
</div>
) : showGrouped ? (
<div className="flex flex-col gap-4 pb-4">
{groupedExtensions.map((group) => (
<div key={group.type} className="flex flex-col">
<div className="px-[0.8rem] flex items-center gap-2 mb-2">
<h3 className="text-sm font-semibold text-foreground">
{t(group.labelKey)}
</h3>
<span className="text-xs text-muted-foreground">
({group.items.length})
</span>
</div>
<div className="px-[0.8rem] grid gap-5 sm:gap-8 [grid-template-columns:repeat(auto-fill,minmax(min(100%,22rem),1fr))] sm:[grid-template-columns:repeat(auto-fill,minmax(min(100%,28rem),1fr))] items-start">
{group.items.map((vo, index) => (
<div key={vo.id || index}>
<ExtensionCardComponent
cardVO={vo}
onCardClick={() => handleExtensionClick(vo)}
onDeleteClick={() => handleExtensionDelete(vo)}
onUpgradeClick={
vo.type === 'plugin'
? () => handleExtensionUpdate(vo)
: undefined
}
/>
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className={`${styles.pluginListContainer}`}>
{filteredExtensions.map((vo, index) => {
return (
<div key={vo.id || index}>
<ExtensionCardComponent
cardVO={vo}
onCardClick={() => handleExtensionClick(vo)}
onDeleteClick={() => handleExtensionDelete(vo)}
onUpgradeClick={
vo.type === 'plugin'
? () => handleExtensionUpdate(vo)
: undefined
}
/>
</div>
);
})}
</div>
)}
</>
);
});
export default PluginInstalledComponent;

View File

@@ -60,6 +60,24 @@ export default function PluginCardComponent({
>
v{cardVO.version}
</Badge>
{cardVO.type && (
<Badge
variant="outline"
className={`text-[0.7rem] flex-shrink-0 ${
cardVO.type === 'mcp'
? 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300'
: cardVO.type === 'skill'
? 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300'
: 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300'
}`}
>
{cardVO.type === 'mcp'
? 'MCP'
: cardVO.type === 'skill'
? t('common.skill')
: t('market.typePlugin')}
</Badge>
)}
{cardVO.debug && (
<Badge
variant="outline"

View File

@@ -4,10 +4,16 @@ import { Plugin } from '@/app/infra/entities/plugin';
import { httpClient } from '@/app/infra/http/HttpClient';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
export default function PluginForm({
pluginAuthor,
@@ -36,7 +42,6 @@ export default function PluginForm({
setPluginConfig(res);
// 提取初始配置中的所有文件 key
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const extractFileKeys = (obj: any): string[] => {
const keys: string[] = [];
if (obj && typeof obj === 'object') {
@@ -71,7 +76,6 @@ export default function PluginForm({
);
// 提取最终保存的配置中的所有文件 key
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const extractFileKeys = (obj: any): string[] => {
const keys: string[] = [];
if (obj && typeof obj === 'object') {
@@ -137,65 +141,34 @@ export default function PluginForm({
}
return (
<div>
<div className="space-y-2">
<div className="text-lg font-medium">
{extractI18nObject(pluginInfo.manifest.manifest.metadata.label)}
</div>
<div className="text-sm text-gray-500 pb-2">
{extractI18nObject(
pluginInfo.manifest.manifest.metadata.description ?? {
en_US: '',
zh_Hans: '',
},
<div className="min-w-0 max-w-full space-y-4">
<Card className="min-w-0 overflow-x-hidden">
<CardHeader>
<CardTitle>{t('plugins.pluginConfig')}</CardTitle>
<CardDescription>{t('plugins.saveConfig')}</CardDescription>
</CardHeader>
<CardContent className="min-w-0 overflow-x-hidden">
{pluginInfo.manifest.manifest.spec.config.length > 0 ? (
<DynamicFormComponent
itemConfigList={pluginInfo.manifest.manifest.spec.config}
initialValues={pluginConfig.config as Record<string, object>}
onSubmit={(values) => {
// 只保存表单值的引用,不触发状态更新
currentFormValues.current = values;
}}
onFileUploaded={(fileKey) => {
// 追踪上传的文件
uploadedFileKeys.current.add(fileKey);
}}
/>
) : (
<div className="text-sm text-muted-foreground">
{t('plugins.pluginNoConfig')}
</div>
)}
</div>
<div className="mb-4 flex flex-row items-center justify-start gap-[0.4rem]">
<PluginComponentList
components={(() => {
const componentKindCount: Record<string, number> = {};
for (const component of pluginInfo.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
</CardContent>
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
<DynamicFormComponent
itemConfigList={pluginInfo.manifest.manifest.spec.config}
initialValues={pluginConfig.config as Record<string, object>}
onSubmit={(values) => {
// 只保存表单值的引用,不触发状态更新
currentFormValues.current = values;
}}
onFileUploaded={(fileKey) => {
// 追踪上传的文件
uploadedFileKeys.current.add(fileKey);
}}
/>
)}
{pluginInfo.manifest.manifest.spec.config.length === 0 && (
<div className="text-sm text-gray-500">
{t('plugins.pluginNoConfig')}
</div>
)}
</div>
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
<div className="flex justify-end gap-2">
<CardFooter className="justify-end">
<Button
type="submit"
onClick={() => handleSubmit()}
@@ -203,9 +176,9 @@ export default function PluginForm({
>
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
</Button>
</div>
</div>
)}
</CardFooter>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { Fragment } from 'react';
import { TFunction } from 'i18next';
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
export default function PluginComponentList({
components,
showComponentName,
showTitle,
useBadge,
t,
responsive = false,
}: {
components: Record<string, number>;
showComponentName: boolean;
showTitle: boolean;
useBadge: boolean;
t: TFunction;
responsive?: boolean;
}) {
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
KnowledgeEngine: <Book className="w-5 h-5" />,
Parser: <FileText className="w-5 h-5" />,
};
const componentKindList = Object.keys(components || {});
return (
<>
{showTitle && <div>{t('market.componentsList')}</div>}
{componentKindList.length > 0 && (
<>
{componentKindList.map((kind) => {
return (
<Fragment key={kind}>
{useBadge && (
<Badge variant="outline" className="flex items-center gap-1">
{kindIconMap[kind]}
{responsive ? (
<span className="hidden md:inline">
{t('market.componentName.' + kind)}
</span>
) : (
showComponentName && t('market.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
</Badge>
)}
{!useBadge && (
<div className="flex flex-row items-center justify-start gap-[0.2rem]">
{kindIconMap[kind]}
{responsive ? (
<span className="hidden md:inline">
{t('market.componentName.' + kind)}
</span>
) : (
showComponentName && t('market.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
</div>
)}
</Fragment>
);
})}
</>
)}
{componentKindList.length === 0 && <div>{t('market.noComponents')}</div>}
</>
);
}

View File

@@ -8,6 +8,12 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Search,
@@ -16,22 +22,33 @@ import {
Hash,
Book,
FileText,
PanelTop,
AppWindow,
SlidersHorizontal,
X,
Info,
} from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { RecommendationLists } from './RecommendationLists';
import type { RecommendationList } from './RecommendationLists';
import {
getCloudServiceClient,
getCloudServiceClientSync,
} from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { PluginV4, PluginV4Status } from '@/app/infra/entities/plugin';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { TagsFilter } from './TagsFilter';
import { Button } from '@/components/ui/button';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
import { RecommendationLists, RecommendationList } from './RecommendationLists';
interface SortOption {
value: string;
label: string;
@@ -39,45 +56,91 @@ interface SortOption {
sortOrder: string;
}
// Persist the market filter conditions (type / component / tags / sort) across
// visits via localStorage.
const MARKET_FILTERS_KEY = 'langbot_market_filters';
interface MarketFilters {
typeFilter?: string;
componentFilter?: string;
selectedTags?: string[];
sortOption?: string;
}
function loadMarketFilters(): MarketFilters {
try {
return JSON.parse(localStorage.getItem(MARKET_FILTERS_KEY) || '{}');
} catch {
return {};
}
}
// 内部组件,用于处理搜索参数
function MarketPageContent({
installPlugin,
headerActions,
}: {
installPlugin: (plugin: PluginV4) => void;
headerActions?: React.ReactNode;
}) {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const validCategories = [
'Tool',
'Command',
'EventListener',
'KnowledgeEngine',
'Parser',
'Page',
const validTypes = ['plugin', 'mcp', 'skill'];
const extensionTypeOptions = [
{ value: 'all', label: t('market.filters.allFormats'), icon: null },
{ value: 'plugin', label: t('market.typePlugin'), icon: Wrench },
{ value: 'mcp', label: t('market.typeMCP'), icon: AudioWaveform },
{ value: 'skill', label: t('market.typeSkill'), icon: Book },
];
const [searchQuery, setSearchQuery] = useState('');
const [componentFilter, setComponentFilter] = useState<string>(() => {
const category = searchParams.get('category');
if (category && validCategories.includes(category)) {
return category;
const [componentFilter, setComponentFilter] = useState<string>(
() => loadMarketFilters().componentFilter ?? 'all',
);
const [typeFilter, setTypeFilter] = useState<string>(() => {
const type = searchParams.get('type');
if (type && validTypes.includes(type)) {
return type;
}
return 'all';
const saved = loadMarketFilters().typeFilter;
return saved && validTypes.includes(saved) ? saved : 'all';
});
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const activeAdvancedFilters =
(typeFilter === 'all' ? 0 : 1) + (componentFilter === 'all' ? 0 : 1);
const [selectedTags, setSelectedTags] = useState<string[]>(
() => loadMarketFilters().selectedTags ?? [],
);
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
const [tagNames, setTagNames] = useState<Record<string, string>>({});
const [recommendationLists, setRecommendationLists] = useState<
RecommendationList[]
>([]);
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const [sortOption, setSortOption] = useState('install_count_desc');
const [recommendationLists, setRecommendationLists] = useState<
RecommendationList[]
>([]);
const [sortOption, setSortOption] = useState<string>(
() => loadMarketFilters().sortOption ?? 'install_count_desc',
);
// Persist filter conditions so they survive navigation / reload.
useEffect(() => {
try {
localStorage.setItem(
MARKET_FILTERS_KEY,
JSON.stringify({
typeFilter,
componentFilter,
selectedTags,
sortOption,
}),
);
} catch {
// ignore storage errors
}
}, [typeFilter, componentFilter, selectedTags, sortOption]);
const pageSize = 12; // 每页12个
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -112,6 +175,32 @@ function MarketPageContent({
},
];
const componentOptions = [
{ value: 'all', label: t('market.allComponents'), icon: null },
{ value: 'Tool', label: t('market.componentName.Tool'), icon: Wrench },
{ value: 'Command', label: t('market.componentName.Command'), icon: Hash },
{
value: 'EventListener',
label: t('market.componentName.EventListener'),
icon: AudioWaveform,
},
{
value: 'KnowledgeEngine',
label: t('market.componentName.KnowledgeEngine'),
icon: Book,
},
{
value: 'Parser',
label: t('market.componentName.Parser'),
icon: FileText,
},
{
value: 'Page',
label: t('market.componentName.Page'),
icon: AppWindow,
},
];
// 获取当前排序参数
const getCurrentSort = useCallback(() => {
const option = sortOptions.find((opt) => opt.value === sortOption);
@@ -121,29 +210,43 @@ function MarketPageContent({
}, [sortOption]);
// 将API响应转换为VO对象
const transformToVO = useCallback((plugin: PluginV4): PluginMarketCardVO => {
return new PluginMarketCardVO({
pluginId: plugin.author + ' / ' + plugin.name,
author: plugin.author,
pluginName: plugin.name,
label: extractI18nObject(plugin.label),
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count,
iconURL: getCloudServiceClientSync().getPluginIconURL(
plugin.author,
plugin.name,
),
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
});
}, []);
const transformToVO = useCallback(
(plugin: PluginV4): PluginMarketCardVO => {
const cloudClient = getCloudServiceClientSync();
const iconURL =
plugin.type === 'mcp'
? cloudClient.getMCPMarketplaceIconURL(plugin.author, plugin.name)
: plugin.type === 'skill'
? cloudClient.getSkillMarketplaceIconURL(plugin.author, plugin.name)
: cloudClient.getPluginIconURL(plugin.author, plugin.name);
return new PluginMarketCardVO({
pluginId: plugin.author + ' / ' + plugin.name,
author: plugin.author,
pluginName: plugin.name,
label: extractI18nObject(plugin.label),
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count || 0,
iconURL,
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components || {},
tags: plugin.tags || [],
type: plugin.type,
});
},
[t],
);
// 获取插件列表
const fetchPlugins = useCallback(
async (page: number, isSearch: boolean = false, reset: boolean = false) => {
async (
page: number,
isSearch: boolean = false,
reset: boolean = false,
queryOverride?: string,
) => {
if (page === 1) {
setIsLoading(true);
} else {
@@ -152,31 +255,23 @@ function MarketPageContent({
try {
const { sortBy, sortOrder } = getCurrentSort();
const filterValue =
componentFilter === 'all' ? undefined : componentFilter;
const query = (queryOverride ?? searchQuery).trim();
// Always use searchMarketplacePlugins to support component filtering and tags filtering
const response =
await getCloudServiceClientSync().searchMarketplacePlugins(
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
await getCloudServiceClientSync().searchMarketplaceExtensions({
query: isSearch ? query : '',
page,
pageSize,
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
);
page_size: pageSize,
sort_by: sortBy,
sort_order: sortOrder,
type_filter: typeFilter === 'all' ? undefined : typeFilter,
component_filter:
componentFilter === 'all' ? undefined : componentFilter,
tags_filter: selectedTags.length > 0 ? selectedTags : undefined,
});
const data: ApiRespMarketplacePlugins = response;
const newPlugins = data.plugins
.filter((plugin) => {
// Hide plugins that only contain deprecated KnowledgeRetriever components
const keys = Object.keys(plugin.components || {});
return !(
keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')
);
})
.map(transformToVO);
const newPlugins = data.plugins.map(transformToVO);
const total = data.total;
if (reset || page === 1) {
@@ -187,8 +282,10 @@ function MarketPageContent({
setTotal(total);
setHasMore(
data.plugins.length === pageSize &&
plugins.length + newPlugins.length < total,
newPlugins.length > 0 &&
(reset || page === 1
? newPlugins.length
: plugins.length + newPlugins.length) < total,
);
} catch (error) {
console.error('Failed to fetch plugins:', error);
@@ -206,16 +303,35 @@ function MarketPageContent({
transformToVO,
plugins.length,
getCurrentSort,
typeFilter,
],
);
// 初始加载
useEffect(() => {
fetchPlugins(1, false, true);
fetchAvailableTags();
// Resolve the cloud service base URL (from system info) before any
// marketplace fetch — otherwise the sync client may still hold the default
// URL and hit space.langbot.app instead of the configured instance.
(async () => {
await getCloudServiceClient();
fetchPlugins(1, false, true);
fetchAvailableTags();
fetchRecommendationLists();
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 获取推荐列表(精选,混合插件/MCP/Skill
const fetchRecommendationLists = async () => {
try {
const client = await getCloudServiceClient();
const { lists } = await client.getRecommendationLists();
setRecommendationLists(lists || []);
} catch (error) {
console.error('Failed to fetch recommendation lists:', error);
}
};
// 获取可用标签
const fetchAvailableTags = async () => {
try {
@@ -240,27 +356,13 @@ function MarketPageContent({
}
};
// Fetch recommendation lists
useEffect(() => {
async function fetchRecommendationLists() {
try {
const response =
await getCloudServiceClientSync().getRecommendationLists();
setRecommendationLists(response.lists || []);
} catch (error) {
console.error('Failed to fetch recommendation lists:', error);
}
}
fetchRecommendationLists();
}, []);
// 搜索功能
const handleSearch = useCallback(
(query: string) => {
setSearchQuery(query);
setCurrentPage(1);
setPlugins([]);
fetchPlugins(1, !!query.trim(), true);
fetchPlugins(1, !!query.trim(), true, query);
},
[fetchPlugins],
);
@@ -292,33 +394,52 @@ function MarketPageContent({
setSortOption(value);
setCurrentPage(1);
setPlugins([]);
// fetchPlugins will be called by useEffect when sortOption changes
}, []);
// 组件筛选变化处理
const handleComponentFilterChange = useCallback((value: string) => {
setComponentFilter(value);
// Handle type filter change
const handleTypeFilterChange = useCallback((value: string) => {
setTypeFilter(value);
if (value !== 'plugin') {
setComponentFilter('all');
}
setCurrentPage(1);
setSelectedTags([]);
setPlugins([]);
// Update URL query param to keep it in sync
const params = new URLSearchParams(window.location.search);
if (value === 'all') {
params.delete('category');
params.delete('type');
} else {
params.set('category', value);
params.set('type', value);
}
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
// fetchPlugins will be called by useEffect when componentFilter changes
}, []);
// 当排序选项或组件筛选变化时重新加载数据
const handleComponentFilterChange = useCallback((value: string) => {
setComponentFilter(value);
setCurrentPage(1);
setPlugins([]);
if (value !== 'all') {
setTypeFilter('plugin');
const params = new URLSearchParams(window.location.search);
params.set('type', 'plugin');
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
}, []);
// 当排序选项或组件筛选或类型筛选变化时重新加载数据
useEffect(() => {
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption, componentFilter]);
}, [sortOption, componentFilter, typeFilter]);
// Tags 筛选变化时重新搜索
useEffect(() => {
@@ -336,13 +457,56 @@ function MarketPageContent({
// 处理安装插件
const handleInstallPlugin = useCallback(
async (author: string, pluginName: string) => {
async (cardVO: PluginMarketCardVO) => {
try {
// Fetch full plugin details to get PluginV4 object
if (cardVO.type === 'mcp' || cardVO.type === 'skill') {
// For MCP and Skill, directly pass the data - backend will fetch from Space
const pluginV4: PluginV4 = {
id: 0,
plugin_id: `${cardVO.author}/${cardVO.pluginName}`,
mcp_id:
cardVO.type === 'mcp'
? `${cardVO.author}/${cardVO.pluginName}`
: undefined,
skill_id:
cardVO.type === 'skill'
? `${cardVO.author}/${cardVO.pluginName}`
: undefined,
author: cardVO.author,
name: cardVO.pluginName,
label: { en_US: cardVO.label, zh_Hans: cardVO.label },
description: {
en_US: cardVO.description,
zh_Hans: cardVO.description,
},
icon: cardVO.iconURL,
repository: cardVO.githubURL,
tags: cardVO.tags || [],
install_count: cardVO.installCount,
latest_version: cardVO.version,
components: cardVO.components || {},
status: PluginV4Status.Live,
type: cardVO.type,
created_at: '',
updated_at: '',
};
installPlugin(pluginV4);
return;
}
// For plugin type, fetch full details via API
const response = await getCloudServiceClientSync().getPluginDetail(
author,
pluginName,
cardVO.author,
cardVO.pluginName,
);
if (!response?.plugin) {
console.error('Failed to install plugin: plugin not found', {
author: cardVO.author,
pluginName: cardVO.pluginName,
});
toast.error(t('market.installFailed'));
return;
}
const pluginV4: PluginV4 = response.plugin;
// Call the install function passed from parent
@@ -352,7 +516,7 @@ function MarketPageContent({
toast.error(t('market.installFailed'));
}
},
[plugins, installPlugin, t],
[installPlugin, t],
);
// 清理定时器
@@ -429,11 +593,11 @@ function MarketPageContent({
return (
<div className="h-full flex flex-col">
{/* Fixed header with search and sort controls */}
<div className="flex-shrink-0 space-y-4 px-3 sm:px-4 py-4 sm:py-6">
{/* Search box and Tags filter */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<div className="relative w-full max-w-2xl">
{/* Fixed header section with search, sort, and status */}
<div className="flex-none px-3 sm:px-4 py-2 sm:py-4 space-y-4 sm:space-y-6 container mx-auto">
{/* 搜索、排序和筛选入口 */}
<div className="flex w-full items-center justify-center gap-2 sm:gap-3">
<div className="relative min-w-0 flex-1 lg:max-w-xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder={t('market.searchPlaceholder')}
@@ -448,109 +612,19 @@ function MarketPageContent({
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
// Immediately search, clear debounce timer
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
handleSearch(searchQuery);
}
}}
className="pl-10 pr-4 text-sm sm:text-base"
className="min-w-0 pl-10 pr-4 text-sm sm:text-base"
/>
</div>
{/* Tags filter */}
<TagsFilter
availableTags={availableTags}
selectedTags={selectedTags}
onTagsChange={handleTagsChange}
/>
</div>
{/* Component filter and sort */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4">
{/* Component filter */}
<div className="flex flex-col sm:flex-row items-center gap-2 min-w-0 max-w-full">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.filterByComponent')}:
</span>
<div className="overflow-x-auto max-w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={componentFilter}
onValueChange={(value) => {
if (value) handleComponentFilterChange(value);
}}
className="justify-start flex-nowrap"
>
<ToggleGroupItem
value="all"
aria-label="All components"
className="text-xs sm:text-sm cursor-pointer"
>
{t('market.allComponents')}
</ToggleGroupItem>
<ToggleGroupItem
value="Tool"
aria-label="Tool"
className="text-xs sm:text-sm cursor-pointer"
>
<Wrench className="h-4 w-4 mr-1" />
{t('plugins.componentName.Tool')}
</ToggleGroupItem>
<ToggleGroupItem
value="Command"
aria-label="Command"
className="text-xs sm:text-sm cursor-pointer"
>
<Hash className="h-4 w-4 mr-1" />
{t('plugins.componentName.Command')}
</ToggleGroupItem>
<ToggleGroupItem
value="EventListener"
aria-label="EventListener"
className="text-xs sm:text-sm cursor-pointer"
>
<AudioWaveform className="h-4 w-4 mr-1" />
{t('plugins.componentName.EventListener')}
</ToggleGroupItem>
<ToggleGroupItem
value="KnowledgeEngine"
aria-label="KnowledgeEngine"
className="text-xs sm:text-sm cursor-pointer"
>
<Book className="h-4 w-4 mr-1" />
{t('plugins.componentName.KnowledgeEngine')}
</ToggleGroupItem>
<ToggleGroupItem
value="Parser"
aria-label="Parser"
className="text-xs sm:text-sm cursor-pointer"
>
<FileText className="h-4 w-4 mr-1" />
{t('plugins.componentName.Parser')}
</ToggleGroupItem>
<ToggleGroupItem
value="Page"
aria-label="Page"
className="text-xs sm:text-sm cursor-pointer"
>
<PanelTop className="h-4 w-4 mr-1" />
{t('plugins.componentName.Page')}
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
{/* Sort dropdown */}
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.sortBy')}:
</span>
<div className="flex shrink-0 items-center gap-2">
<Select value={sortOption} onValueChange={handleSortChange}>
<SelectTrigger className="w-40 sm:w-48 text-xs sm:text-sm">
<SelectTrigger className="w-28 shrink-0 text-xs sm:w-40 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -561,10 +635,151 @@ function MarketPageContent({
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="relative shrink-0 px-3 sm:px-4"
>
<SlidersHorizontal className="h-4 w-4" />
<span className="hidden sm:inline">
{t('market.filters.more')}
</span>
{activeAdvancedFilters > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none text-primary-foreground">
{activeAdvancedFilters}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[320px] space-y-4">
<div>
<div className="text-sm font-medium">
{t('market.filters.advancedTitle')}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t('market.filters.advancedDescription')}
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{t('market.filters.technicalType')}
</div>
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={typeFilter}
onValueChange={(value) => {
if (value) handleTypeFilterChange(value);
}}
className="flex flex-wrap justify-start gap-2"
>
{extensionTypeOptions.map((option) => {
const Icon = option.icon;
return (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
className="cursor-pointer text-xs"
>
{Icon && <Icon className="mr-1 h-3.5 w-3.5" />}
{option.label}
</ToggleGroupItem>
);
})}
</ToggleGroup>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
{t('market.filterByComponent')}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex text-muted-foreground/70 hover:text-foreground"
aria-label={t('market.filterByComponentHint')}
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
{t('market.filterByComponentHint')}
</TooltipContent>
</Tooltip>
</div>
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={componentFilter}
onValueChange={(value) => {
if (value) handleComponentFilterChange(value);
}}
className="flex flex-wrap justify-start gap-2"
>
{componentOptions.map((option) => {
const Icon = option.icon;
return (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
className="cursor-pointer text-xs"
>
{Icon && <Icon className="mr-1 h-3.5 w-3.5" />}
{option.label}
</ToggleGroupItem>
);
})}
</ToggleGroup>
</div>
</PopoverContent>
</Popover>
{headerActions}
</div>
</div>
{/* Search results stats */}
{/* 用真实标签做快速筛选 */}
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:justify-center sm:overflow-visible">
<Button
type="button"
variant={selectedTags.length === 0 ? 'secondary' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => handleTagsChange([])}
>
{t('market.allExtensions')}
</Button>
{availableTags.map((tag) => {
const selected = selectedTags.includes(tag.tag);
return (
<Button
key={tag.tag}
type="button"
variant={selected ? 'secondary' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => {
const newTags = selected
? selectedTags.filter((t) => t !== tag.tag)
: [...selectedTags, tag.tag];
handleTagsChange(newTags);
}}
>
{tagNames[tag.tag] || tag.tag}
{selected && <X className="h-3.5 w-3.5" />}
</Button>
);
})}
</div>
{/* 搜索结果统计 */}
{total > 0 && (
<div className="text-center text-muted-foreground text-sm">
{searchQuery
@@ -574,22 +789,21 @@ function MarketPageContent({
)}
</div>
{/* Scrollable content area */}
{/* Scrollable extension list section */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto px-3 sm:px-4"
className="flex-1 overflow-y-auto px-3 sm:px-4 pb-6 container mx-auto"
>
{/* Recommendation Lists */}
{/* 推荐列表(仅在无搜索/筛选时展示,混合插件/MCP/Skill */}
{!searchQuery &&
typeFilter === 'all' &&
componentFilter === 'all' &&
selectedTags.length === 0 && (
<div className="pt-4">
<RecommendationLists
lists={recommendationLists}
tagNames={tagNames}
onInstall={handleInstallPlugin}
/>
</div>
<RecommendationLists
lists={recommendationLists}
tagNames={tagNames}
onInstall={handleInstallPlugin}
/>
)}
{isLoading ? (
@@ -611,7 +825,7 @@ function MarketPageContent({
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6 pb-6 pt-4">
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,22rem),1fr))] gap-6 mt-6">
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.pluginId}
@@ -654,8 +868,10 @@ function MarketPageContent({
// 主组件,包装在 Suspense 中
export default function MarketPage({
installPlugin,
headerActions,
}: {
installPlugin: (plugin: PluginV4) => void;
headerActions?: React.ReactNode;
}) {
return (
<Suspense
@@ -667,7 +883,10 @@ export default function MarketPage({
</div>
}
>
<MarketPageContent installPlugin={installPlugin} />
<MarketPageContent
installPlugin={installPlugin}
headerActions={headerActions}
/>
</Suspense>
);
}

View File

@@ -22,6 +22,15 @@ function pluginToVO(
plugin: PluginV4,
t: (key: string) => string,
): PluginMarketCardVO {
const cloudClient = getCloudServiceClientSync();
// Recommendation lists are mixed-type; resolve the icon per extension type.
const iconURL =
plugin.type === 'mcp'
? cloudClient.getMCPMarketplaceIconURL(plugin.author, plugin.name)
: plugin.type === 'skill'
? cloudClient.getSkillMarketplaceIconURL(plugin.author, plugin.name)
: cloudClient.getPluginIconURL(plugin.author, plugin.name);
return new PluginMarketCardVO({
pluginId: plugin.author + ' / ' + plugin.name,
author: plugin.author,
@@ -30,14 +39,12 @@ function pluginToVO(
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count,
iconURL: getCloudServiceClientSync().getPluginIconURL(
plugin.author,
plugin.name,
),
iconURL,
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
type: plugin.type,
});
}
@@ -49,7 +56,7 @@ function RecommendationListRow({
}: {
list: RecommendationList;
tagNames: Record<string, string>;
onInstall: (author: string, pluginName: string) => void;
onInstall: (cardVO: PluginMarketCardVO) => void;
isLast: boolean;
}) {
const { t } = useTranslation();
@@ -161,7 +168,7 @@ export function RecommendationLists({
}: {
lists: RecommendationList[];
tagNames: Record<string, string>;
onInstall: (author: string, pluginName: string) => void;
onInstall: (cardVO: PluginMarketCardVO) => void;
}) {
if (!lists || lists.length === 0) return null;

View File

@@ -44,7 +44,7 @@ export function TagsFilter({
return (
<Select open={open} onOpenChange={setOpen}>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="w-[140px] cursor-pointer">
<div className="flex items-center gap-2 w-full">
<TagIcon className="h-4 w-4 flex-shrink-0" />
{selectedTags.length === 0 ? (

View File

@@ -1,16 +1,15 @@
import { PluginMarketCardVO } from './PluginMarketCardVO';
import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '../PluginComponentList';
import { Badge } from '@/components/ui/badge';
import { Info, Package, ExternalLink } from 'lucide-react';
import {
Wrench,
AudioWaveform,
Hash,
Download,
ExternalLink,
Book,
FileText,
} from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
export default function PluginMarketCardComponent({
@@ -19,15 +18,40 @@ export default function PluginMarketCardComponent({
tagNames = {},
}: {
cardVO: PluginMarketCardVO;
onInstall?: (author: string, pluginName: string) => void;
onInstall?: (cardVO: PluginMarketCardVO) => void;
tagNames?: Record<string, string>;
}) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const [visibleTags, setVisibleTags] = useState(2);
const [iconFailed, setIconFailed] = useState(!cardVO.iconURL);
const pluginDetailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
const isDeprecated = (() => {
if (!cardVO.components) return false;
const keys = Object.keys(cardVO.components);
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
})();
const showTypeBadge = cardVO.type;
const typeLabel =
cardVO.type === 'mcp'
? t('market.typeMCP')
: cardVO.type === 'skill'
? t('market.typeSkill')
: t('market.typePlugin');
const typeDotClass =
cardVO.type === 'mcp'
? 'bg-sky-500/70'
: cardVO.type === 'skill'
? 'bg-emerald-500/70'
: 'bg-violet-500/70';
useEffect(() => {
setIconFailed(!cardVO.iconURL);
}, [cardVO.iconURL]);
// Measure how many tags fit in the bottom row
useEffect(() => {
const tags = cardVO.tags;
if (!bottomRef.current || !tags || tags.length === 0) return;
@@ -61,44 +85,43 @@ export default function PluginMarketCardComponent({
}, [cardVO.tags]);
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
function handleInstallClick(e: React.MouseEvent) {
e.stopPropagation();
if (onInstall) {
onInstall(cardVO.author, cardVO.pluginName);
}
}
function handleViewDetailsClick(e: React.MouseEvent) {
e.stopPropagation();
const detailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
window.open(detailUrl, '_blank');
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-4 h-4" />,
EventListener: <AudioWaveform className="w-4 h-4" />,
Command: <Hash className="w-4 h-4" />,
KnowledgeEngine: <Book className="w-4 h-4" />,
Parser: <FileText className="w-4 h-4" />,
const handleInstallClick = () => {
onInstall?.(cardVO);
};
return (
<div
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-3 sm:p-[1rem] hover:border-[#a1a1aa] dark:hover:border-[#3f3f46] transition-all duration-200 dark:bg-[#1f1f22] relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
role="button"
tabIndex={0}
aria-label={t('market.installCard', { name: cardVO.label })}
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_0px_4px_0_rgba(255,255,255,0.1)] dark:hover:shadow-[0px_2px_8px_0_rgba(255,255,255,0.15)] relative"
onClick={handleInstallClick}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleInstallClick();
}
}}
>
<div className="w-full h-full flex flex-col justify-between gap-3">
{/* 上部分:插件信息 */}
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0">
<img
src={cardVO.iconURL}
alt="plugin icon"
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%]"
/>
<div className="w-full h-full flex flex-col justify-between">
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0 flex-1 overflow-hidden">
{iconFailed ? (
<div className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%] border bg-muted text-muted-foreground flex items-center justify-center">
<Package className="w-6 h-6 sm:w-8 sm:h-8" />
</div>
) : (
<img
src={cardVO.iconURL}
alt="plugin icon"
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%] object-cover"
loading="lazy"
decoding="async"
fetchPriority="low"
onError={() => setIconFailed(true)}
/>
)}
<div className="flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 overflow-hidden">
<div className="flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 pr-1 overflow-hidden">
<div className="flex flex-col items-start justify-start w-full min-w-0">
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
{cardVO.pluginId}
@@ -107,6 +130,44 @@ export default function PluginMarketCardComponent({
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">
{cardVO.label}
</div>
{isDeprecated && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Badge
variant="outline"
className="text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 border-red-400 text-red-500 dark:border-red-500 dark:text-red-400 gap-0.5 cursor-help"
>
{t('market.deprecated')}
<Info className="w-2.5 h-2.5" />
</Badge>
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-[240px] text-xs"
>
{t('market.deprecatedTooltip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{showTypeBadge && (
<Badge
variant="outline"
className="h-4 max-w-[4.5rem] flex-shrink-0 gap-1 border-border/60 bg-muted/30 px-1.5 py-0 text-[0.58rem] font-normal text-muted-foreground"
>
<span
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${typeDotClass}`}
/>
<span className="truncate">{typeLabel}</span>
</Badge>
)}
</div>
</div>
@@ -115,31 +176,53 @@ export default function PluginMarketCardComponent({
</div>
</div>
<div className="flex flex-row items-start justify-center gap-[0.4rem] flex-shrink-0">
<div className="flex flex-row items-start justify-center gap-1 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
title={t('market.viewDetails')}
aria-label={t('market.viewDetails')}
className="h-7 w-7 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
window.open(pluginDetailUrl, '_blank');
}}
>
<ExternalLink className="h-4 w-4" />
</Button>
{cardVO.githubURL && (
<svg
className="w-5 h-5 sm:w-[1.4rem] sm:h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
<Button
type="button"
variant="ghost"
size="icon"
title="GitHub"
aria-label="GitHub"
className="h-7 w-7 rounded-md text-foreground hover:bg-muted hover:text-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(cardVO.githubURL, '_blank');
}}
>
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
</svg>
<svg
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
</svg>
</Button>
)}
</div>
</div>
{/* 下部分:下载量、标签和组件列表 */}
<div
ref={bottomRef}
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
>
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
{/* 下载数量 */}
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
@@ -158,7 +241,6 @@ export default function PluginMarketCardComponent({
</div>
</div>
{/* Tags - adaptive */}
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
{cardVO.tags.slice(0, visibleTags).map((tag) => (
@@ -197,52 +279,20 @@ export default function PluginMarketCardComponent({
)}
</div>
{/* 组件列表 */}
{cardVO.components && Object.keys(cardVO.components).length > 0 && (
<div className="flex flex-row items-center gap-1">
{Object.entries(cardVO.components).map(([kind, count]) => (
<Badge
key={kind}
variant="outline"
className="flex items-center gap-1"
>
{kindIconMap[kind]}
<span className="ml-1">{count}</span>
</Badge>
))}
<div className="flex flex-row items-center gap-1 flex-shrink-0">
<PluginComponentList
components={cardVO.components}
showComponentName={false}
showTitle={false}
useBadge={true}
t={t}
responsive={false}
/>
</div>
)}
</div>
</div>
{/* Hover overlay with action buttons */}
<div
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<Button
onClick={handleInstallClick}
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
>
<Download className="w-4 h-4" />
{t('market.install')}
</Button>
<Button
onClick={handleViewDetailsClick}
variant="outline"
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
>
<ExternalLink className="w-4 h-4" />
{t('market.viewDetails')}
</Button>
</div>
</div>
);
}

View File

@@ -10,6 +10,7 @@ export interface IPluginMarketCardVO {
version: string;
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -24,6 +25,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
version: string;
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
@@ -37,5 +39,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.version = prop.version;
this.components = prop.components;
this.tags = prop.tags;
this.type = prop.type;
}
}

View File

@@ -3,6 +3,7 @@ import MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComp
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
import { useTranslation } from 'react-i18next';
import { MCPSessionStatus } from '@/app/infra/entities/api';
import { Hexagon } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -79,14 +80,7 @@ export default function MCPComponent({
</div>
) : installedServers.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z"></path>
</svg>
<Hexagon className="h-[3rem] w-[3rem]" />
<div className="text-lg mb-2">{t('mcp.noServerInstalled')}</div>
</div>
) : (

View File

@@ -6,7 +6,14 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react';
import {
RefreshCcw,
Wrench,
Ban,
AlertCircle,
Loader2,
Link,
} from 'lucide-react';
import { MCPSessionStatus } from '@/app/infra/entities/api';
export default function MCPCardComponent({
@@ -88,15 +95,10 @@ export default function MCPCardComponent({
onClick={onCardClick}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="64"
height="64"
fill="rgba(70,146,221,1)"
>
<path d="M17.6567 14.8284L16.2425 13.4142L17.6567 12C19.2188 10.4379 19.2188 7.90524 17.6567 6.34314C16.0946 4.78105 13.5619 4.78105 11.9998 6.34314L10.5856 7.75736L9.17139 6.34314L10.5856 4.92893C12.9287 2.58578 16.7277 2.58578 19.0709 4.92893C21.414 7.27208 21.414 11.0711 19.0709 13.4142L17.6567 14.8284ZM14.8282 17.6569L13.414 19.0711C11.0709 21.4142 7.27189 21.4142 4.92875 19.0711C2.5856 16.7279 2.5856 12.9289 4.92875 10.5858L6.34296 9.17157L7.75717 10.5858L6.34296 12C4.78086 13.5621 4.78086 16.0948 6.34296 17.6569C7.90506 19.2189 10.4377 19.2189 11.9998 17.6569L13.414 16.2426L14.8282 17.6569ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z"></path>
</svg>
<Link
className="w-16 h-16 flex-shrink-0"
style={{ color: 'rgba(70,146,221,1)' }}
/>
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start gap-[0.3rem]">
@@ -127,22 +129,22 @@ export default function MCPCardComponent({
{t('mcp.toolCount', { count: toolsCount })}
</div>
</div>
) : status === MCPSessionStatus.CONNECTING ? (
// 连接 - 蓝色加载
<div className="flex flex-row items-center gap-[0.4rem]">
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
{t('mcp.connecting')}
</div>
</div>
) : (
// 连接失败 - 红色
) : status === MCPSessionStatus.ERROR ? (
// 连接失败 - 红色(仅在明确报错时)
<div className="flex flex-row items-center gap-[0.4rem]">
<AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" />
<div className="text-sm text-red-500 dark:text-red-400 font-medium">
{t('mcp.connectionFailedStatus')}
</div>
</div>
) : (
// 连接中 - 蓝色加载CONNECTING 或初始/未知状态,避免误报失败)
<div className="flex flex-row items-center gap-[0.4rem]">
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
{t('mcp.connecting')}
</div>
</div>
)}
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, XCircle, Trash2 } from 'lucide-react';
import { Resolver, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -46,6 +47,8 @@ import {
MCPServerExtraArgsStdio,
} from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
// Status Display Component - 在测试中、连接中或连接失败时使用
function StatusDisplay({
@@ -60,26 +63,7 @@ function StatusDisplay({
if (testing) {
return (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<Loader2 className="w-5 h-5 animate-spin" />
<span className="font-medium">{t('mcp.testing')}</span>
</div>
);
@@ -89,49 +73,43 @@ function StatusDisplay({
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
return (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<Loader2 className="w-5 h-5 animate-spin" />
<span className="font-medium">{t('mcp.connecting')}</span>
</div>
);
}
// Stdio MCP refused because Box is disabled / unreachable. The backend
// marks the phase so we can show a localized, actionable message instead
// of the raw "box_disabled_in_config" / "box_unavailable" marker.
if (runtimeInfo.error_phase === 'box_unavailable') {
const isDisabledByConfig =
runtimeInfo.error_message === 'box_disabled_in_config';
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-red-600">
<XCircle className="w-5 h-5" />
<span className="font-medium">{t('mcp.connectionFailed')}</span>
</div>
<div className="text-sm text-red-500 pl-7 space-y-0.5">
<div>
{isDisabledByConfig
? t('mcp.boxDisabledStdioRefused')
: t('mcp.boxUnavailableStdioRefused')}
</div>
<div className="text-muted-foreground">
{t('mcp.boxStdioRefusedSuggestion')}
</div>
</div>
</div>
);
}
// 连接失败
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-red-600">
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<XCircle className="w-5 h-5" />
<span className="font-medium">{t('mcp.connectionFailed')}</span>
</div>
{runtimeInfo.error_message && (
@@ -261,6 +239,14 @@ export default function MCPFormDialog({
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const watchMode = form.watch('mode');
const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
// stdio mode requires the Box sandbox at runtime. Block creation here
// so users aren't surprised by a connection failure on the detail page.
const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable;
// Load server data when editing
useEffect(() => {
@@ -384,6 +370,12 @@ export default function MCPFormDialog({
}
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
// Belt-and-suspenders: Save button is also disabled in this case, but
// a programmatic submit (e.g. Enter key) should still be refused.
if (value.mode === 'stdio' && !boxAvailable) {
toast.error(t('mcp.stdioBlockedByBoxToast'));
return;
}
try {
let serverConfig: MCPServer;
@@ -676,10 +668,24 @@ export default function MCPFormDialog({
</FormControl>
<SelectContent>
<SelectItem value="http">{t('mcp.http')}</SelectItem>
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
<SelectItem value="stdio" disabled={!boxAvailable}>
{t('mcp.stdio')}
{!boxAvailable && (
<span className="ml-2 text-xs text-muted-foreground">
({t('mcp.boxRequired')})
</span>
)}
</SelectItem>
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
</SelectContent>
</Select>
{stdioBlockedByBox && (
<BoxUnavailableNotice
hint={boxHint}
reason={boxReason}
className="mt-2"
/>
)}
<FormMessage />
</FormItem>
)}
@@ -780,14 +786,7 @@ export default function MCPFormDialog({
className="p-2 hover:bg-gray-100 rounded"
onClick={() => removeStdioArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
<Trash2 className="w-5 h-5 text-red-500" />
</button>
</div>
))}
@@ -851,14 +850,7 @@ export default function MCPFormDialog({
className="p-2 hover:bg-gray-100 rounded"
onClick={() => removeExtraArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
<Trash2 className="w-5 h-5 text-red-500" />
</button>
</div>
))}
@@ -885,7 +877,7 @@ export default function MCPFormDialog({
</Button>
)}
<Button type="submit">
<Button type="submit" disabled={stdioBlockedByBox}>
{isEditMode ? t('common.save') : t('common.submit')}
</Button>

File diff suppressed because it is too large Load Diff

View File

@@ -14,8 +14,15 @@
padding-top: 2rem;
padding-bottom: 2rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr));
gap: 2rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
gap: 1.25rem;
justify-items: stretch;
align-items: start;
}
@media (min-width: 640px) {
.pluginListContainer {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 28rem), 1fr));
gap: 2rem;
}
}

View File

@@ -0,0 +1,220 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { httpClient } from '@/app/infra/http/HttpClient';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { Sparkles, Trash2 } from 'lucide-react';
export default function SkillDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const skill = skills.find((item) => item.id === id);
const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('skills.createSkill'));
} else {
setDetailEntityName(skill?.name ?? id);
}
return () => setDetailEntityName(null);
}, [id, isCreateMode, setDetailEntityName, skill, t]);
function handleImportedSkills(skillNames: string[]) {
void refreshSkills();
const primarySkill = skillNames[0];
if (primarySkill) {
navigate(`/home/skills?id=${encodeURIComponent(primarySkill)}`);
return;
}
navigate('/home/skills');
}
function handleSkillUpdated() {
void refreshSkills();
}
async function confirmDelete() {
try {
await httpClient.deleteSkill(id);
toast.success(t('skills.deleteSuccess'));
setShowDeleteConfirm(false);
void refreshSkills();
navigate('/home/skills');
} catch (error) {
toast.error(t('skills.deleteError') + String(error));
}
}
if (isCreateMode) {
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 items-center gap-3">
<h1 className="truncate text-xl font-semibold">
{t('skills.createSkill')}
</h1>
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
<Sparkles className="size-3.5" />
{t('skills.title')}
</Badge>
</div>
</div>
<Button
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
>
{t('common.save')}
</Button>
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div>
)}
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
onSkillUpdated={() => {}}
/>
</div>
</div>
);
}
const editActions = (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('skills.dangerZone')}
</CardTitle>
<CardDescription>{t('skills.dangerZoneDescription')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{t('skills.delete')}</p>
<p className="text-sm text-muted-foreground">
{t('skills.deleteConfirmation')}
</p>
</div>
<Button
variant="destructive"
type="button"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="shrink-0"
>
<Trash2 className="mr-1.5 size-4" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
);
return (
<>
<div className="flex h-full flex-col">
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 items-center gap-3">
<h1 className="truncate text-xl font-semibold">
{skill?.name ?? id}
</h1>
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
<Sparkles className="size-3.5" />
{t('skills.title')}
</Badge>
</div>
{skill?.description && (
<p className="line-clamp-2 text-sm text-muted-foreground">
{skill.description}
</p>
)}
</div>
<Button
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
>
{t('common.save')}
</Button>
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div>
)}
<div className="min-h-0 flex-1">
<SkillForm
key={id}
initSkillName={id}
layout="split"
sideFooter={editActions}
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
onSkillUpdated={handleSkillUpdated}
/>
</div>
</div>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent className="max-h-[min(420px,80vh)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<div className="py-4">{t('skills.deleteConfirmation')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,277 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { BookOpen, FileArchive, Loader2, PackageOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { httpClient } from '@/app/infra/http/HttpClient';
import type { Skill } from '@/app/infra/entities/api';
import { cn } from '@/lib/utils';
interface PreviewSkill extends Skill {
source_path?: string;
}
interface SkillZipPreviewPanelProps {
file: File;
onImported: (skillNames: string[]) => void;
onCancel?: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
function previewPath(skill: PreviewSkill): string {
return skill.source_path ?? '';
}
function displayPreviewPath(skill: PreviewSkill): string {
return previewPath(skill) || skill.name;
}
function truncateInstructions(instructions?: string): string {
if (!instructions) return '';
const trimmed = instructions.trim();
if (trimmed.length <= 900) return trimmed;
return trimmed.slice(0, 900).trimEnd() + '\n...';
}
export default function SkillZipPreviewPanel({
file,
onImported,
onCancel,
}: SkillZipPreviewPanelProps) {
const { t } = useTranslation();
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
const [activePath, setActivePath] = useState('');
const [previewing, setPreviewing] = useState(false);
const [installing, setInstalling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const lastPreviewSignatureRef = useRef('');
const previewFileSignature = `${file.name}:${file.size}:${file.lastModified}`;
const activeSkill = useMemo(
() =>
previewSkills.find((skill) => previewPath(skill) === activePath) ||
previewSkills[0] ||
null,
[activePath, previewSkills],
);
const loadPreview = useCallback(async () => {
setPreviewing(true);
setPreviewSkills([]);
setSelectedPaths([]);
setActivePath('');
setErrorMessage(null);
try {
const resp = await httpClient.previewSkillInstallFromUpload(file);
const skills = (resp.skills || []) as PreviewSkill[];
setPreviewSkills(skills);
const paths = skills.map(previewPath);
setSelectedPaths(paths);
setActivePath(paths[0] || '');
if (skills.length === 0) {
setErrorMessage(t('skills.noSkillMdInDirectory'));
} else {
setErrorMessage(null);
}
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('skills.previewLoadError'));
} finally {
setPreviewing(false);
}
}, [file, t]);
useEffect(() => {
if (lastPreviewSignatureRef.current === previewFileSignature) return;
lastPreviewSignatureRef.current = previewFileSignature;
void loadPreview();
}, [loadPreview, previewFileSignature]);
function toggleSelection(path: string) {
setSelectedPaths((current) => {
if (current.includes(path)) {
const next = current.filter((item) => item !== path);
if (activePath === path) {
setActivePath(next[0] || path);
}
return next;
}
setActivePath(path);
return [...current, path];
});
}
async function handleInstall() {
if (selectedPaths.length === 0) return;
setInstalling(true);
setErrorMessage(null);
try {
const resp = await httpClient.installSkillFromUpload(file, selectedPaths);
toast.success(t('skills.installSuccess'));
onImported(resp.skills.map((skill) => skill.name));
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setInstalling(false);
}
}
const activeInstructions = truncateInstructions(activeSkill?.instructions);
return (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-md bg-muted/40 px-3 py-3">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
{previewing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FileArchive className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{previewing ? t('skills.loading') : t('skills.preview')}
</div>
<div className="mt-1 break-all text-xs text-muted-foreground">
{file.name} · {formatFileSize(file.size)}
</div>
</div>
</div>
{previewSkills.length > 0 && (
<div
className={cn(
'grid gap-4',
previewSkills.length > 1 && 'md:grid-cols-[240px_minmax(0,1fr)]',
)}
>
{previewSkills.length > 1 && (
<div className="space-y-2">
{previewSkills.map((skill) => {
const path = previewPath(skill);
const displayPath = displayPreviewPath(skill);
const selected = selectedPaths.includes(path);
const active = activePath === path;
return (
<button
key={`${path}:${skill.name}`}
type="button"
className={cn(
'flex w-full items-start gap-2 rounded-md px-3 py-2 text-left transition-colors',
active ? 'bg-accent' : 'bg-muted/30 hover:bg-accent/70',
)}
onClick={() => setActivePath(path)}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggleSelection(path)}
onClick={(event) => event.stopPropagation()}
className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{skill.display_name || skill.name}
</span>
{path && (
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{displayPath}
</span>
)}
</span>
</button>
);
})}
</div>
)}
{activeSkill && (
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<BookOpen className="size-4 text-muted-foreground" />
<h3 className="min-w-0 truncate text-base font-semibold">
{activeSkill.display_name || activeSkill.name}
</h3>
</div>
{activeSkill.description && (
<p className="text-sm leading-6 text-muted-foreground">
{activeSkill.description}
</p>
)}
{activeInstructions && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t('skills.previewInstructions')}
</div>
<div className="max-h-56 overflow-y-auto rounded-md bg-muted/40 p-3 font-mono text-xs leading-5 whitespace-pre-wrap">
{activeInstructions}
</div>
</div>
)}
</div>
)}
</div>
)}
{errorMessage && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2">
{onCancel && (
<Button variant="outline" onClick={onCancel} disabled={installing}>
{t('common.cancel')}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={
previewing ||
installing ||
previewSkills.length === 0 ||
selectedPaths.length === 0
}
>
{installing ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('skills.installing')}
</>
) : (
<>
<PackageOpen className="size-4" />
{t('skills.confirmInstall')}
</>
)}
</Button>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
import { useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
export default function SkillsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
const actionParam = searchParams.get('action');
const { refreshSkills } = useSidebarData();
const isCreateView = actionParam === 'create';
const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
useEffect(() => {
if (!detailId && !isCreateView) {
navigate('/home/add-extension', { replace: true });
}
}, [detailId, isCreateView, navigate]);
if (detailId) {
return <SkillDetailContent id={detailId} />;
}
function handleCreatedSkill(skillName: string) {
void refreshSkills();
navigate(`/home/skills?id=${encodeURIComponent(skillName)}`, {
replace: true,
});
}
function handleCancel() {
navigate('/home/add-extension');
}
if (!isCreateView) {
return null;
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="text-xl font-semibold">{t('skills.createSkill')}</h1>
<p className="text-sm text-muted-foreground">
{t('skills.createSkillDescription')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
<Button type="submit" form="skill-form" disabled={!boxAvailable}>
{t('common.save')}
</Button>
</div>
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div>
)}
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={handleCreatedSkill}
onSkillUpdated={() => {}}
/>
</div>
</div>
);
}

View File

@@ -1,27 +0,0 @@
import styles from './createCartComponent.module.css';
export default function CreateCardComponent({
height,
plusSize,
onClick,
width = '100%',
}: {
height: string;
plusSize: string;
onClick: () => void;
width?: string;
}) {
return (
<div
className={`${styles.cardContainer} ${styles.createCardContainer} `}
style={{
width: `${width}`,
height: `${height}`,
fontSize: `${plusSize}px`,
}}
onClick={onClick}
>
+
</div>
);
}

View File

@@ -280,6 +280,15 @@ export interface ApiRespPlugins {
plugins: Plugin[];
}
export type ExtensionItem =
| { type: 'plugin'; plugin: Plugin }
| { type: 'mcp'; server: MCPServer }
| { type: 'skill'; skill: Skill };
export interface ApiRespExtensions {
extensions: ExtensionItem[];
}
export interface ApiRespPlugin {
plugin: Plugin;
}
@@ -351,6 +360,37 @@ export interface ApiRespPluginSystemStatus {
plugin_connector_error: string;
}
export interface ApiRespBoxStatus {
available: boolean;
/** Whether ``box.enabled`` is true in config. When false, the sandbox
* is deliberately disabled — distinct from "configured but failed". */
enabled?: boolean;
profile: string;
recent_error_count: number;
connector_error?: string;
backend?: {
name: string;
available: boolean;
};
active_sessions?: number;
managed_processes?: number;
session_ttl_sec?: number;
}
export interface BoxSessionInfo {
session_id: string;
backend_name: string;
image: string;
network: string;
host_path: string | null;
host_path_mode: string;
mount_path: string;
cpus: number;
memory_mb: number;
created_at: string;
last_used_at: string;
}
export interface ApiRespAsyncTasks {
tasks: AsyncTask[];
}
@@ -489,8 +529,18 @@ export enum MCPSessionStatus {
export interface MCPServerRuntimeInfo {
status: MCPSessionStatus;
error_message?: string;
/** Stage at which the session failed. Frontends key off this to render
* a localized actionable message instead of the raw ``error_message``.
* Notable values: ``box_unavailable`` (stdio MCP refused because Box is
* disabled / unreachable). See ``MCPSessionErrorPhase`` (backend). */
error_phase?: string;
retry_count?: number;
tool_count: number;
tools: MCPTool[];
/** Optional ``box_session_id`` / ``box_enabled`` set when this stdio
* server runs inside Box. Absent when Box is unavailable. */
box_session_id?: string;
box_enabled?: boolean;
}
export type MCPServer =
@@ -545,3 +595,23 @@ export interface ApiRespTools {
export interface ApiRespToolDetail {
tool: PluginTool;
}
// Skills
export interface Skill {
name: string;
display_name?: string;
description: string;
instructions?: string;
package_root?: string;
is_builtin?: boolean;
created_at?: string;
updated_at?: string;
}
export interface ApiRespSkills {
skills: Skill[];
}
export interface ApiRespSkill {
skill: Skill;
}

View File

@@ -16,7 +16,18 @@ export interface IDynamicFormItemSchema {
type: DynamicFormItemType;
description?: I18nObject;
options?: IDynamicFormItemOption[];
/** When the condition matches, the field is rendered. Same evaluator as
* ``disable_if`` — supports the ``__system.*`` namespace via
* ``DynamicFormComponent.systemContext``. */
show_if?: IShowIfCondition;
/** When the condition matches, the field is rendered as read-only/disabled
* but stays visible. Use this when the operator needs to see that the
* field exists but can't be edited under the current runtime state (e.g.
* a sandbox-bound field when Box is disabled). Pair with
* ``disabled_tooltip`` to explain why. */
disable_if?: IShowIfCondition;
/** Tooltip shown next to the field label when ``disable_if`` is active. */
disabled_tooltip?: I18nObject;
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
scopes?: string[];

View File

@@ -31,6 +31,8 @@ export enum PluginV4Status {
export interface PluginV4 {
id: number;
plugin_id: string;
mcp_id?: string;
skill_id?: string;
author: string;
name: string;
label: I18nObject;
@@ -42,6 +44,7 @@ export interface PluginV4 {
latest_version: string;
components: Record<string, number>;
status: PluginV4Status;
type?: 'plugin' | 'mcp' | 'skill';
created_at: string;
updated_at: string;
}

View File

@@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from 'react';
import type { ApiRespBoxStatus } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
/**
* Shared hook for Box runtime status — used by every UI surface that needs
* to gate behaviour on whether the sandbox is available. Returns:
*
* - status: full payload (or null while loading / on error)
* - available: convenience flag (status?.available === true)
* - disabled: true iff Box is explicitly disabled by config
* (status.enabled === false), distinguishing it from
* "configured but currently failed"
* - hint: a single i18n-key choice for the banner message —
* 'boxDisabled' / 'boxUnavailable' / null
* - refresh: imperative re-fetch
*
* Polls every ``refreshMs`` (default 30s) so a flapping runtime is picked
* up without a page reload.
*/
export function useBoxStatus(refreshMs = 30_000) {
const [status, setStatus] = useState<ApiRespBoxStatus | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const data = await httpClient.getBoxStatus();
setStatus(data);
} catch {
// Keep last-known status; the dashboard polls separately so a
// transient failure here should not blank the UI for sandbox
// consumers.
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const id = setInterval(() => void refresh(), refreshMs);
return () => clearInterval(id);
}, [refresh, refreshMs]);
const available = status?.available === true;
const disabled = status?.available === false && status?.enabled === false;
const hint: 'boxDisabled' | 'boxUnavailable' | null = available
? null
: disabled
? 'boxDisabled'
: status
? 'boxUnavailable'
: null;
// Specific reason from the backend (e.g.
// ``Configured sandbox backend "nsjail" is unavailable`` or
// ``docker daemon not running``). Surface this in the failed-state
// banner so the user sees WHY instead of a generic "unavailable".
// For the disabled-by-config case the boxDisabled i18n string already
// carries the reason, so we suppress the duplicate.
const reason =
hint === 'boxUnavailable' ? status?.connector_error?.trim() || null : null;
return { status, loading, available, disabled, hint, reason, refresh };
}

View File

@@ -15,6 +15,7 @@ import {
ApiRespPlugins,
ApiRespPlugin,
ApiRespPluginConfig,
ApiRespExtensions,
AsyncTaskCreatedResp,
ApiRespSystemInfo,
ApiRespAsyncTasks,
@@ -35,6 +36,8 @@ import {
ApiRespProviderRerankModel,
RerankModel,
ApiRespPluginSystemStatus,
ApiRespBoxStatus,
BoxSessionInfo,
ApiRespMCPServers,
ApiRespMCPServer,
MCPServer,
@@ -47,8 +50,12 @@ import {
RagMigrationStatusResp,
ApiRespTools,
ApiRespToolDetail,
Skill,
ApiRespSkills,
ApiRespSkill,
} from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
import type { I18nObject } from '@/app/infra/entities/common';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -260,10 +267,13 @@ export class BackendClient extends BaseHttpClient {
public getPipelineExtensions(uuid: string): Promise<{
enable_all_plugins: boolean;
enable_all_mcp_servers: boolean;
enable_all_skills: boolean;
bound_plugins: Array<{ author: string; name: string }>;
available_plugins: Plugin[];
bound_mcp_servers: string[];
available_mcp_servers: MCPServer[];
bound_skills: string[];
available_skills: Skill[];
}> {
return this.get(`/api/v1/pipelines/${uuid}/extensions`);
}
@@ -274,12 +284,16 @@ export class BackendClient extends BaseHttpClient {
bound_mcp_servers: string[],
enable_all_plugins: boolean = true,
enable_all_mcp_servers: boolean = true,
bound_skills: string[] = [],
enable_all_skills: boolean = true,
): Promise<object> {
return this.put(`/api/v1/pipelines/${uuid}/extensions`, {
bound_plugins,
bound_mcp_servers,
enable_all_plugins,
enable_all_mcp_servers,
bound_skills,
enable_all_skills,
});
}
@@ -531,6 +545,11 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/knowledge/parsers${params}`);
}
// ============ Extensions API ============
public getExtensions(): Promise<ApiRespExtensions> {
return this.get('/api/v1/extensions');
}
// ============ Plugins API ============
public getPlugins(): Promise<ApiRespPlugins> {
return this.get('/api/v1/plugins');
@@ -653,9 +672,12 @@ export class BackendClient extends BaseHttpClient {
published_at: string;
prerelease: boolean;
draft: boolean;
source_type?: 'release' | 'tag' | 'branch';
archive_url?: string;
}>;
owner: string;
repo: string;
source_subdir?: string;
}> {
return this.post('/api/v1/plugins/github/releases', { repo_url: repoUrl });
}
@@ -664,6 +686,9 @@ export class BackendClient extends BaseHttpClient {
owner: string,
repo: string,
releaseId: number,
releaseTag?: string,
sourceType?: 'release' | 'tag' | 'branch',
archiveUrl?: string,
): Promise<{
assets: Array<{
id: number;
@@ -677,6 +702,9 @@ export class BackendClient extends BaseHttpClient {
owner,
repo,
release_id: releaseId,
release_tag: releaseTag,
source_type: sourceType,
archive_url: archiveUrl,
});
}
@@ -686,6 +714,83 @@ export class BackendClient extends BaseHttpClient {
return this.postFile('/api/v1/plugins/install/local', formData);
}
public previewPluginInstallFromLocal(file: File): Promise<{
filename: string;
size: number;
manifest: Record<string, unknown>;
metadata: {
author?: string;
name?: string;
version?: string;
label?: I18nObject;
description?: I18nObject;
repository?: string;
};
component_types: string[];
component_counts: Record<string, number>;
requirements: string[];
file_count: number;
}> {
const formData = new FormData();
formData.append('file', file);
return this.postFile('/api/v1/plugins/install/local/preview', formData);
}
// ============ Skill Install API ============
public installSkillFromGithub(
assetUrl: string,
owner: string,
repo: string,
releaseTag: string,
sourcePaths?: string[],
sourceSubdir?: string,
): Promise<ApiRespSkills> {
return this.post('/api/v1/skills/install/github', {
asset_url: assetUrl,
owner,
repo,
release_tag: releaseTag,
source_paths: sourcePaths,
source_subdir: sourceSubdir,
});
}
public previewSkillInstallFromGithub(
assetUrl: string,
owner: string,
repo: string,
releaseTag: string,
sourceSubdir?: string,
): Promise<{ skills: Skill[] }> {
return this.post('/api/v1/skills/install/github/preview', {
asset_url: assetUrl,
owner,
repo,
release_tag: releaseTag,
source_subdir: sourceSubdir,
});
}
public previewSkillInstallFromUpload(
file: File,
): Promise<{ skills: Skill[] }> {
const formData = new FormData();
formData.append('file', file);
return this.postFile('/api/v1/skills/install/upload/preview', formData);
}
public installSkillFromUpload(
file: File,
sourcePaths?: string[],
): Promise<ApiRespSkills> {
const formData = new FormData();
formData.append('file', file);
for (const sourcePath of sourcePaths || []) {
formData.append('source_paths', sourcePath);
}
return this.postFile('/api/v1/skills/install/upload', formData);
}
public installPluginFromMarketplace(
author: string,
name: string,
@@ -731,7 +836,7 @@ export class BackendClient extends BaseHttpClient {
}
public getMCPServer(serverName: string): Promise<ApiRespMCPServer> {
return this.get(`/api/v1/mcp/servers/${serverName}`);
return this.get(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`);
}
public createMCPServer(server: MCPServer): Promise<AsyncTaskCreatedResp> {
@@ -742,18 +847,21 @@ export class BackendClient extends BaseHttpClient {
serverName: string,
server: Partial<MCPServer>,
): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}`, server);
return this.put(
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`,
server,
);
}
public deleteMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
return this.delete(`/api/v1/mcp/servers/${serverName}`);
return this.delete(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`);
}
public toggleMCPServer(
serverName: string,
target_enabled: boolean,
): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}`, {
return this.put(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`, {
enable: target_enabled,
});
}
@@ -762,7 +870,10 @@ export class BackendClient extends BaseHttpClient {
serverName: string,
serverData: object,
): Promise<AsyncTaskCreatedResp> {
return this.post(`/api/v1/mcp/servers/${serverName}/test`, serverData);
return this.post(
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/test`,
serverData,
);
}
public installMCPServerFromGithub(
@@ -839,6 +950,14 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/plugins/debug-info');
}
public getBoxStatus(): Promise<ApiRespBoxStatus> {
return this.get('/api/v1/box/status');
}
public getBoxSessions(): Promise<BoxSessionInfo[]> {
return this.get('/api/v1/box/sessions');
}
// ============ User API ============
public checkIfInited(): Promise<{ initialized: boolean }> {
return this.get('/api/v1/user/init');
@@ -1133,6 +1252,98 @@ export class BackendClient extends BaseHttpClient {
public dismissSurvey(surveyId: string): Promise<object> {
return this.post('/api/v1/survey/dismiss', { survey_id: surveyId });
}
// ============ Skills API ============
public getSkills(): Promise<ApiRespSkills> {
return this.get('/api/v1/skills');
}
public getSkill(name: string): Promise<ApiRespSkill> {
return this.get(`/api/v1/skills/${name}`);
}
public createSkill(
skill: Omit<Skill, 'name'> & { name: string },
): Promise<ApiRespSkill> {
return this.post('/api/v1/skills', skill);
}
public updateSkill(
name: string,
skill: Partial<Skill>,
): Promise<ApiRespSkill> {
return this.put(`/api/v1/skills/${name}`, skill);
}
public deleteSkill(name: string): Promise<object> {
return this.delete(`/api/v1/skills/${name}`);
}
public previewSkill(name: string): Promise<{ instructions: string }> {
return this.get(`/api/v1/skills/${name}/preview`);
}
public getSkillIndex(pipelineUuid?: string): Promise<{ index: string }> {
const params = pipelineUuid ? { pipeline_uuid: pipelineUuid } : {};
return this.get('/api/v1/skills/index', params);
}
public scanSkillDirectory(path: string): Promise<{
package_root: string;
name: string;
display_name?: string;
description: string;
instructions: string;
}> {
return this.get('/api/v1/skills/scan', { path });
}
public listSkillFiles(
skillName: string,
path: string = '.',
includeHidden: boolean = false,
): Promise<{
skill: { name: string };
base_path: string;
entries: Array<{
path: string;
name: string;
is_dir: boolean;
size: number | null;
}>;
truncated: boolean;
}> {
return this.get(`/api/v1/skills/${skillName}/files`, {
path,
include_hidden: includeHidden,
});
}
public readSkillFile(
skillName: string,
filePath: string,
): Promise<{
skill: { name: string };
path: string;
content: string;
}> {
return this.get(`/api/v1/skills/${skillName}/files/${filePath}`);
}
public writeSkillFile(
skillName: string,
filePath: string,
content: string,
): Promise<{
skill: { name: string };
path: string;
bytes_written: number;
}> {
return this.put(`/api/v1/skills/${skillName}/files/${filePath}`, {
content,
});
}
}
export interface SurveyQuestion {

View File

@@ -38,7 +38,49 @@ export class CloudServiceClient extends BaseHttpClient {
sort_order?: string,
component_filter?: string,
tags_filter?: string[],
type_filter?: string,
): Promise<ApiRespMarketplacePlugins> {
// Use different endpoints based on type_filter
if (type_filter === 'mcp') {
return this.post<{ mcps: PluginV4[]; total: number }>(
'/api/v1/marketplace/mcps/search',
{
query,
page,
page_size,
sort_by,
sort_order,
tags_filter,
},
).then((resp) => ({
plugins: (resp?.mcps || []).map((mcp) => ({
...mcp,
plugin_id: mcp.mcp_id || mcp.plugin_id,
type: 'mcp' as const,
})),
total: resp?.total || 0,
}));
} else if (type_filter === 'skill') {
return this.post<{ skills: PluginV4[]; total: number }>(
'/api/v1/marketplace/skills/search',
{
query,
page,
page_size,
sort_by,
sort_order,
tags_filter,
},
).then((resp) => ({
plugins: (resp?.skills || []).map((skill) => ({
...skill,
plugin_id: skill.skill_id || skill.plugin_id,
type: 'skill' as const,
})),
total: resp?.total || 0,
}));
}
return this.post<ApiRespMarketplacePlugins>(
'/api/v1/marketplace/plugins/search',
{
@@ -49,10 +91,113 @@ export class CloudServiceClient extends BaseHttpClient {
sort_order,
component_filter,
tags_filter,
type_filter,
},
);
}
public searchMarketplaceExtensions(data: {
query?: string;
page: number;
page_size: number;
sort_by?: string;
sort_order?: string;
type_filter?: string;
component_filter?: string;
tags_filter?: string[];
}): Promise<ApiRespMarketplacePlugins> {
return this.post<{ extensions: PluginV4[]; total: number }>(
'/api/v1/marketplace/extensions/search',
data,
)
.then((resp) => ({
plugins: resp?.extensions || [],
total: resp?.total || 0,
}))
.catch(() => this.searchMarketplaceExtensionsLegacy(data));
}
private async searchMarketplaceExtensionsLegacy(data: {
query?: string;
page: number;
page_size: number;
sort_by?: string;
sort_order?: string;
type_filter?: string;
component_filter?: string;
tags_filter?: string[];
}): Promise<ApiRespMarketplacePlugins> {
const query = data.query || '';
if (
data.type_filter === 'plugin' ||
data.type_filter === 'mcp' ||
data.type_filter === 'skill' ||
data.component_filter
) {
return this.searchMarketplacePlugins(
query,
data.page,
data.page_size,
data.sort_by,
data.sort_order,
data.component_filter,
data.tags_filter,
data.component_filter ? 'plugin' : data.type_filter,
).catch((error) => {
if (data.type_filter === 'mcp' || data.type_filter === 'skill') {
return { plugins: [], total: 0 };
}
throw error;
});
}
const [pluginsResp, mcpsResp, skillsResp] = await Promise.all([
this.searchMarketplacePlugins(
query,
data.page,
data.page_size,
data.sort_by,
data.sort_order,
undefined,
data.tags_filter,
'plugin',
).catch(() => ({ plugins: [], total: 0 })),
this.searchMarketplacePlugins(
query,
data.page,
data.page_size,
data.sort_by,
data.sort_order,
undefined,
data.tags_filter,
'mcp',
).catch(() => ({ plugins: [], total: 0 })),
this.searchMarketplacePlugins(
query,
data.page,
data.page_size,
data.sort_by,
data.sort_order,
undefined,
data.tags_filter,
'skill',
).catch(() => ({ plugins: [], total: 0 })),
]);
return {
plugins: [
...(pluginsResp.plugins || []),
...(mcpsResp.plugins || []),
...(skillsResp.plugins || []),
],
total:
(pluginsResp.total || 0) +
(mcpsResp.total || 0) +
(skillsResp.total || 0),
};
}
public getPluginDetail(
author: string,
pluginName: string,
@@ -77,6 +222,14 @@ export class CloudServiceClient extends BaseHttpClient {
return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${name}/resources/icon`;
}
public getMCPMarketplaceIconURL(author: string, name: string): string {
return `${this.baseURL}/api/v1/marketplace/mcps/${author}/${name}/resources/icon`;
}
public getSkillMarketplaceIconURL(author: string, name: string): string {
return `${this.baseURL}/api/v1/marketplace/skills/${author}/${name}/resources/icon`;
}
public getPluginAssetURL(
author: string,
pluginName: string,

View File

@@ -8,7 +8,7 @@ HTTP Client 已经重构为更清晰的架构,将通用方法与业务逻辑
- **BaseHttpClient.ts** - 基础 HTTP 客户端类,包含所有通用的 HTTP 方法和拦截器配置
- **BackendClient.ts** - 后端服务客户端,处理与后端 API 的所有交互
- **CloudServiceClient.ts** - 云服务客户端,处理与 cloud service 的交互(如插件市场)
- **CloudServiceClient.ts** - 云服务客户端,处理与 cloud service 的交互(如扩展市场)
- **index.ts** - 主入口文件,管理客户端实例的创建和导出
- **HttpClient.ts** - 仅用于向后兼容的文件(已废弃)

View File

@@ -90,12 +90,17 @@ export const getCloudServiceClientSync = (): CloudServiceClient => {
* 手动初始化系统信息
* 可以在应用启动时调用此方法预先获取系统信息
*/
export const initializeSystemInfo = async (): Promise<void> => {
export const initializeSystemInfo = async (options?: {
throwOnError?: boolean;
}): Promise<void> => {
try {
Object.assign(systemInfo, await backendClient.getSystemInfo());
cloudServiceClient.updateBaseURL(systemInfo.cloud_service_url);
} catch (error) {
console.error('Failed to initialize system info:', error);
if (options?.throwOnError) {
throw error;
}
}
};

View File

@@ -22,7 +22,14 @@ import {
import { useEffect, useState } from 'react';
import { httpClient, initializeUserInfo } from '@/app/infra/http';
import { useNavigate } from 'react-router-dom';
import { Mail, Lock, Loader2, AlertCircle, RefreshCw } from 'lucide-react';
import {
Mail,
Lock,
Loader2,
AlertCircle,
RefreshCw,
Layers,
} from 'lucide-react';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -75,9 +82,18 @@ export default function Login() {
// Also check if already logged in
checkIfAlreadyLoggedIn();
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : t('common.loginLoadError');
setLoadError(errorMessage);
let detail = '';
if (err instanceof Error) {
detail = err.message;
} else if (
err &&
typeof err === 'object' &&
'msg' in err &&
typeof (err as Record<string, unknown>).msg === 'string'
) {
detail = (err as Record<string, unknown>).msg as string;
}
setLoadError(detail || t('common.loginLoadError'));
setLoading(false);
}
}
@@ -146,8 +162,8 @@ export default function Login() {
if (loadError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
<CardHeader>
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
<CardHeader className="pb-2">
<div className="flex justify-between items-center mb-6">
<ThemeToggle />
<LanguageSelector />
@@ -161,20 +177,25 @@ export default function Login() {
{t('common.welcome')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col items-center gap-3 py-4">
<AlertCircle className="h-10 w-10 text-destructive" />
<p className="text-sm text-center text-muted-foreground">
<CardContent>
<div className="flex flex-col items-center gap-4 rounded-lg border border-destructive/20 bg-destructive/5 p-5">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5 shrink-0" />
<span className="text-sm font-medium">
{t('common.loginLoadError')}
</span>
</div>
<p className="text-sm text-center text-muted-foreground leading-relaxed">
{t('common.loginLoadErrorDesc')}
</p>
<code className="text-xs bg-muted px-3 py-2 rounded max-w-full overflow-x-auto block text-center text-muted-foreground">
<code className="text-xs bg-muted/80 px-3 py-2 rounded-md max-w-full overflow-x-auto block text-center text-muted-foreground/80 break-all">
{loadError}
</code>
<Button
onClick={handleRetry}
disabled={retrying}
variant="outline"
className="mt-2 cursor-pointer"
className="w-full cursor-pointer"
>
{retrying ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -228,34 +249,7 @@ export default function Login() {
{spaceLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<svg
className="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2L2 7L12 12L22 7L12 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 17L12 22L22 17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 12L12 17L22 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<Layers className="mr-2 h-4 w-4" />
)}
{t('common.loginWithSpace')}
</Button>
@@ -348,6 +342,15 @@ export default function Login() {
<p className="text-xs text-center text-muted-foreground">
{t('common.agreementNotice')}{' '}
<a
href="https://langbot.app/terms"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
{t('common.termsOfService')}
</a>
{'、'}
<a
href="https://langbot.app/privacy"
target="_blank"

View File

@@ -22,7 +22,7 @@ import {
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useNavigate } from 'react-router-dom';
import { Mail, Lock, Loader2, Info } from 'lucide-react';
import { Mail, Lock, Loader2, Info, Layers } from 'lucide-react';
import {
Popover,
PopoverContent,
@@ -138,34 +138,7 @@ export default function Register() {
{spaceLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<svg
className="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2L2 7L12 12L22 7L12 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 17L12 22L22 17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 12L12 17L22 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<Layers className="mr-2 h-4 w-4" />
)}
{t('register.initWithSpace')}
</Button>

View File

@@ -0,0 +1,96 @@
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { AlertCircle, Home, Loader2, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { initializeSystemInfo, systemInfo } from '@/app/infra/http';
const RETURN_TO_STORAGE_KEY = 'langbot_backend_unavailable_return_to';
type BackendUnavailableLocationState = {
from?: string;
};
export default function BackendUnavailablePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [checking, setChecking] = useState(false);
const [retryError, setRetryError] = useState<string | null>(null);
async function handleRetry() {
setChecking(true);
setRetryError(null);
try {
await initializeSystemInfo({ throwOnError: true });
const state = location.state as BackendUnavailableLocationState | null;
const storedReturnTo = sessionStorage.getItem(RETURN_TO_STORAGE_KEY);
const returnTo = state?.from || storedReturnTo || '/home';
sessionStorage.removeItem(RETURN_TO_STORAGE_KEY);
if (systemInfo.wizard_status === 'none') {
navigate('/wizard', { replace: true });
return;
}
navigate(returnTo === '/backend-unavailable' ? '/home' : returnTo, {
replace: true,
});
} catch (error) {
setRetryError(
error instanceof Error ? error.message : t('errorPage.retryFailed'),
);
} finally {
setChecking(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="mx-auto flex max-w-md flex-col items-center text-center">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<p className="mb-2 text-sm font-medium text-destructive">
{t('errorPage.backendUnavailableStatus')}
</p>
<h1 className="text-2xl font-semibold text-foreground">
{t('common.loginLoadError')}
</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{t('common.loginLoadErrorDesc')}
</p>
{retryError ? (
<p className="mt-4 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{t('errorPage.retryFailed')}
</p>
) : null}
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Button
variant="outline"
className="gap-2"
onClick={() => navigate('/login')}
>
<Home className="h-4 w-4" />
{t('errorPage.backToLogin')}
</Button>
<Button className="gap-2" onClick={handleRetry} disabled={checking}>
{checking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{checking ? t('errorPage.retrying') : t('common.retry')}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import {
useRouteError,
isRouteErrorResponse,
useNavigate,
} from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { AlertCircle } from 'lucide-react';
export default function ErrorPage() {
const error = useRouteError();
const navigate = useNavigate();
const { t } = useTranslation();
let status = 500;
let title = t('errorPage.unexpectedError');
let description = t('errorPage.unexpectedErrorDescription');
if (isRouteErrorResponse(error)) {
status = error.status;
if (status === 404) {
title = t('errorPage.notFound');
description = t('errorPage.notFoundDescription');
} else {
description = error.statusText || description;
}
} else if (error instanceof Error) {
description = error.message;
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="mx-auto flex max-w-md flex-col items-center text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 mb-6">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<p className="text-5xl font-bold tracking-tight text-foreground mb-2">
{status}
</p>
<h1 className="text-xl font-semibold text-foreground mt-2">{title}</h1>
<p className="mt-3 text-sm text-muted-foreground leading-relaxed">
{description}
</p>
<div className="mt-8 flex gap-3">
<Button variant="outline" onClick={() => navigate(-1)}>
{t('errorPage.goBack')}
</Button>
<Button onClick={() => navigate('/home/monitoring')}>
{t('errorPage.backToHome')}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -78,7 +78,7 @@ function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn('grid gap-2', className)}
className={cn('grid min-w-0 gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
@@ -128,7 +128,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
className={cn('text-muted-foreground text-sm break-words', className)}
{...props}
/>
);
@@ -146,7 +146,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
className={cn('text-destructive text-sm break-words', className)}
{...props}
>
{body}

View File

@@ -374,9 +374,13 @@ function SidebarSeparator({
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
@@ -386,7 +390,8 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
{...props}
/>
);
}
});
SidebarContent.displayName = 'SidebarContent';
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (

View File

@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex min-h-16 w-full min-w-0 max-w-full resize-y overflow-x-hidden rounded-md border bg-transparent px-3 py-2 text-base break-words shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}

View File

@@ -2,12 +2,16 @@ const enUS = {
sidebar: {
home: 'Home',
extensions: 'Extensions',
installedPlugins: 'Installed Plugins',
pluginMarket: 'Marketplace',
installedPlugins: 'Installed Extensions',
pluginMarket: 'Extension Market',
mcpServers: 'MCP Servers',
addExtension: 'Add Extension',
pluginPages: 'Plugin Pages',
pluginPagesTooltip: 'Visual pages provided by installed plugins',
quickStart: 'Quick Start',
scrollToBottom: 'Scroll to bottom',
editionCommunity: 'Community',
editionCloud: 'Cloud',
},
common: {
login: 'Login',
@@ -38,6 +42,7 @@ const enUS = {
delete: 'Delete',
add: 'Add',
select: 'Select',
skill: 'Skill',
cancel: 'Cancel',
submit: 'Submit',
error: 'Error',
@@ -66,6 +71,7 @@ const enUS = {
test: 'Test',
forgotPassword: 'Forgot Password?',
agreementNotice: 'By continuing, you agree to our',
termsOfService: 'Terms of Service',
privacyPolicy: 'Privacy Policy',
and: 'and',
dataCollectionPolicy: 'Data Collection Policy',
@@ -429,10 +435,11 @@ const enUS = {
createPlugin: 'Create Plugin',
editPlugin: 'Edit Plugin',
installed: 'Installed',
marketplace: 'Marketplace',
marketplace: 'Extension Market',
arrange: 'Sort Plugins',
install: 'Install',
installPlugin: 'Install Plugin',
newPlugin: 'New Plugin',
onlySupportGithub: 'Currently only supports installation from GitHub',
enterGithubLink: 'Enter GitHub link of the plugin',
installing: 'Installing plugin...',
@@ -447,6 +454,9 @@ const enUS = {
loading: 'Loading...',
getPluginListError: 'Failed to get plugin list:',
noPluginInstalled: 'No plugins installed',
noExtensionInstalled: 'No extensions installed',
loadingExtensions: 'Loading extensions...',
groupByType: 'Group by format',
pluginConfig: 'Plugin Configuration',
pluginSort: 'Plugin Sort',
pluginSortDescription:
@@ -472,6 +482,19 @@ const enUS = {
noDebugKey: '(Not Set)',
debugKeyDisabled:
'Debug key is not set, plugin debugging does not require authentication',
boxStatusTitle: 'Box Runtime',
boxStatus: 'Status',
boxConnected: 'Connected',
boxUnavailable: 'Unavailable',
boxBackend: 'Backend',
boxProfile: 'Profile',
boxSandboxes: 'Sandboxes',
boxErrors: 'Errors',
boxSessionImage: 'Image',
boxSessionBackend: 'Backend',
boxSessionResources: 'Resources',
boxSessionNetwork: 'Network',
boxStatusLoadFailed: 'Failed to load Box status',
failedToGetDebugInfo: 'Failed to get debug information',
copiedToClipboard: 'Copied to clipboard',
deleting: 'Deleting...',
@@ -488,6 +511,8 @@ const enUS = {
close: 'Close',
deleteConfirm: 'Delete Confirmation',
deleteSuccess: 'Delete successful',
dangerZone: 'Danger Zone',
dangerZoneDescription: 'Irreversible and destructive actions',
modifyFailed: 'Modify failed: ',
componentName: {
Tool: 'Tool',
@@ -500,14 +525,31 @@ const enUS = {
uploadLocal: 'Upload Local',
debugging: 'Debugging',
uploadLocalPlugin: 'Upload Local Plugin',
localPreview: {
title: 'Preview Local Plugin Package',
unpacking: 'Unpacking package preview...',
unpackComplete: 'Package preview ready',
failed: 'Failed to preview package',
pluginInfo: 'Plugin Info',
packageInfo: 'Package Info',
name: 'Name',
author: 'Author',
version: 'Version',
fileCount: 'Files',
dependencies: 'Dependencies',
components: 'Components',
ready: 'The plugin package is unpacked. Confirm to start installation.',
},
uploadPluginOnly: 'Only .lbpkg files are supported',
dragToUpload: 'Drag plugin file here to upload',
unsupportedFileType:
'Unsupported file type, only .lbpkg and .zip files are supported',
'Unsupported file type, only .lbpkg files are supported',
uploadingPlugin: 'Uploading plugin...',
uploadSuccess: 'Upload successful',
uploadFailed: 'Upload failed',
selectFileToUpload: 'Select plugin file to upload',
askConfirm: 'Are you sure to install plugin "{{name}}" ({{version}})?',
askConfirmNoVersion: 'Are you sure to install plugin "{{name}}"?',
fromGithub: 'From GitHub',
fromLocal: 'From Local',
fromMarketplace: 'From Marketplace',
@@ -558,22 +600,28 @@ const enUS = {
assetSize: 'Size: {{size}}',
confirmInstall: 'Confirm Install',
installFromGithubDesc: 'Install plugin from GitHub Release',
goToMarketplace: 'Go to Marketplace',
goToMarketplace: 'Go to Extension Market',
installProgress: {
title: 'Installing {{name}}',
titleGeneric: 'Plugin Installation',
titleGeneric: 'Extension Installation',
titlePlugin: 'Installing Plugin {{name}}',
titleMCP: 'Installing MCP Server {{name}}',
titleSkill: 'Installing Skill {{name}}',
overallProgress: 'Overall Progress',
downloading: 'Downloading Plugin',
downloading: 'Downloading',
installingDeps: 'Installing Dependencies',
initializing: 'Initializing Settings',
launching: 'Launching Plugin',
launching: 'Launching',
completed: 'Completed',
failed: 'Failed',
downloadSize: 'Package size: {{size}}',
depsInfo: '{{count}} dependencies to install',
depsProgress:
'{{installed}}/{{total}} installed · {{remaining}} remaining',
installComplete: 'Plugin installed successfully',
installComplete: 'Installation successful',
installCompletePlugin: 'Plugin installed successfully',
installCompleteMCP: 'MCP Server installed successfully',
installCompleteSkill: 'Skill installed successfully',
dismiss: 'Dismiss',
background: 'Run in Background',
taskQueue: 'Install Tasks',
@@ -591,6 +639,7 @@ const enUS = {
loading: 'Loading...',
allLoaded: 'All plugins displayed',
install: 'Install',
installCard: 'Install {{name}}',
installConfirm:
'Are you sure you want to install plugin "{{name}}" ({{version}})?',
downloadComplete: 'Plugin "{{name}}" download completed',
@@ -623,13 +672,37 @@ const enUS = {
markAsRead: 'Mark as Read',
markAsReadSuccess: 'Marked as read',
markAsReadFailed: 'Mark as read failed',
filterByComponent: 'Component',
filterByComponent: 'Plugin Component',
filterByComponentHint:
'The capability types a plugin provides — Tool, Command, EventListener, etc. — used to extend LangBot in various ways. Filter by component to show only plugins offering that capability.',
allComponents: 'All Components',
componentName: {
Tool: 'Tool',
EventListener: 'Event Listener',
Command: 'Command',
KnowledgeEngine: 'Knowledge Engine',
Parser: 'Parser',
Page: 'Page',
},
filterByType: 'Type',
allTypes: 'All Types',
typePlugin: 'Plugin',
typeMCP: 'MCP',
typeSkill: 'Skill',
requestPlugin: 'Request Plugin',
viewDetails: 'View Details',
deprecated: 'Deprecated',
deprecatedTooltip:
'Please install the corresponding Knowledge Engine plugin.',
filters: {
allFormats: 'All formats',
more: 'Filters',
advancedTitle: 'Advanced filters',
advancedDescription:
'Most users do not need these. Use them only when you know the extension format you want.',
technicalType: 'Extension format',
},
allExtensions: 'All Extensions',
tags: {
filterByTags: 'Filter by Tags',
selected: 'selected',
@@ -641,6 +714,7 @@ const enUS = {
mcp: {
title: 'MCP',
createServer: 'Add MCP Server',
addMCPServer: 'Add MCP Server',
editServer: 'Edit MCP Server',
deleteServer: 'Delete MCP Server',
confirmDeleteServer: 'Are you sure you want to delete this MCP server?',
@@ -678,6 +752,15 @@ const enUS = {
connectionSuccess: 'Connection successful',
connectionFailed: 'Connection failed, please check URL',
connectionFailedStatus: 'Connection Failed',
boxDisabledStdioRefused:
'Stdio MCP servers require the Box sandbox, which is disabled in config (box.enabled = false).',
boxUnavailableStdioRefused:
'Stdio MCP servers require the Box sandbox, which is currently unreachable.',
boxStdioRefusedSuggestion:
'Enable Box (box.enabled = true) and ensure the runtime is healthy, or switch this server to http/sse mode.',
boxRequired: 'requires Box',
stdioBlockedByBoxToast:
'Stdio MCP cannot be saved while the Box sandbox is disabled or unreachable. Enable Box or pick http/sse.',
toolsFound: 'tools',
unknownError: 'Unknown error',
noToolsFound: 'No tools found',
@@ -696,6 +779,8 @@ const enUS = {
loadFailed: 'Load failed',
modifyFailed: 'Modify failed: ',
toolCount: 'Tools: {{count}}',
parameterCount: 'Parameters: {{count}}',
noParameters: 'No parameters',
statusConnected: 'Connected',
statusDisconnected: 'Disconnected',
statusError: 'Connection Error',
@@ -796,8 +881,15 @@ const enUS = {
selectAll: 'Select All',
enableAllPlugins: 'Enable All Plugins',
enableAllMCPServers: 'Enable All MCP Servers',
enableAllSkills: 'Enable All Skills',
allPluginsEnabled: 'All plugins enabled',
allMCPServersEnabled: 'All MCP servers enabled',
allSkillsEnabled: 'All skills enabled',
skillsTitle: 'Skills',
noSkillsSelected: 'No skills selected',
addSkill: 'Add Skill',
selectSkills: 'Select Skills',
noSkillsAvailable: 'No skills available',
},
debugDialog: {
title: 'Pipeline Chat',
@@ -902,7 +994,7 @@ const enUS = {
builtInParser: 'Provided by Knowledge engine',
noParserAvailable:
'No parser supports this file type. Please install a parser plugin that can handle this format.',
installParserHint: 'Browse parser plugins in Marketplace →',
installParserHint: 'Browse parser plugins in Extension Market →',
confirmUpload: 'Upload',
cancelUpload: 'Cancel',
},
@@ -1230,6 +1322,25 @@ const enUS = {
sessions: 'Sessions',
feedback: 'User Feedback',
},
systemStatus: 'System Status',
pluginRuntime: 'Plugin Runtime',
boxRuntime: 'Box Runtime',
connected: 'Connected',
disconnected: 'Disconnected',
disabled: 'Disabled',
statusDetail: 'Status',
pluginDisabled: 'Plugin system is disabled',
boxDisabled:
'Box sandbox is disabled in config — sandbox tools, skill add/edit, and stdio MCP are unavailable',
boxUnavailable:
'Box sandbox is unavailable — sandbox tools, skill add/edit, and stdio MCP are unavailable',
boxRequiredHint:
'This feature requires the Box runtime. Enable it in config (box.enabled = true) and ensure the runtime is healthy.',
boxBackend: 'Backend',
boxProfile: 'Profile',
boxSandboxes: 'Sandboxes',
boxSessionCreated: 'Created',
boxSessionLastUsed: 'Last used',
},
storageAnalysis: {
title: 'Storage Analysis',
@@ -1272,7 +1383,100 @@ const enUS = {
maxPipelinesReached:
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
maxExtensionsReached:
'Maximum number of extensions ({{max}}) reached. Please remove an existing MCP server or plugin before adding a new one.',
'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.',
},
skills: {
title: 'Skills',
description:
'Create and manage skills that can be activated during conversations',
createSkill: 'Create Skill',
createSkillDescription:
'Import a local directory or create one by filling in details',
editSkill: 'Edit Skill',
getSkillListError: 'Failed to get skill list: ',
skillName: 'Skill Name',
displayName: 'Skill Name',
displayNamePlaceholder: 'Display name (supports any language)',
skillSlug: 'Directory Name',
skillSlugPlaceholder: 'english-name-only',
skillSlugHelp:
'Used as the skill directory name. Only letters, numbers, hyphens and underscores.',
skillDescription: 'Skill Description',
skillInstructions: 'Instructions',
saveSuccess: 'Saved successfully',
saveError: 'Save failed: ',
createSuccess: 'Created successfully',
createError: 'Creation failed: ',
deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ',
deleteConfirmation: 'Are you sure you want to delete this skill?',
delete: 'Delete Skill',
skillNameRequired: 'Skill name cannot be empty',
skillDescriptionRequired: 'Skill description cannot be empty',
packageRootRequired: 'Package root path cannot be empty',
scan: 'Scan',
scanSuccess: 'Directory scanned successfully',
scanError: 'Failed to scan directory: ',
noSkills: 'No skills configured',
preview: 'Preview',
previewInstructions: 'SKILL.md Content Preview',
instructionsPlaceholder: 'Enter skill instructions in Markdown format...',
descriptionPlaceholder:
'A brief description of what this skill does (shown to the LLM)',
packageRoot: 'Package Directory',
packageRootHelp:
'Optional. Only needed when importing an existing skill directory. Leave empty for new skills. Scanning checks the current directory and subdirectories up to 2 levels deep.',
importLocalDirectory: 'Import Local Skill Directory',
chooseSkillDirectory: 'Choose SKILL.md Directory',
chooseAnotherDirectory: 'Choose Another Directory',
importingDirectory: 'Previewing...',
clearDirectoryPreview: 'Clear Selected Directory',
noSkillMdInDirectory: 'No SKILL.md was found in the selected directory',
multipleSkillMdInDirectory:
'The selected directory contains multiple SKILL.md files. Choose a single skill directory directly.',
importDirectoryError: 'Failed to import directory: ',
advancedSettings: 'Advanced Settings',
searchSkills: 'Search skills...',
selectSkills: 'Select Skills',
addSkill: 'Add Skill',
builtin: 'Built-in',
importFromGithub: 'Install Skill from GitHub',
createManually: 'Create Manually',
uploadZip: 'Upload ZIP Package',
uploadZipOnly: 'Only .zip skill packages are supported',
installSuccess: 'Skill installed successfully',
installError: 'Failed to install skill: ',
enterRepoUrl: 'Enter GitHub repository URL',
repoUrlPlaceholder: 'e.g., https://github.com/owner/repo',
fetchingReleases: 'Fetching releases...',
selectRelease: 'Select Release',
noReleasesFound: 'No releases found',
fetchReleasesError: 'Failed to fetch releases: ',
selectAsset: 'Select file to install',
sourceArchive: 'Source code (zip)',
noAssetsFound: 'No installable files available in this release',
fetchAssetsError: 'Failed to fetch assets: ',
backToReleases: 'Back to releases',
backToRepoUrl: 'Back to repository URL',
backToAssets: 'Back to assets',
releaseTag: 'Tag: {{tag}}',
publishedAt: 'Published at: {{date}}',
prerelease: 'Pre-release',
assetSize: 'Size: {{size}}',
confirmInstall: 'Confirm Install',
installing: 'Installing skill...',
loading: 'Loading...',
previewLoadError: 'Failed to load preview',
selectFromSidebar: 'Select a skill from the sidebar',
dangerZone: 'Danger Zone',
dangerZoneDescription: 'Irreversible and destructive actions',
files: 'Files',
noFiles: 'No files found',
loadFilesError: 'Failed to load files: ',
readFileError: 'Failed to read file: ',
saveFile: 'Save File',
saveFileSuccess: 'File saved successfully',
saveFileError: 'Failed to save file: ',
},
wizard: {
sidebarDescription: 'Create a bot with guided steps',
@@ -1338,6 +1542,52 @@ const enUS = {
backToWorkbench: 'Back to Workbench',
},
},
addExtension: {
installTitle: 'Install {{type}}',
installConfirm: 'Install {{type}} "{{name}}"?',
installInfoType: 'Type',
installInfoId: 'ID',
installInfoVersion: 'Version',
installSuccess: 'Installed successfully',
installStage: {
mcpInstalling: 'Adding and connecting the MCP server…',
skillInstalling: 'Installing the skill…',
installed: 'Done',
},
manualAdd: 'Manual Add',
uploadExtension: 'Drag & drop or click to upload',
uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files',
orContinueWith: 'or choose an action below',
addMCPServerHint: 'Connect an MCP tool server extension',
installFromGithub: 'Install from GitHub',
installFromGithubHint: 'Plugin package or Skill (SKILL.md)',
githubUrlHelp: 'Paste a GitHub URL',
githubUrlTooltip:
'Plugin: paste a repository, Release, or Tag URL. Skill: paste the SKILL.md page URL inside the skill directory.',
githubUrlPlaceholder: 'GitHub repository, Release, or SKILL.md link',
githubUrlRequired: 'Enter a GitHub URL',
previewSkill: 'Preview Skill',
noSkillPreviewFound: 'No importable Skill found',
createSkill: 'Create New Skill',
createSkillHint: 'Import from a local directory or create manually',
unsupportedFileType:
'Unsupported file type. Only .zip and .lbpkg files are supported',
},
errorPage: {
unexpectedError: 'Something went wrong',
unexpectedErrorDescription:
'An unexpected error occurred. Please try again later.',
notFound: 'Page not found',
notFoundDescription:
'The page you are looking for does not exist or has been moved.',
backendUnavailableStatus: 'Backend unavailable',
goBack: 'Go Back',
backToHome: 'Back to Home',
backToLogin: 'Back to Login',
retrying: 'Retrying',
retryFailed:
'Still cannot connect to the backend. Start the service and try again.',
},
feishu: {
createApp: 'One-Click Create Feishu App',
scanQRCode:

View File

@@ -5,10 +5,14 @@ const esES = {
installedPlugins: 'Plugins instalados',
pluginMarket: 'Tienda',
mcpServers: 'Servidores MCP',
addExtension: 'Añadir extensión',
pluginPages: 'Páginas de plugins',
pluginPagesTooltip:
'Páginas visuales proporcionadas por los plugins instalados',
quickStart: 'Inicio rápido',
scrollToBottom: 'Desplazar al final',
editionCommunity: 'Comunidad',
editionCloud: 'Cloud',
},
common: {
login: 'Iniciar sesión',
@@ -41,6 +45,7 @@ const esES = {
delete: 'Eliminar',
add: 'Añadir',
select: 'Seleccionar',
skill: 'Habilidad',
cancel: 'Cancelar',
submit: 'Enviar',
error: 'Error',
@@ -68,7 +73,8 @@ const esES = {
copyFailed: 'Error al copiar',
test: 'Probar',
forgotPassword: '¿Olvidaste tu contraseña?',
agreementNotice: 'Al continuar, aceptas nuestra',
agreementNotice: 'Al continuar, aceptas nuestros',
termsOfService: 'Términos de servicio',
privacyPolicy: 'Política de privacidad',
and: 'y',
dataCollectionPolicy: 'Política de recopilación de datos',
@@ -237,7 +243,6 @@ const esES = {
noLocalModels:
'No hay modelos locales. Haz clic en Crear para añadir un modelo.',
providerCount: '{{count}} proveedores',
// New keys for provider-based structure
addModel: 'Añadir modelo',
manualAdd: 'Manual',
scanAdd: 'Escanear',
@@ -446,6 +451,7 @@ const esES = {
arrange: 'Ordenar plugins',
install: 'Instalar',
installPlugin: 'Instalar plugin',
newPlugin: 'Nuevo Plugin',
onlySupportGithub: 'Actualmente solo se admite la instalación desde GitHub',
enterGithubLink: 'Introduce el enlace de GitHub del plugin',
installing: 'Instalando plugin...',
@@ -460,6 +466,9 @@ const esES = {
loading: 'Cargando...',
getPluginListError: 'Error al obtener la lista de plugins:',
noPluginInstalled: 'No hay plugins instalados',
noExtensionInstalled: 'No hay extensiones instaladas',
loadingExtensions: 'Cargando extensiones...',
groupByType: 'Agrupar por formato',
pluginConfig: 'Configuración del plugin',
pluginSort: 'Orden de plugins',
pluginSortDescription:
@@ -485,6 +494,19 @@ const esES = {
noDebugKey: '(No establecida)',
debugKeyDisabled:
'La clave de depuración no está configurada, la depuración del plugin no requiere autenticación',
boxStatusTitle: 'Box Runtime',
boxStatus: 'Estado',
boxConnected: 'Conectado',
boxUnavailable: 'No disponible',
boxBackend: 'Backend',
boxProfile: 'Perfil',
boxSandboxes: 'Sandboxes',
boxErrors: 'Errores',
boxSessionImage: 'Imagen',
boxSessionBackend: 'Backend',
boxSessionResources: 'Recursos',
boxSessionNetwork: 'Red',
boxStatusLoadFailed: 'Error al cargar el estado de Box',
failedToGetDebugInfo: 'Error al obtener la información de depuración',
copiedToClipboard: 'Copiado al portapapeles',
deleting: 'Eliminando...',
@@ -501,6 +523,8 @@ const esES = {
close: 'Cerrar',
deleteConfirm: 'Confirmación de eliminación',
deleteSuccess: 'Eliminación exitosa',
dangerZone: 'Zona de peligro',
dangerZoneDescription: 'Acciones irreversibles y destructivas',
modifyFailed: 'Error al modificar: ',
componentName: {
Tool: 'Herramienta',
@@ -513,6 +537,22 @@ const esES = {
uploadLocal: 'Subir local',
debugging: 'Depuración',
uploadLocalPlugin: 'Subir plugin local',
localPreview: {
title: 'Previsualizar paquete de plugin local',
unpacking: 'Descomprimiendo vista previa del paquete...',
unpackComplete: 'Vista previa del paquete lista',
failed: 'No se pudo previsualizar el paquete',
pluginInfo: 'Información del plugin',
packageInfo: 'Información del paquete',
name: 'Nombre',
author: 'Autor',
version: 'Versión',
fileCount: 'Archivos',
dependencies: 'Dependencias',
components: 'Componentes',
ready:
'El paquete del plugin está descomprimido. Confirma para iniciar la instalación.',
},
dragToUpload: 'Arrastra el archivo del plugin aquí para subirlo',
unsupportedFileType:
'Tipo de archivo no soportado, solo se admiten archivos .lbpkg y .zip',
@@ -521,6 +561,7 @@ const esES = {
uploadFailed: 'Error en la subida',
selectFileToUpload: 'Selecciona el archivo del plugin para subir',
askConfirm: '¿Estás seguro de instalar el plugin "{{name}}" ({{version}})?',
askConfirmNoVersion: '¿Estás seguro de instalar el plugin "{{name}}"?',
fromGithub: 'Desde GitHub',
fromLocal: 'Desde local',
fromMarketplace: 'Desde la tienda',
@@ -592,7 +633,14 @@ const esES = {
taskQueue: 'Tareas de instalación',
clearCompleted: 'Limpiar completados',
noTasks: 'No hay tareas de instalación',
titlePlugin: 'Instalando plugin {{name}}',
titleMCP: 'Instalando servidor MCP {{name}}',
titleSkill: 'Instalando skill {{name}}',
installCompletePlugin: 'Plugin instalado correctamente',
installCompleteMCP: 'Servidor MCP instalado correctamente',
installCompleteSkill: 'Skill instalada correctamente',
},
uploadPluginOnly: 'Solo se admiten paquetes de plugin .lbpkg',
},
market: {
searchPlaceholder: 'Buscar plugins...',
@@ -637,13 +685,36 @@ const esES = {
markAsRead: 'Marcar como leído',
markAsReadSuccess: 'Marcado como leído',
markAsReadFailed: 'Error al marcar como leído',
filterByComponent: 'Componente',
filterByComponent: 'Componente del plugin',
filterByComponentHint:
'Los tipos de capacidad que ofrece un plugin: herramienta (Tool), comando (Command), escucha de eventos (EventListener), etc., usados para ampliar las capacidades de LangBot. Filtra por componente para ver solo los plugins que ofrecen esa capacidad.',
allComponents: 'Todos los componentes',
componentName: {
Tool: 'Herramienta',
EventListener: 'Listener de eventos',
Command: 'Comando',
KnowledgeEngine: 'Motor de conocimiento',
Parser: 'Analizador',
Page: 'Página',
},
filterByType: 'Tipo',
allTypes: 'Todos los tipos',
typePlugin: 'Plugin',
typeMCP: 'MCP',
typeSkill: 'Habilidad',
requestPlugin: 'Solicitar plugin',
viewDetails: 'Ver detalles',
deprecated: 'Obsoleto',
deprecatedTooltip:
'Por favor, instala el plugin de motor de conocimiento correspondiente.',
filters: {
allFormats: 'Todos los tipos',
more: 'Más',
advancedTitle: 'Filtros avanzados',
advancedDescription: 'Filtrar por tipo de extensión',
technicalType: 'Tipo técnico',
},
allExtensions: 'Todas las extensiones',
tags: {
filterByTags: 'Filtrar por etiquetas',
selected: 'seleccionadas',
@@ -651,10 +722,12 @@ const esES = {
clearAll: 'Borrar todo',
noTags: 'No hay etiquetas disponibles',
},
installCard: 'Instalar {{name}}',
},
mcp: {
title: 'MCP',
createServer: 'Añadir servidor MCP',
addMCPServer: 'Añadir servidor MCP',
editServer: 'Editar servidor MCP',
deleteServer: 'Eliminar servidor MCP',
confirmDeleteServer:
@@ -693,6 +766,15 @@ const esES = {
connectionSuccess: 'Conexión exitosa',
connectionFailed: 'Error de conexión, por favor verifica la URL',
connectionFailedStatus: 'Conexión fallida',
boxDisabledStdioRefused:
'Los servidores MCP en modo stdio requieren el sandbox de Box, desactivado en la configuración (box.enabled = false).',
boxUnavailableStdioRefused:
'Los servidores MCP en modo stdio requieren el sandbox de Box, actualmente no accesible.',
boxStdioRefusedSuggestion:
'Active Box (box.enabled = true) y asegúrese de que el runtime está conectado, o cambie este servidor a modo http/sse.',
boxRequired: 'requiere Box',
stdioBlockedByBoxToast:
'No se puede guardar el MCP en modo stdio mientras el sandbox de Box está desactivado o no disponible. Active Box o seleccione modo http/sse.',
toolsFound: 'herramientas',
unknownError: 'Error desconocido',
noToolsFound: 'No se encontraron herramientas',
@@ -711,6 +793,8 @@ const esES = {
loadFailed: 'Error al cargar',
modifyFailed: 'Error al modificar: ',
toolCount: 'Herramientas: {{count}}',
parameterCount: 'Parámetros: {{count}}',
noParameters: 'Sin parámetros',
statusConnected: 'Conectado',
statusDisconnected: 'Desconectado',
statusError: 'Error de conexión',
@@ -816,6 +900,13 @@ const esES = {
enableAllMCPServers: 'Activar todos los servidores MCP',
allPluginsEnabled: 'Todos los plugins activados',
allMCPServersEnabled: 'Todos los servidores MCP activados',
enableAllSkills: 'Activar todas las skills',
allSkillsEnabled: 'Todas las skills están activadas',
skillsTitle: 'Skills',
noSkillsSelected: 'No hay skills seleccionadas',
addSkill: 'Añadir skill',
selectSkills: 'Seleccionar skills',
noSkillsAvailable: 'No hay skills disponibles',
},
debugDialog: {
title: 'Chat del Pipeline',
@@ -1264,6 +1355,25 @@ const esES = {
sessions: 'Sesiones',
feedback: 'Comentarios de usuarios',
},
systemStatus: 'Estado del sistema',
pluginRuntime: 'Plugin Runtime',
boxRuntime: 'Box Runtime',
connected: 'Conectado',
disconnected: 'Desconectado',
disabled: 'Desactivado',
statusDetail: 'Estado',
pluginDisabled: 'El sistema de plugins está desactivado',
boxDisabled:
'El sandbox de Box está desactivado en la configuración — herramientas de sandbox, alta/edición de skills y MCP stdio no están disponibles',
boxUnavailable:
'El sandbox de Box no está disponible — herramientas de sandbox, alta/edición de skills y MCP stdio no están disponibles',
boxRequiredHint:
'Esta función requiere el runtime de Box. Actívelo en la configuración (box.enabled = true) y asegúrese de que el runtime está conectado.',
boxBackend: 'Backend',
boxProfile: 'Perfil',
boxSandboxes: 'Sandboxes',
boxSessionCreated: 'Creado',
boxSessionLastUsed: 'Último uso',
},
storageAnalysis: {
title: 'Análisis de almacenamiento',
@@ -1377,6 +1487,20 @@ const esES = {
backToWorkbench: 'Volver al panel de trabajo',
},
},
errorPage: {
unexpectedError: 'Algo salió mal',
unexpectedErrorDescription:
'Ocurrió un error inesperado. Por favor, inténtelo de nuevo más tarde.',
notFound: 'Página no encontrada',
notFoundDescription: 'La página que buscas no existe o ha sido movida.',
backendUnavailableStatus: 'Backend no disponible',
goBack: 'Volver',
backToHome: 'Ir al inicio',
backToLogin: 'Volver al inicio de sesión',
retrying: 'Reintentando',
retryFailed:
'Aún no se puede conectar con el backend. Inicia el servicio e inténtalo de nuevo.',
},
feishu: {
createApp: 'Crear aplicación de Feishu con un clic',
scanQRCode:
@@ -1430,6 +1554,132 @@ const esES = {
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
invalidPage: 'Página de plugin no válida',
},
skills: {
title: 'Skills',
description:
'Crea y gestiona skills que se pueden activar durante las conversaciones',
createSkill: 'Crear skill',
createSkillDescription:
'Importa un directorio local o crea una skill rellenando la información',
editSkill: 'Editar skill',
getSkillListError: 'Error al obtener la lista de skills: ',
skillName: 'Nombre de la skill',
displayName: 'Nombre de la skill',
displayNamePlaceholder: 'Nombre visible (admite cualquier idioma)',
skillSlug: 'Nombre del directorio',
skillSlugPlaceholder: 'english-name-only',
skillSlugHelp:
'Se usa como nombre del directorio de la skill. Solo letras, números, guiones y guiones bajos.',
skillDescription: 'Descripción de la skill',
skillInstructions: 'Instrucciones',
saveSuccess: 'Guardado correctamente',
saveError: 'Error al guardar: ',
createSuccess: 'Creado correctamente',
createError: 'Error al crear: ',
deleteSuccess: 'Eliminado correctamente',
deleteError: 'Error al eliminar: ',
deleteConfirmation: '¿Seguro que quieres eliminar esta skill?',
delete: 'Eliminar skill',
skillNameRequired: 'El nombre de la skill no puede estar vacío',
skillDescriptionRequired: 'La descripción de la skill no puede estar vacía',
packageRootRequired: 'La ruta raíz del paquete no puede estar vacía',
scan: 'Escanear',
scanSuccess: 'Directorio escaneado correctamente',
scanError: 'Error al escanear el directorio: ',
noSkills: 'No hay skills configuradas',
preview: 'Vista previa',
previewInstructions: 'Vista previa del contenido de SKILL.md',
instructionsPlaceholder:
'Introduce las instrucciones de la skill en formato Markdown...',
descriptionPlaceholder:
'Breve descripción de lo que hace esta skill (se muestra al LLM)',
packageRoot: 'Directorio del paquete',
packageRootHelp:
'Opcional. Solo se necesita al importar un directorio de skill existente. Déjalo vacío para skills nuevas. El escaneo revisa el directorio actual y subdirectorios hasta 2 niveles.',
importLocalDirectory: 'Importar directorio local de skill',
chooseSkillDirectory: 'Elegir directorio de SKILL.md',
chooseAnotherDirectory: 'Elegir otro directorio',
importingDirectory: 'Generando vista previa...',
clearDirectoryPreview: 'Borrar directorio seleccionado',
noSkillMdInDirectory:
'No se encontró SKILL.md en el directorio seleccionado',
multipleSkillMdInDirectory:
'El directorio seleccionado contiene varios SKILL.md. Elige directamente un único directorio de skill.',
importDirectoryError: 'Error al importar el directorio: ',
advancedSettings: 'Configuración avanzada',
searchSkills: 'Buscar skills...',
selectSkills: 'Seleccionar skills',
addSkill: 'Añadir skill',
builtin: 'Integrada',
importFromGithub: 'Instalar skill desde GitHub',
createManually: 'Crear manualmente',
uploadZip: 'Subir paquete ZIP',
uploadZipOnly: 'Solo se admiten paquetes de skill .zip',
installSuccess: 'Skill instalada correctamente',
installError: 'Error al instalar la skill: ',
enterRepoUrl: 'Introduce la URL del repositorio de GitHub',
repoUrlPlaceholder: 'p. ej., https://github.com/owner/repo',
fetchingReleases: 'Obteniendo releases...',
selectRelease: 'Seleccionar release',
noReleasesFound: 'No se encontraron releases',
fetchReleasesError: 'Error al obtener releases: ',
selectAsset: 'Selecciona el archivo para instalar',
sourceArchive: 'Código fuente (zip)',
noAssetsFound: 'No hay archivos instalables en esta release',
fetchAssetsError: 'Error al obtener archivos: ',
backToReleases: 'Volver a releases',
backToRepoUrl: 'Volver a la URL del repositorio',
backToAssets: 'Volver a archivos',
releaseTag: 'Etiqueta: {{tag}}',
publishedAt: 'Publicado el: {{date}}',
prerelease: 'Pre-release',
assetSize: 'Tamaño: {{size}}',
confirmInstall: 'Confirmar instalación',
installing: 'Instalando skill...',
loading: 'Cargando...',
previewLoadError: 'Error al cargar la vista previa',
selectFromSidebar: 'Selecciona una skill en la barra lateral',
dangerZone: 'Zona peligrosa',
dangerZoneDescription: 'Acciones irreversibles y destructivas',
files: 'Archivos',
noFiles: 'No se encontraron archivos',
loadFilesError: 'Error al cargar archivos: ',
readFileError: 'Error al leer el archivo: ',
saveFile: 'Guardar archivo',
saveFileSuccess: 'Archivo guardado correctamente',
saveFileError: 'Error al guardar el archivo: ',
},
addExtension: {
installTitle: 'Instalar {{type}}',
installConfirm: '¿Instalar {{type}} "{{name}}"?',
installInfoType: 'Tipo',
installInfoId: 'ID',
installInfoVersion: 'Versión',
installSuccess: 'Instalado correctamente',
installStage: {
mcpInstalling: 'Añadiendo y conectando el servidor MCP…',
skillInstalling: 'Instalando la skill…',
installed: 'Listo',
},
manualAdd: 'Añadir manualmente',
uploadExtension: 'Arrastra y suelta o haz clic para subir',
uploadHint: 'Admite archivos .zip (skills) y .lbpkg (plugins)',
orContinueWith: 'o elige una acción abajo',
addMCPServerHint: 'Conectar una extensión de servidor de herramientas MCP',
installFromGithub: 'Instalar desde GitHub',
installFromGithubHint: 'Paquete de plugin o skill (SKILL.md)',
githubUrlHelp: 'Pega una URL de GitHub',
githubUrlTooltip:
'Plugin: pega una URL de repositorio, Release o Tag. Skill: pega la URL de la página SKILL.md dentro del directorio de la skill.',
githubUrlPlaceholder: 'Repositorio de GitHub, Release o enlace SKILL.md',
githubUrlRequired: 'Introduce una URL de GitHub',
previewSkill: 'Previsualizar skill',
noSkillPreviewFound: 'No se encontró ninguna skill importable',
createSkill: 'Crear nueva skill',
createSkillHint: 'Importar desde un directorio local o crear manualmente',
unsupportedFileType:
'Tipo de archivo no admitido. Solo se admiten archivos .zip y .lbpkg',
},
};
export default esES;

View File

@@ -1,13 +1,17 @@
const jaJP = {
const jaJP = {
sidebar: {
home: 'ホーム',
extensions: '拡張機能',
installedPlugins: 'インストール済みプラグイン',
pluginMarket: 'プラグインマーケット',
mcpServers: 'MCPサーバー',
addExtension: '拡張機能を追加',
pluginPages: 'プラグインページ',
pluginPagesTooltip: 'インストール済みプラグインが提供するビジュアルページ',
quickStart: 'クイックスタート',
scrollToBottom: '一番下までスクロール',
editionCommunity: 'コミュニティ版',
editionCloud: 'Cloud',
},
common: {
login: 'ログイン',
@@ -39,6 +43,7 @@
delete: '削除',
add: '追加',
select: '選択してください',
skill: 'スキル',
cancel: 'キャンセル',
submit: '送信',
error: 'エラー',
@@ -67,6 +72,7 @@
test: 'テスト',
forgotPassword: 'パスワードを忘れた?',
agreementNotice: '続行することで、以下に同意したものとみなされます:',
termsOfService: '利用規約',
privacyPolicy: 'プライバシーポリシー',
and: 'および',
dataCollectionPolicy: 'データ収集ポリシー',
@@ -438,6 +444,7 @@
arrange: '並び替え',
install: 'インストール',
installPlugin: 'プラグインをインストール',
newPlugin: '新規プラグイン',
onlySupportGithub: '現在はGitHubからのインストールのみサポートしています',
enterGithubLink: 'プラグインのGitHubリンクを入力してください',
installing: 'プラグインをインストール中...',
@@ -452,6 +459,9 @@
loading: '読み込み中...',
getPluginListError: 'プラグインリストの取得に失敗しました:',
noPluginInstalled: 'プラグインがインストールされていません',
noExtensionInstalled: '拡張機能がインストールされていません',
loadingExtensions: '拡張機能を読み込み中...',
groupByType: '形式でグループ化',
pluginConfig: 'プラグイン設定',
pluginSort: 'プラグインの並び替え',
pluginSortDescription:
@@ -477,6 +487,19 @@
noDebugKey: '(未設定)',
debugKeyDisabled:
'デバッグキーが設定されていません。プラグインデバッグには認証が不要です',
boxStatusTitle: 'Box ランタイム',
boxStatus: 'ステータス',
boxConnected: '接続済み',
boxUnavailable: '利用不可',
boxBackend: 'バックエンド',
boxProfile: 'プロファイル',
boxSandboxes: 'サンドボックス',
boxErrors: 'エラー',
boxSessionImage: 'イメージ',
boxSessionBackend: 'バックエンド',
boxSessionResources: 'リソース',
boxSessionNetwork: 'ネットワーク',
boxStatusLoadFailed: 'Box ステータスの読み込みに失敗しました',
failedToGetDebugInfo: 'デバッグ情報の取得に失敗しました',
copiedToClipboard: 'クリップボードにコピーしました',
deleting: '削除中...',
@@ -492,6 +515,8 @@
close: '閉じる',
deleteConfirm: '削除の確認',
deleteSuccess: '削除に成功しました',
dangerZone: '危険ゾーン',
dangerZoneDescription: '取り消しできない操作です',
modifyFailed: '変更に失敗しました:',
componentName: {
Tool: 'ツール',
@@ -504,6 +529,22 @@
uploadLocal: 'ローカルアップロード',
debugging: 'デバッグ中',
uploadLocalPlugin: 'ローカルプラグインのアップロード',
localPreview: {
title: 'ローカルプラグインパッケージをプレビュー',
unpacking: 'パッケージを展開してプレビュー中...',
unpackComplete: 'パッケージのプレビュー準備完了',
failed: 'パッケージのプレビューに失敗しました',
pluginInfo: 'プラグイン情報',
packageInfo: 'パッケージ情報',
name: '名前',
author: '作者',
version: 'バージョン',
fileCount: 'ファイル数',
dependencies: '依存関係',
components: 'コンポーネント',
ready:
'プラグインパッケージを展開しました。確認するとインストールを開始します。',
},
dragToUpload: 'ファイルをここにドラッグしてアップロード',
unsupportedFileType:
'サポートされていないファイルタイプです。.lbpkg と .zip ファイルのみサポートされています',
@@ -512,6 +553,7 @@
uploadFailed: 'アップロード失敗',
selectFileToUpload: 'アップロードするプラグインファイルを選択',
askConfirm: 'プラグイン "{{name}}" ({{version}}) をインストールしますか?',
askConfirmNoVersion: 'プラグイン "{{name}}" をインストールしますか?',
fromGithub: 'GitHubから',
fromLocal: 'ローカルから',
fromMarketplace: 'プラグインマーケットから',
@@ -583,7 +625,14 @@
taskQueue: 'インストールタスク',
clearCompleted: '完了を消去',
noTasks: 'インストールタスクはありません',
titlePlugin: 'プラグイン {{name}} をインストール中',
titleMCP: 'MCP サーバー {{name}} をインストール中',
titleSkill: 'スキル {{name}} をインストール中',
installCompletePlugin: 'プラグインをインストールしました',
installCompleteMCP: 'MCP サーバーをインストールしました',
installCompleteSkill: 'スキルをインストールしました',
},
uploadPluginOnly: '.lbpkg プラグインパッケージのみ対応しています',
},
market: {
searchPlaceholder: 'プラグインを検索...',
@@ -628,8 +677,23 @@
markAsRead: '既読',
markAsReadSuccess: '既読に設定しました',
markAsReadFailed: '既読に設定に失敗しました',
filterByComponent: 'コンポーネント',
filterByComponent: 'プラグインコンポーネント',
filterByComponentHint:
'プラグインが提供する機能の種類ですツール、コマンド、イベントリスナーなど。LangBot のさまざまな機能を拡張するために使われます。コンポーネントで絞り込むと、その機能を提供するプラグインのみを表示できます。',
allComponents: '全部コンポーネント',
componentName: {
Tool: 'ツール',
EventListener: 'イベント監視器',
Command: 'コマンド',
KnowledgeEngine: '知識エンジン',
Parser: 'パーサー',
Page: 'ページ',
},
filterByType: 'タイプ',
allTypes: '全部',
typePlugin: 'プラグイン',
typeMCP: 'MCP',
typeSkill: 'スキル',
requestPlugin: 'プラグインをリクエスト',
tags: {
filterByTags: 'タグで絞り込み',
@@ -638,14 +702,24 @@
clearAll: 'クリア',
noTags: 'タグがありません',
},
filters: {
allFormats: 'すべての種類',
more: 'もっと',
advancedTitle: '高度なフィルター',
advancedDescription: '拡張子タイプでフィルター',
technicalType: '技術タイプ',
},
allExtensions: 'すべての拡張機能',
viewDetails: '詳細を表示',
deprecated: '非推奨',
deprecatedTooltip:
'対応する「ナレッジエンジン」プラグインをインストールしてください。',
installCard: '{{name}} をインストール',
},
mcp: {
title: 'MCP',
createServer: 'MCPサーバーを追加',
addMCPServer: 'MCPサーバーを追加',
editServer: 'MCPサーバーを編集',
deleteServer: 'MCPサーバーを削除',
confirmDeleteServer: 'このMCPサーバーを削除してもよろしいですか',
@@ -683,6 +757,15 @@
connectionSuccess: '接続に成功しました',
connectionFailed: '接続に失敗しましたURLを確認してください',
connectionFailedStatus: '接続失敗',
boxDisabledStdioRefused:
'Stdio モードの MCP サーバーは Box サンドボックスを必要としますが、設定で無効化されていますbox.enabled = false。',
boxUnavailableStdioRefused:
'Stdio モードの MCP サーバーは Box サンドボックスを必要としますが、現在接続できません。',
boxStdioRefusedSuggestion:
'Box を有効化box.enabled = trueしてランタイムの接続を確認するか、このサーバーを http/sse モードに切り替えてください。',
boxRequired: 'Box が必要',
stdioBlockedByBoxToast:
'Box サンドボックスが無効または利用できないため、stdio モードの MCP は保存できません。Box を有効化するか、http/sse モードに切り替えてください。',
toolsFound: '個のツール',
unknownError: '不明なエラー',
noToolsFound: 'ツールが見つかりません',
@@ -701,6 +784,8 @@
loadFailed: '読み込みに失敗しました',
modifyFailed: '変更に失敗しました:',
toolCount: 'ツール:{{count}}',
parameterCount: 'パラメータ:{{count}}',
noParameters: 'パラメータなし',
statusConnected: '接続済み',
statusDisconnected: '未接続',
statusError: '接続エラー',
@@ -801,6 +886,13 @@
enableAllMCPServers: 'すべてのMCPサーバーを有効にする',
allPluginsEnabled: 'すべてのプラグインが有効になっています',
allMCPServersEnabled: 'すべてのMCPサーバーが有効になっています',
enableAllSkills: 'すべてのスキルを有効化',
allSkillsEnabled: 'すべてのスキルが有効です',
skillsTitle: 'スキル',
noSkillsSelected: 'スキルが選択されていません',
addSkill: 'スキルを追加',
selectSkills: 'スキルを選択',
noSkillsAvailable: '利用可能なスキルがありません',
},
debugDialog: {
title: 'パイプラインのチャット',
@@ -1235,6 +1327,25 @@
sessions: 'セッション',
feedback: 'ユーザーフィードバック',
},
systemStatus: 'システム状態',
pluginRuntime: 'プラグインランタイム',
boxRuntime: 'Box ランタイム',
connected: '接続済み',
disconnected: '未接続',
disabled: '無効',
statusDetail: 'ステータス',
pluginDisabled: 'プラグインシステムが無効です',
boxDisabled:
'Box サンドボックスは設定で無効化されています — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
boxUnavailable:
'Box サンドボックスは利用できません — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
boxRequiredHint:
'この機能には Box ランタイムが必要です。設定で有効化box.enabled = trueし、ランタイムが正常に接続できることを確認してください。',
boxBackend: 'バックエンド',
boxProfile: 'プロファイル',
boxSandboxes: 'サンドボックス',
boxSessionCreated: '作成日時',
boxSessionLastUsed: '最終使用',
},
storageAnalysis: {
title: 'ストレージ分析',
@@ -1345,6 +1456,53 @@
backToWorkbench: 'ワークベンチに戻る',
},
},
addExtension: {
installTitle: '{{type}}をインストール',
installConfirm: '{{type}}「{{name}}」をインストールしますか?',
installInfoType: 'タイプ',
installInfoId: 'ID',
installInfoVersion: 'バージョン',
installSuccess: 'インストールに成功しました',
installStage: {
mcpInstalling: 'MCPサーバーを追加して接続しています…',
skillInstalling: 'スキルをインストールしています…',
installed: '完了',
},
manualAdd: '手動追加',
uploadExtension: 'ドラッグ&ドロップまたはクリックしてアップロード',
uploadHint: '.zipスキルと.lbpkgプラグインファイルに対応',
orContinueWith: 'または以下の操作を選択',
addMCPServerHint: 'MCPツールサーバー拡張を接続',
installFromGithub: 'GitHubからプラグインをインストール',
installFromGithubHint: 'GitHub Releaseからプラグイン拡張をインストール',
createSkill: '新しいスキルを作成',
createSkillHint: '新しいスキル拡張を手動で作成',
unsupportedFileType:
'サポートされていないファイルタイプです。.zipと.lbpkgファイルのみサポートされています',
githubUrlHelp: 'GitHub URL を貼り付けてください',
githubUrlTooltip:
'プラグイン: リポジトリ、Release、Tag の URL を貼り付けます。スキル: スキルディレクトリ内の SKILL.md ページ URL を貼り付けます。',
githubUrlPlaceholder:
'GitHub リポジトリ、Release、または SKILL.md のリンク',
githubUrlRequired: 'GitHub URL を入力してください',
previewSkill: 'スキルをプレビュー',
noSkillPreviewFound: 'インポート可能なスキルが見つかりません',
},
errorPage: {
unexpectedError: 'エラーが発生しました',
unexpectedErrorDescription:
'予期しないエラーが発生しました。しばらくしてからもう一度お試しください。',
notFound: 'ページが見つかりません',
notFoundDescription:
'お探しのページは存在しないか、移動された可能性があります。',
backendUnavailableStatus: 'バックエンドを利用できません',
goBack: '戻る',
backToHome: 'ホームに戻る',
backToLogin: 'ログインに戻る',
retrying: '再試行中',
retryFailed:
'バックエンドにまだ接続できません。サービスを起動してからもう一度お試しください。',
},
feishu: {
createApp: 'ワンクリックでFeishuアプリ作成',
scanQRCode: '以下のQRコードをFeishuでスキャンし、アプリを自動作成',
@@ -1389,6 +1547,97 @@
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
invalidPage: '無効なプラグインページ',
},
skills: {
title: 'スキル',
description: '会話中に有効化できるスキルを作成・管理します',
createSkill: 'スキルを作成',
createSkillDescription:
'ローカルディレクトリをインポートするか、情報を入力して作成します',
editSkill: 'スキルを編集',
getSkillListError: 'スキル一覧の取得に失敗しました: ',
skillName: 'スキル名',
displayName: 'スキル名',
displayNamePlaceholder: '表示名(任意の言語に対応)',
skillSlug: 'ディレクトリ名',
skillSlugPlaceholder: 'english-name-only',
skillSlugHelp:
'スキルのディレクトリ名として使用します。英字、数字、ハイフン、アンダースコアのみ使用できます。',
skillDescription: 'スキルの説明',
skillInstructions: '指示内容',
saveSuccess: '保存しました',
saveError: '保存に失敗しました: ',
createSuccess: '作成しました',
createError: '作成に失敗しました: ',
deleteSuccess: '削除しました',
deleteError: '削除に失敗しました: ',
deleteConfirmation: 'このスキルを削除してもよろしいですか?',
delete: 'スキルを削除',
skillNameRequired: 'スキル名は必須です',
skillDescriptionRequired: 'スキルの説明は必須です',
packageRootRequired: 'パッケージルートパスは必須です',
scan: 'スキャン',
scanSuccess: 'ディレクトリをスキャンしました',
scanError: 'ディレクトリのスキャンに失敗しました: ',
noSkills: '設定済みのスキルはありません',
preview: 'プレビュー',
previewInstructions: 'SKILL.md 内容プレビュー',
instructionsPlaceholder: 'Markdown 形式でスキルの指示を入力...',
descriptionPlaceholder: 'このスキルの概要LLM に表示されます)',
packageRoot: 'パッケージディレクトリ',
packageRootHelp:
'任意。既存のスキルディレクトリをインポートする場合のみ必要です。新規スキルでは空のままにしてください。スキャンは現在のディレクトリと最大 2 階層下まで確認します。',
importLocalDirectory: 'ローカルスキルディレクトリをインポート',
chooseSkillDirectory: 'SKILL.md のディレクトリを選択',
chooseAnotherDirectory: '別のディレクトリを選択',
importingDirectory: 'プレビュー中...',
clearDirectoryPreview: '選択したディレクトリをクリア',
noSkillMdInDirectory: '選択したディレクトリに SKILL.md が見つかりません',
multipleSkillMdInDirectory:
'選択したディレクトリに複数の SKILL.md があります。単一のスキルディレクトリを直接選択してください。',
importDirectoryError: 'ディレクトリのインポートに失敗しました: ',
advancedSettings: '詳細設定',
searchSkills: 'スキルを検索...',
selectSkills: 'スキルを選択',
addSkill: 'スキルを追加',
builtin: '組み込み',
importFromGithub: 'GitHub からスキルをインストール',
createManually: '手動で作成',
uploadZip: 'ZIP パッケージをアップロード',
uploadZipOnly: '.zip スキルパッケージのみ対応しています',
installSuccess: 'スキルをインストールしました',
installError: 'スキルのインストールに失敗しました: ',
enterRepoUrl: 'GitHub リポジトリ URL を入力',
repoUrlPlaceholder: '例: https://github.com/owner/repo',
fetchingReleases: 'リリースを取得中...',
selectRelease: 'リリースを選択',
noReleasesFound: 'リリースが見つかりません',
fetchReleasesError: 'リリースの取得に失敗しました: ',
selectAsset: 'インストールするファイルを選択',
sourceArchive: 'ソースコード (zip)',
noAssetsFound: 'このリリースにはインストール可能なファイルがありません',
fetchAssetsError: 'ファイルの取得に失敗しました: ',
backToReleases: 'リリースへ戻る',
backToRepoUrl: 'リポジトリ URL へ戻る',
backToAssets: 'ファイル一覧へ戻る',
releaseTag: 'タグ: {{tag}}',
publishedAt: '公開日時: {{date}}',
prerelease: 'プレリリース',
assetSize: 'サイズ: {{size}}',
confirmInstall: 'インストールを確認',
installing: 'スキルをインストール中...',
loading: '読み込み中...',
previewLoadError: 'プレビューの読み込みに失敗しました',
selectFromSidebar: 'サイドバーからスキルを選択してください',
dangerZone: '危険な操作',
dangerZoneDescription: '元に戻せない破壊的な操作',
files: 'ファイル',
noFiles: 'ファイルが見つかりません',
loadFilesError: 'ファイルの読み込みに失敗しました: ',
readFileError: 'ファイルの読み取りに失敗しました: ',
saveFile: 'ファイルを保存',
saveFileSuccess: 'ファイルを保存しました',
saveFileError: 'ファイルの保存に失敗しました: ',
},
};
export default jaJP;

View File

@@ -5,10 +5,14 @@ const ruRU = {
installedPlugins: 'Установленные плагины',
pluginMarket: 'Маркетплейс',
mcpServers: 'MCP-серверы',
addExtension: 'Добавить расширение',
pluginPages: 'Страницы плагинов',
pluginPagesTooltip:
'Визуальные страницы, предоставляемые установленными плагинами',
quickStart: 'Быстрый старт',
scrollToBottom: 'Прокрутить вниз',
editionCommunity: 'Сообщество',
editionCloud: 'Cloud',
},
common: {
login: 'Войти',
@@ -39,6 +43,7 @@ const ruRU = {
delete: 'Удалить',
add: 'Добавить',
select: 'Выбрать',
skill: 'Навык',
cancel: 'Отмена',
submit: 'Отправить',
error: 'Ошибка',
@@ -153,6 +158,7 @@ const ruRU = {
more: 'Ещё ({{count}})',
less: 'Свернуть',
noItems: 'Нет элементов',
termsOfService: 'Условия обслуживания',
},
notFound: {
title: 'Страница не найдена',
@@ -442,6 +448,7 @@ const ruRU = {
arrange: 'Сортировка плагинов',
install: 'Установить',
installPlugin: 'Установить плагин',
newPlugin: 'Новый плагин',
onlySupportGithub:
'В настоящее время поддерживается установка только с GitHub',
enterGithubLink: 'Введите ссылку на GitHub плагина',
@@ -457,6 +464,9 @@ const ruRU = {
loading: 'Загрузка...',
getPluginListError: 'Не удалось получить список плагинов:',
noPluginInstalled: 'Плагины не установлены',
noExtensionInstalled: 'Расширения не установлены',
loadingExtensions: 'Загрузка расширений...',
groupByType: 'Группировать по формату',
pluginConfig: 'Настройка плагина',
pluginSort: 'Порядок плагинов',
pluginSortDescription:
@@ -482,6 +492,19 @@ const ruRU = {
noDebugKey: '(Не задан)',
debugKeyDisabled:
'Ключ отладки не задан, аутентификация при отладке плагина не требуется',
boxStatusTitle: 'Box Runtime',
boxStatus: 'Статус',
boxConnected: 'Подключено',
boxUnavailable: 'Недоступно',
boxBackend: 'Бэкенд',
boxProfile: 'Профиль',
boxSandboxes: 'Песочницы',
boxErrors: 'Ошибки',
boxSessionImage: 'Образ',
boxSessionBackend: 'Бэкенд',
boxSessionResources: 'Ресурсы',
boxSessionNetwork: 'Сеть',
boxStatusLoadFailed: 'Не удалось загрузить статус Box',
failedToGetDebugInfo: 'Не удалось получить отладочную информацию',
copiedToClipboard: 'Скопировано в буфер обмена',
deleting: 'Удаление...',
@@ -498,6 +521,8 @@ const ruRU = {
close: 'Закрыть',
deleteConfirm: 'Подтверждение удаления',
deleteSuccess: 'Удаление успешно',
dangerZone: 'Опасная зона',
dangerZoneDescription: 'Необратимые и разрушительные действия',
modifyFailed: 'Ошибка изменения: ',
componentName: {
Tool: 'Инструмент',
@@ -510,6 +535,21 @@ const ruRU = {
uploadLocal: 'Загрузить локально',
debugging: 'Отладка',
uploadLocalPlugin: 'Загрузить локальный плагин',
localPreview: {
title: 'Предпросмотр локального пакета плагина',
unpacking: 'Распаковка пакета для предпросмотра...',
unpackComplete: 'Предпросмотр пакета готов',
failed: 'Не удалось выполнить предпросмотр пакета',
pluginInfo: 'Информация о плагине',
packageInfo: 'Информация о пакете',
name: 'Название',
author: 'Автор',
version: 'Версия',
fileCount: 'Файлы',
dependencies: 'Зависимости',
components: 'Компоненты',
ready: 'Пакет плагина распакован. Подтвердите, чтобы начать установку.',
},
dragToUpload: 'Перетащите файл плагина сюда для загрузки',
unsupportedFileType:
'Неподдерживаемый тип файла, поддерживаются только файлы .lbpkg и .zip',
@@ -519,6 +559,7 @@ const ruRU = {
selectFileToUpload: 'Выберите файл плагина для загрузки',
askConfirm:
'Вы уверены, что хотите установить плагин "{{name}}" ({{version}})?',
askConfirmNoVersion: 'Вы уверены, что хотите установить плагин "{{name}}"?',
fromGithub: 'С GitHub',
fromLocal: 'Из локального файла',
fromMarketplace: 'Из маркетплейса',
@@ -590,7 +631,14 @@ const ruRU = {
taskQueue: 'Задачи установки',
clearCompleted: 'Очистить завершённые',
noTasks: 'Нет задач установки',
titlePlugin: 'Установка плагина {{name}}',
titleMCP: 'Установка сервера MCP {{name}}',
titleSkill: 'Установка навыка {{name}}',
installCompletePlugin: 'Плагин успешно установлен',
installCompleteMCP: 'Сервер MCP успешно установлен',
installCompleteSkill: 'Навык успешно установлен',
},
uploadPluginOnly: 'Поддерживаются только пакеты плагинов .lbpkg',
},
market: {
searchPlaceholder: 'Поиск плагинов...',
@@ -634,13 +682,36 @@ const ruRU = {
markAsRead: 'Отметить как прочитанное',
markAsReadSuccess: 'Отмечено как прочитанное',
markAsReadFailed: 'Не удалось отметить как прочитанное',
filterByComponent: 'Компонент',
filterByComponent: 'Компонент плагина',
filterByComponentHint:
'Типы возможностей, которые предоставляет плагин — инструмент (Tool), команда (Command), обработчик событий (EventListener) и т. д., — расширяющие функции LangBot. Фильтруйте по компоненту, чтобы видеть только плагины с нужной возможностью.',
allComponents: 'Все компоненты',
componentName: {
Tool: 'Инструмент',
EventListener: 'Обработчик событий',
Command: 'Команда',
KnowledgeEngine: 'Движок знаний',
Parser: 'Парсер',
Page: 'Страница',
},
filterByType: 'Тип',
allTypes: 'Все типы',
typePlugin: 'Плагин',
typeMCP: 'MCP',
typeSkill: 'Навык',
requestPlugin: 'Запросить плагин',
viewDetails: 'Подробнее',
deprecated: 'Устаревший',
deprecatedTooltip:
'Пожалуйста, установите соответствующий плагин движка знаний.',
filters: {
allFormats: 'Все типы',
more: 'Ещё',
advancedTitle: 'Расширенные фильтры',
advancedDescription: 'Фильтр по типу расширения',
technicalType: 'Технический тип',
},
allExtensions: 'Все расширения',
tags: {
filterByTags: 'Фильтр по тегам',
selected: 'выбрано',
@@ -648,10 +719,12 @@ const ruRU = {
clearAll: 'Очистить всё',
noTags: 'Нет доступных тегов',
},
installCard: 'Установить {{name}}',
},
mcp: {
title: 'MCP',
createServer: 'Добавить MCP-сервер',
addMCPServer: 'Добавить MCP-сервер',
editServer: 'Редактировать MCP-сервер',
deleteServer: 'Удалить MCP-сервер',
confirmDeleteServer: 'Вы уверены, что хотите удалить этот MCP-сервер?',
@@ -689,6 +762,15 @@ const ruRU = {
connectionSuccess: 'Подключение успешно',
connectionFailed: 'Не удалось подключиться, проверьте URL',
connectionFailedStatus: 'Ошибка подключения',
boxDisabledStdioRefused:
'MCP-серверы в режиме stdio требуют песочницу Box, которая отключена в конфигурации (box.enabled = false).',
boxUnavailableStdioRefused:
'MCP-серверы в режиме stdio требуют песочницу Box, которая сейчас недоступна.',
boxStdioRefusedSuggestion:
'Включите Box (box.enabled = true) и убедитесь, что среда работает, либо переключите этот сервер в режим http/sse.',
boxRequired: 'требуется Box',
stdioBlockedByBoxToast:
'Сохранить MCP в режиме stdio нельзя: песочница Box отключена или недоступна. Включите Box либо выберите режим http/sse.',
toolsFound: 'инструментов',
unknownError: 'Неизвестная ошибка',
noToolsFound: 'Инструменты не найдены',
@@ -707,6 +789,8 @@ const ruRU = {
loadFailed: 'Ошибка загрузки',
modifyFailed: 'Ошибка изменения: ',
toolCount: 'Инструменты: {{count}}',
parameterCount: 'Параметры: {{count}}',
noParameters: 'Нет параметров',
statusConnected: 'Подключён',
statusDisconnected: 'Отключён',
statusError: 'Ошибка подключения',
@@ -808,6 +892,13 @@ const ruRU = {
enableAllMCPServers: 'Включить все MCP-серверы',
allPluginsEnabled: 'Все плагины включены',
allMCPServersEnabled: 'Все MCP-серверы включены',
enableAllSkills: 'Включить все навыки',
allSkillsEnabled: 'Все навыки включены',
skillsTitle: 'Навыки',
noSkillsSelected: 'Навыки не выбраны',
addSkill: 'Добавить навык',
selectSkills: 'Выбрать навыки',
noSkillsAvailable: 'Нет доступных навыков',
},
debugDialog: {
title: 'Чат конвейера',
@@ -1239,6 +1330,25 @@ const ruRU = {
sessions: 'Сессии',
feedback: 'Отзывы пользователей',
},
systemStatus: 'Состояние системы',
pluginRuntime: 'Среда плагинов',
boxRuntime: 'Среда Box',
connected: 'Подключено',
disconnected: 'Отключено',
disabled: 'Отключено',
statusDetail: 'Статус',
pluginDisabled: 'Система плагинов отключена',
boxDisabled:
'Песочница Box отключена в конфигурации — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
boxUnavailable:
'Песочница Box недоступна — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
boxRequiredHint:
'Для этой функции требуется среда Box. Включите её в конфигурации (box.enabled = true) и убедитесь, что соединение работает.',
boxBackend: 'Бэкенд',
boxProfile: 'Профиль',
boxSandboxes: 'Песочницы',
boxSessionCreated: 'Создано',
boxSessionLastUsed: 'Последнее использование',
},
storageAnalysis: {
title: 'Анализ хранилища',
@@ -1348,6 +1458,21 @@ const ruRU = {
backToWorkbench: 'Вернуться к рабочей панели',
},
},
errorPage: {
unexpectedError: 'Что-то пошло не так',
unexpectedErrorDescription:
'Произошла непредвиденная ошибка. Повторите попытку позже.',
notFound: 'Страница не найдена',
notFoundDescription:
'Страница, которую вы ищете, не существует или была перемещена.',
backendUnavailableStatus: 'Бэкенд недоступен',
goBack: 'Назад',
backToHome: 'На главную',
backToLogin: 'Вернуться к входу',
retrying: 'Повторяем',
retryFailed:
'По-прежнему не удается подключиться к бэкенду. Запустите сервис и повторите попытку.',
},
feishu: {
createApp: 'Создать приложение Feishu в один клик',
scanQRCode:
@@ -1399,6 +1524,130 @@ const ruRU = {
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
invalidPage: 'Недопустимая страница плагина',
},
skills: {
title: 'Навыки',
description:
'Создавайте навыки и управляйте ими для активации во время диалогов',
createSkill: 'Создать навык',
createSkillDescription:
'Импортируйте локальный каталог или создайте навык, заполнив данные',
editSkill: 'Редактировать навык',
getSkillListError: 'Не удалось получить список навыков: ',
skillName: 'Название навыка',
displayName: 'Название навыка',
displayNamePlaceholder: 'Отображаемое имя (поддерживает любой язык)',
skillSlug: 'Имя каталога',
skillSlugPlaceholder: 'english-name-only',
skillSlugHelp:
'Используется как имя каталога навыка. Разрешены только буквы, цифры, дефисы и подчёркивания.',
skillDescription: 'Описание навыка',
skillInstructions: 'Инструкции',
saveSuccess: 'Успешно сохранено',
saveError: 'Не удалось сохранить: ',
createSuccess: 'Успешно создано',
createError: 'Не удалось создать: ',
deleteSuccess: 'Успешно удалено',
deleteError: 'Не удалось удалить: ',
deleteConfirmation: 'Вы уверены, что хотите удалить этот навык?',
delete: 'Удалить навык',
skillNameRequired: 'Название навыка не может быть пустым',
skillDescriptionRequired: 'Описание навыка не может быть пустым',
packageRootRequired: 'Корневой путь пакета не может быть пустым',
scan: 'Сканировать',
scanSuccess: 'Каталог успешно просканирован',
scanError: 'Не удалось просканировать каталог: ',
noSkills: 'Навыки не настроены',
preview: 'Предпросмотр',
previewInstructions: 'Предпросмотр содержимого SKILL.md',
instructionsPlaceholder: 'Введите инструкции навыка в формате Markdown...',
descriptionPlaceholder:
'Краткое описание того, что делает этот навык (показывается LLM)',
packageRoot: 'Каталог пакета',
packageRootHelp:
'Необязательно. Нужно только при импорте существующего каталога навыка. Для новых навыков оставьте пустым. Сканирование проверяет текущий каталог и подкаталоги глубиной до 2 уровней.',
importLocalDirectory: 'Импортировать локальный каталог навыка',
chooseSkillDirectory: 'Выбрать каталог с SKILL.md',
chooseAnotherDirectory: 'Выбрать другой каталог',
importingDirectory: 'Подготовка предпросмотра...',
clearDirectoryPreview: 'Очистить выбранный каталог',
noSkillMdInDirectory: 'В выбранном каталоге не найден SKILL.md',
multipleSkillMdInDirectory:
'В выбранном каталоге найдено несколько файлов SKILL.md. Выберите один каталог навыка напрямую.',
importDirectoryError: 'Не удалось импортировать каталог: ',
advancedSettings: 'Расширенные настройки',
searchSkills: 'Поиск навыков...',
selectSkills: 'Выбрать навыки',
addSkill: 'Добавить навык',
builtin: 'Встроенный',
importFromGithub: 'Установить навык из GitHub',
createManually: 'Создать вручную',
uploadZip: 'Загрузить ZIP-пакет',
uploadZipOnly: 'Поддерживаются только ZIP-пакеты навыков',
installSuccess: 'Навык успешно установлен',
installError: 'Не удалось установить навык: ',
enterRepoUrl: 'Введите URL репозитория GitHub',
repoUrlPlaceholder: 'например, https://github.com/owner/repo',
fetchingReleases: 'Получение релизов...',
selectRelease: 'Выбрать релиз',
noReleasesFound: 'Релизы не найдены',
fetchReleasesError: 'Не удалось получить релизы: ',
selectAsset: 'Выберите файл для установки',
sourceArchive: 'Исходный код (zip)',
noAssetsFound: 'В этом релизе нет устанавливаемых файлов',
fetchAssetsError: 'Не удалось получить файлы: ',
backToReleases: 'Назад к релизам',
backToRepoUrl: 'Назад к URL репозитория',
backToAssets: 'Назад к файлам',
releaseTag: 'Тег: {{tag}}',
publishedAt: 'Опубликовано: {{date}}',
prerelease: 'Предварительный релиз',
assetSize: 'Размер: {{size}}',
confirmInstall: 'Подтвердить установку',
installing: 'Установка навыка...',
loading: 'Загрузка...',
previewLoadError: 'Не удалось загрузить предпросмотр',
selectFromSidebar: 'Выберите навык на боковой панели',
dangerZone: 'Опасная зона',
dangerZoneDescription: 'Необратимые и разрушительные действия',
files: 'Файлы',
noFiles: 'Файлы не найдены',
loadFilesError: 'Не удалось загрузить файлы: ',
readFileError: 'Не удалось прочитать файл: ',
saveFile: 'Сохранить файл',
saveFileSuccess: 'Файл успешно сохранён',
saveFileError: 'Не удалось сохранить файл: ',
},
addExtension: {
installTitle: 'Установить {{type}}',
installConfirm: 'Установить {{type}} «{{name}}»?',
installInfoType: 'Тип',
installInfoId: 'ID',
installInfoVersion: 'Версия',
installSuccess: 'Успешно установлено',
installStage: {
mcpInstalling: 'Добавление и подключение сервера MCP…',
skillInstalling: 'Установка навыка…',
installed: 'Готово',
},
manualAdd: 'Добавить вручную',
uploadExtension: 'Перетащите файл сюда или нажмите для загрузки',
uploadHint: 'Поддерживаются файлы .zip (навыки) и .lbpkg (плагины)',
orContinueWith: 'или выберите действие ниже',
addMCPServerHint: 'Подключить расширение сервера инструментов MCP',
installFromGithub: 'Установить из GitHub',
installFromGithubHint: 'Пакет плагина или навык (SKILL.md)',
githubUrlHelp: 'Вставьте URL GitHub',
githubUrlTooltip:
'Плагин: вставьте URL репозитория, Release или Tag. Навык: вставьте URL страницы SKILL.md внутри каталога навыка.',
githubUrlPlaceholder: 'Репозиторий GitHub, Release или ссылка SKILL.md',
githubUrlRequired: 'Введите URL GitHub',
previewSkill: 'Предпросмотр навыка',
noSkillPreviewFound: 'Импортируемый навык не найден',
createSkill: 'Создать новый навык',
createSkillHint: 'Импортировать из локального каталога или создать вручную',
unsupportedFileType:
'Неподдерживаемый тип файла. Поддерживаются только файлы .zip и .lbpkg',
},
};
export default ruRU;

View File

@@ -5,9 +5,13 @@ const thTH = {
installedPlugins: 'ปลั๊กอินที่ติดตั้ง',
pluginMarket: 'ตลาดปลั๊กอิน',
mcpServers: 'เซิร์ฟเวอร์ MCP',
addExtension: 'เพิ่มส่วนขยาย',
pluginPages: 'หน้าปลั๊กอิน',
pluginPagesTooltip: 'หน้าเว็บที่จัดทำโดยปลั๊กอินที่ติดตั้ง',
quickStart: 'เริ่มต้นอย่างรวดเร็ว',
scrollToBottom: 'เลื่อนไปด้านล่าง',
editionCommunity: 'รุ่นชุมชน',
editionCloud: 'Cloud',
},
common: {
login: 'เข้าสู่ระบบ',
@@ -38,6 +42,7 @@ const thTH = {
delete: 'ลบ',
add: 'เพิ่ม',
select: 'เลือก',
skill: 'สกิล',
cancel: 'ยกเลิก',
submit: 'ส่ง',
error: 'ข้อผิดพลาด',
@@ -66,6 +71,7 @@ const thTH = {
test: 'ทดสอบ',
forgotPassword: 'ลืมรหัสผ่าน?',
agreementNotice: 'การดำเนินการต่อแสดงว่าคุณยอมรับ',
termsOfService: 'ข้อกำหนดการให้บริการ',
privacyPolicy: 'นโยบายความเป็นส่วนตัว',
and: 'และ',
dataCollectionPolicy: 'นโยบายการเก็บรวบรวมข้อมูล',
@@ -429,6 +435,7 @@ const thTH = {
arrange: 'เรียงลำดับปลั๊กอิน',
install: 'ติดตั้ง',
installPlugin: 'ติดตั้งปลั๊กอิน',
newPlugin: 'สร้างปลั๊กอินใหม่',
onlySupportGithub: 'ปัจจุบันรองรับเฉพาะการติดตั้งจาก GitHub',
enterGithubLink: 'กรอกลิงก์ GitHub ของปลั๊กอิน',
installing: 'กำลังติดตั้งปลั๊กอิน...',
@@ -443,6 +450,9 @@ const thTH = {
loading: 'กำลังโหลด...',
getPluginListError: 'ไม่สามารถดึงรายการปลั๊กอินได้:',
noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง',
noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง',
loadingExtensions: 'กำลังโหลดส่วนขยาย...',
groupByType: 'จัดกลุ่มตามรูปแบบ',
pluginConfig: 'การกำหนดค่าปลั๊กอิน',
pluginSort: 'เรียงลำดับปลั๊กอิน',
pluginSortDescription:
@@ -467,6 +477,19 @@ const thTH = {
noDebugKey: '(ไม่ได้ตั้งค่า)',
debugKeyDisabled:
'ไม่ได้ตั้งค่าคีย์ดีบัก การดีบักปลั๊กอินไม่ต้องยืนยันตัวตน',
boxStatusTitle: 'Box Runtime',
boxStatus: 'สถานะ',
boxConnected: 'เชื่อมต่อแล้ว',
boxUnavailable: 'ไม่พร้อมใช้งาน',
boxBackend: 'แบ็กเอนด์',
boxProfile: 'โปรไฟล์',
boxSandboxes: 'แซนด์บ็อกซ์',
boxErrors: 'ข้อผิดพลาด',
boxSessionImage: 'อิมเมจ',
boxSessionBackend: 'แบ็กเอนด์',
boxSessionResources: 'ทรัพยากร',
boxSessionNetwork: 'เครือข่าย',
boxStatusLoadFailed: 'โหลดสถานะ Box ล้มเหลว',
failedToGetDebugInfo: 'ไม่สามารถดึงข้อมูลดีบักได้',
copiedToClipboard: 'คัดลอกไปยังคลิปบอร์ดแล้ว',
deleting: 'กำลังลบ...',
@@ -482,6 +505,8 @@ const thTH = {
close: 'ปิด',
deleteConfirm: 'ยืนยันการลบ',
deleteSuccess: 'ลบสำเร็จ',
dangerZone: 'พื้นที่อันตราย',
dangerZoneDescription: 'การดำเนินการที่ไม่สามารถย้อนกลับได้',
modifyFailed: 'แก้ไขล้มเหลว: ',
componentName: {
Tool: 'เครื่องมือ',
@@ -494,6 +519,21 @@ const thTH = {
uploadLocal: 'อัปโหลดจากเครื่อง',
debugging: 'ดีบัก',
uploadLocalPlugin: 'อัปโหลดปลั๊กอินจากเครื่อง',
localPreview: {
title: 'ดูตัวอย่างแพ็กเกจปลั๊กอินในเครื่อง',
unpacking: 'กำลังแตกแพ็กเกจเพื่อดูตัวอย่าง...',
unpackComplete: 'พร้อมดูตัวอย่างแพ็กเกจแล้ว',
failed: 'ดูตัวอย่างแพ็กเกจไม่สำเร็จ',
pluginInfo: 'ข้อมูลปลั๊กอิน',
packageInfo: 'ข้อมูลแพ็กเกจ',
name: 'ชื่อ',
author: 'ผู้เขียน',
version: 'เวอร์ชัน',
fileCount: 'จำนวนไฟล์',
dependencies: 'แพ็กเกจที่ต้องใช้',
components: 'คอมโพเนนต์',
ready: 'แตกแพ็กเกจปลั๊กอินแล้ว ยืนยันเพื่อเริ่มติดตั้ง',
},
dragToUpload: 'ลากไฟล์ปลั๊กอินมาวางที่นี่เพื่ออัปโหลด',
unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .lbpkg และ .zip',
uploadingPlugin: 'กำลังอัปโหลดปลั๊กอิน...',
@@ -501,6 +541,7 @@ const thTH = {
uploadFailed: 'อัปโหลดล้มเหลว',
selectFileToUpload: 'เลือกไฟล์ปลั๊กอินเพื่ออัปโหลด',
askConfirm: 'คุณแน่ใจหรือไม่ที่จะติดตั้งปลั๊กอิน "{{name}}" ({{version}})?',
askConfirmNoVersion: 'คุณแน่ใจหรือไม่ที่จะติดตั้งปลั๊กอิน "{{name}}"?',
fromGithub: 'จาก GitHub',
fromLocal: 'จากเครื่อง',
fromMarketplace: 'จากตลาดปลั๊กอิน',
@@ -571,7 +612,14 @@ const thTH = {
taskQueue: 'งานติดตั้ง',
clearCompleted: 'ล้างที่เสร็จแล้ว',
noTasks: 'ไม่มีงานติดตั้ง',
titlePlugin: 'กำลังติดตั้งปลั๊กอิน {{name}}',
titleMCP: 'กำลังติดตั้งเซิร์ฟเวอร์ MCP {{name}}',
titleSkill: 'กำลังติดตั้งสกิล {{name}}',
installCompletePlugin: 'ติดตั้งปลั๊กอินสำเร็จ',
installCompleteMCP: 'ติดตั้งเซิร์ฟเวอร์ MCP สำเร็จ',
installCompleteSkill: 'ติดตั้งสกิลสำเร็จ',
},
uploadPluginOnly: 'รองรับเฉพาะแพ็กเกจปลั๊กอิน .lbpkg',
},
market: {
searchPlaceholder: 'ค้นหาปลั๊กอิน...',
@@ -615,12 +663,35 @@ const thTH = {
markAsRead: 'ทำเครื่องหมายว่าอ่านแล้ว',
markAsReadSuccess: 'ทำเครื่องหมายว่าอ่านแล้ว',
markAsReadFailed: 'ทำเครื่องหมายว่าอ่านแล้วล้มเหลว',
filterByComponent: 'ส่วนประกอบ',
filterByComponent: 'ส่วนประกอบปลั๊กอิน',
filterByComponentHint:
'ประเภทความสามารถที่ปลั๊กอินมีให้ เช่น เครื่องมือ (Tool) คำสั่ง (Command) ตัวรับฟังเหตุการณ์ (EventListener) เป็นต้น ใช้เพื่อขยายความสามารถต่าง ๆ ของ LangBot กรองตามส่วนประกอบเพื่อแสดงเฉพาะปลั๊กอินที่มีความสามารถนั้น',
allComponents: 'ส่วนประกอบทั้งหมด',
componentName: {
Tool: 'เครื่องมือ',
EventListener: 'ตัวรับฟังเหตุการณ์',
Command: 'คำสั่ง',
KnowledgeEngine: 'เครื่องมือความรู้',
Parser: 'ตัวแยกวิเคราะห์',
Page: 'หน้า',
},
filterByType: 'ประเภท',
allTypes: 'ทุกประเภท',
typePlugin: 'ปลั๊กอิน',
typeMCP: 'MCP',
typeSkill: 'สกิล',
requestPlugin: 'ขอปลั๊กอิน',
viewDetails: 'ดูรายละเอียด',
deprecated: 'เลิกใช้แล้ว',
deprecatedTooltip: 'กรุณาติดตั้งปลั๊กอินเครื่องมือความรู้ที่เกี่ยวข้อง',
filters: {
allFormats: 'ทุกประเภท',
more: 'เพิ่มเติม',
advancedTitle: 'ตัวกรองขั้นสูง',
advancedDescription: 'กรองตามประเภทส่วนขยาย',
technicalType: 'ประเภทเทคนิค',
},
allExtensions: 'ส่วนขยายทั้งหมด',
tags: {
filterByTags: 'กรองตามแท็ก',
selected: 'เลือกแล้ว',
@@ -628,10 +699,12 @@ const thTH = {
clearAll: 'ล้างทั้งหมด',
noTags: 'ไม่มีแท็กที่พร้อมใช้งาน',
},
installCard: 'ติดตั้ง {{name}}',
},
mcp: {
title: 'MCP',
createServer: 'เพิ่มเซิร์ฟเวอร์ MCP',
addMCPServer: 'เพิ่มเซิร์ฟเวอร์ MCP',
editServer: 'แก้ไขเซิร์ฟเวอร์ MCP',
deleteServer: 'ลบเซิร์ฟเวอร์ MCP',
confirmDeleteServer: 'คุณแน่ใจหรือไม่ว่าต้องการลบเซิร์ฟเวอร์ MCP นี้?',
@@ -669,6 +742,15 @@ const thTH = {
connectionSuccess: 'เชื่อมต่อสำเร็จ',
connectionFailed: 'เชื่อมต่อล้มเหลว กรุณาตรวจสอบ URL',
connectionFailedStatus: 'เชื่อมต่อล้มเหลว',
boxDisabledStdioRefused:
'MCP server แบบ stdio ต้องใช้ Sandbox Box ซึ่งถูกปิดใช้งานในการตั้งค่า (box.enabled = false)',
boxUnavailableStdioRefused:
'MCP server แบบ stdio ต้องใช้ Sandbox Box ซึ่งขณะนี้เชื่อมต่อไม่ได้',
boxStdioRefusedSuggestion:
'กรุณาเปิดใช้งาน Box (box.enabled = true) และตรวจสอบว่ารันไทม์ทำงานปกติ หรือเปลี่ยน MCP server เป็นโหมด http/sse',
boxRequired: 'ต้องใช้ Box',
stdioBlockedByBoxToast:
'ไม่สามารถบันทึก MCP โหมด stdio เนื่องจาก Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งาน Box หรือเลือกโหมด http/sse',
toolsFound: 'เครื่องมือ',
unknownError: 'ข้อผิดพลาดที่ไม่ทราบสาเหตุ',
noToolsFound: 'ไม่พบเครื่องมือ',
@@ -687,6 +769,8 @@ const thTH = {
loadFailed: 'โหลดล้มเหลว',
modifyFailed: 'แก้ไขล้มเหลว: ',
toolCount: 'เครื่องมือ: {{count}}',
parameterCount: 'พารามิเตอร์: {{count}}',
noParameters: 'ไม่มีพารามิเตอร์',
statusConnected: 'เชื่อมต่อแล้ว',
statusDisconnected: 'ไม่ได้เชื่อมต่อ',
statusError: 'ข้อผิดพลาดการเชื่อมต่อ',
@@ -787,6 +871,13 @@ const thTH = {
enableAllMCPServers: 'เปิดใช้งานเซิร์ฟเวอร์ MCP ทั้งหมด',
allPluginsEnabled: 'เปิดใช้งานปลั๊กอินทั้งหมดแล้ว',
allMCPServersEnabled: 'เปิดใช้งานเซิร์ฟเวอร์ MCP ทั้งหมดแล้ว',
enableAllSkills: 'เปิดใช้สกิลทั้งหมด',
allSkillsEnabled: 'เปิดใช้สกิลทั้งหมดแล้ว',
skillsTitle: 'สกิล',
noSkillsSelected: 'ยังไม่ได้เลือกสกิล',
addSkill: 'เพิ่มสกิล',
selectSkills: 'เลือกสกิล',
noSkillsAvailable: 'ไม่มีสกิลที่พร้อมใช้งาน',
},
debugDialog: {
title: 'แชท Pipeline',
@@ -1210,6 +1301,25 @@ const thTH = {
sessions: 'เซสชัน',
feedback: 'ความคิดเห็นผู้ใช้',
},
systemStatus: 'สถานะระบบ',
pluginRuntime: 'Plugin Runtime',
boxRuntime: 'Box Runtime',
connected: 'เชื่อมต่อแล้ว',
disconnected: 'ไม่ได้เชื่อมต่อ',
disabled: 'ปิดใช้งาน',
statusDetail: 'สถานะ',
pluginDisabled: 'ระบบปลั๊กอินถูกปิดใช้งาน',
boxDisabled:
'Sandbox Box ถูกปิดใช้งานในการตั้งค่า — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
boxUnavailable:
'Sandbox Box ไม่พร้อมใช้งาน — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
boxRequiredHint:
'ฟีเจอร์นี้ต้องใช้ Box runtime กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่าการเชื่อมต่อปกติ',
boxBackend: 'แบ็กเอนด์',
boxProfile: 'โปรไฟล์',
boxSandboxes: 'แซนด์บ็อกซ์',
boxSessionCreated: 'สร้างเมื่อ',
boxSessionLastUsed: 'ใช้ล่าสุด',
},
storageAnalysis: {
title: 'วิเคราะห์พื้นที่จัดเก็บ',
@@ -1317,6 +1427,20 @@ const thTH = {
backToWorkbench: 'กลับไปหน้าทำงาน',
},
},
errorPage: {
unexpectedError: 'เกิดข้อผิดพลาด',
unexpectedErrorDescription:
'เกิดข้อผิดพลาดที่ไม่คาดคิด กรุณาลองใหม่อีกครั้งในภายหลัง',
notFound: 'ไม่พบหน้า',
notFoundDescription: 'หน้าที่คุณกำลังมองหาไม่มีอยู่หรือถูกย้ายแล้ว',
backendUnavailableStatus: 'แบ็กเอนด์ไม่พร้อมใช้งาน',
goBack: 'ย้อนกลับ',
backToHome: 'กลับหน้าหลัก',
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
retrying: 'กำลังลองใหม่',
retryFailed:
'ยังไม่สามารถเชื่อมต่อแบ็กเอนด์ได้ โปรดเริ่มบริการแล้วลองใหม่อีกครั้ง',
},
feishu: {
createApp: 'สร้างแอป Feishu ด้วยคลิกเดียว',
scanQRCode:
@@ -1365,6 +1489,127 @@ const thTH = {
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
},
skills: {
title: 'สกิล',
description: 'สร้างและจัดการสกิลที่สามารถเปิดใช้ระหว่างการสนทนาได้',
createSkill: 'สร้างสกิล',
createSkillDescription: 'นำเข้าไดเรกทอรีในเครื่องหรือสร้างโดยกรอกข้อมูล',
editSkill: 'แก้ไขสกิล',
getSkillListError: 'ดึงรายการสกิลไม่สำเร็จ: ',
skillName: 'ชื่อสกิล',
displayName: 'ชื่อสกิล',
displayNamePlaceholder: 'ชื่อที่แสดง (รองรับทุกภาษา)',
skillSlug: 'ชื่อไดเรกทอรี',
skillSlugPlaceholder: 'english-name-only',
skillSlugHelp:
'ใช้เป็นชื่อไดเรกทอรีของสกิล รองรับเฉพาะตัวอักษร ตัวเลข ขีดกลาง และขีดล่าง',
skillDescription: 'คำอธิบายสกิล',
skillInstructions: 'คำสั่ง',
saveSuccess: 'บันทึกสำเร็จ',
saveError: 'บันทึกไม่สำเร็จ: ',
createSuccess: 'สร้างสำเร็จ',
createError: 'สร้างไม่สำเร็จ: ',
deleteSuccess: 'ลบสำเร็จ',
deleteError: 'ลบไม่สำเร็จ: ',
deleteConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบสกิลนี้?',
delete: 'ลบสกิล',
skillNameRequired: 'ชื่อสกิลต้องไม่ว่าง',
skillDescriptionRequired: 'คำอธิบายสกิลต้องไม่ว่าง',
packageRootRequired: 'เส้นทางรากของแพ็กเกจต้องไม่ว่าง',
scan: 'สแกน',
scanSuccess: 'สแกนไดเรกทอรีสำเร็จ',
scanError: 'สแกนไดเรกทอรีไม่สำเร็จ: ',
noSkills: 'ยังไม่มีสกิลที่ตั้งค่าไว้',
preview: 'ดูตัวอย่าง',
previewInstructions: 'ตัวอย่างเนื้อหา SKILL.md',
instructionsPlaceholder: 'ป้อนคำสั่งของสกิลในรูปแบบ Markdown...',
descriptionPlaceholder:
'คำอธิบายสั้น ๆ ว่าสกิลนี้ทำอะไร (แสดงให้ LLM เห็น)',
packageRoot: 'ไดเรกทอรีแพ็กเกจ',
packageRootHelp:
'ไม่บังคับ จำเป็นเฉพาะเมื่อนำเข้าไดเรกทอรีสกิลที่มีอยู่ เว้นว่างไว้สำหรับสกิลใหม่ การสแกนจะตรวจไดเรกทอรีปัจจุบันและไดเรกทอรีย่อยลึกสุด 2 ระดับ',
importLocalDirectory: 'นำเข้าไดเรกทอรีสกิลในเครื่อง',
chooseSkillDirectory: 'เลือกไดเรกทอรีของ SKILL.md',
chooseAnotherDirectory: 'เลือกไดเรกทอรีอื่น',
importingDirectory: 'กำลังสร้างตัวอย่าง...',
clearDirectoryPreview: 'ล้างไดเรกทอรีที่เลือก',
noSkillMdInDirectory: 'ไม่พบ SKILL.md ในไดเรกทอรีที่เลือก',
multipleSkillMdInDirectory:
'ไดเรกทอรีที่เลือกมี SKILL.md หลายไฟล์ โปรดเลือกไดเรกทอรีสกิลเดียวโดยตรง',
importDirectoryError: 'นำเข้าไดเรกทอรีไม่สำเร็จ: ',
advancedSettings: 'การตั้งค่าขั้นสูง',
searchSkills: 'ค้นหาสกิล...',
selectSkills: 'เลือกสกิล',
addSkill: 'เพิ่มสกิล',
builtin: 'ในตัว',
importFromGithub: 'ติดตั้งสกิลจาก GitHub',
createManually: 'สร้างด้วยตนเอง',
uploadZip: 'อัปโหลดแพ็กเกจ ZIP',
uploadZipOnly: 'รองรับเฉพาะแพ็กเกจสกิล .zip',
installSuccess: 'ติดตั้งสกิลสำเร็จ',
installError: 'ติดตั้งสกิลไม่สำเร็จ: ',
enterRepoUrl: 'ป้อน URL รีโพสitory GitHub',
repoUrlPlaceholder: 'เช่น https://github.com/owner/repo',
fetchingReleases: 'กำลังดึงรีลีส...',
selectRelease: 'เลือกรีลีส',
noReleasesFound: 'ไม่พบรีลีส',
fetchReleasesError: 'ดึงรีลีสไม่สำเร็จ: ',
selectAsset: 'เลือกไฟล์ที่จะติดตั้ง',
sourceArchive: 'ซอร์สโค้ด (zip)',
noAssetsFound: 'ไม่มีไฟล์ที่ติดตั้งได้ในรีลีสนี้',
fetchAssetsError: 'ดึงไฟล์ไม่สำเร็จ: ',
backToReleases: 'กลับไปที่รีลีส',
backToRepoUrl: 'กลับไปที่ URL รีโพสitory',
backToAssets: 'กลับไปที่ไฟล์',
releaseTag: 'แท็ก: {{tag}}',
publishedAt: 'เผยแพร่เมื่อ: {{date}}',
prerelease: 'พรีรีลีส',
assetSize: 'ขนาด: {{size}}',
confirmInstall: 'ยืนยันการติดตั้ง',
installing: 'กำลังติดตั้งสกิล...',
loading: 'กำลังโหลด...',
previewLoadError: 'โหลดตัวอย่างไม่สำเร็จ',
selectFromSidebar: 'เลือกสกิลจากแถบด้านข้าง',
dangerZone: 'พื้นที่อันตราย',
dangerZoneDescription: 'การกระทำที่ย้อนกลับไม่ได้และทำลายข้อมูล',
files: 'ไฟล์',
noFiles: 'ไม่พบไฟล์',
loadFilesError: 'โหลดไฟล์ไม่สำเร็จ: ',
readFileError: 'อ่านไฟล์ไม่สำเร็จ: ',
saveFile: 'บันทึกไฟล์',
saveFileSuccess: 'บันทึกไฟล์สำเร็จ',
saveFileError: 'บันทึกไฟล์ไม่สำเร็จ: ',
},
addExtension: {
installTitle: 'ติดตั้ง {{type}}',
installConfirm: 'ติดตั้ง {{type}} "{{name}}" หรือไม่?',
installInfoType: 'ประเภท',
installInfoId: 'ID',
installInfoVersion: 'เวอร์ชัน',
installSuccess: 'ติดตั้งสำเร็จ',
installStage: {
mcpInstalling: 'กำลังเพิ่มและเชื่อมต่อเซิร์ฟเวอร์ MCP…',
skillInstalling: 'กำลังติดตั้งสกิล…',
installed: 'เสร็จสิ้น',
},
manualAdd: 'เพิ่มด้วยตนเอง',
uploadExtension: 'ลากแล้ววางหรือคลิกเพื่ออัปโหลด',
uploadHint: 'รองรับไฟล์ .zip (สกิล) และ .lbpkg (ปลั๊กอิน)',
orContinueWith: 'หรือเลือกการทำงานด้านล่าง',
addMCPServerHint: 'เชื่อมต่อส่วนขยายเซิร์ฟเวอร์เครื่องมือ MCP',
installFromGithub: 'ติดตั้งจาก GitHub',
installFromGithubHint: 'แพ็กเกจปลั๊กอินหรือสกิล (SKILL.md)',
githubUrlHelp: 'วาง URL GitHub',
githubUrlTooltip:
'ปลั๊กอิน: วาง URL รีโพสitory, Release หรือ Tag สกิล: วาง URL หน้า SKILL.md ภายในไดเรกทอรีสกิล',
githubUrlPlaceholder: 'รีโพสitory GitHub, Release หรือลิงก์ SKILL.md',
githubUrlRequired: 'ป้อน URL GitHub',
previewSkill: 'ดูตัวอย่างสกิล',
noSkillPreviewFound: 'ไม่พบสกิลที่นำเข้าได้',
createSkill: 'สร้างสกิลใหม่',
createSkillHint: 'นำเข้าจากไดเรกทอรีในเครื่องหรือสร้างด้วยตนเอง',
unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .zip และ .lbpkg',
},
};
export default thTH;

View File

@@ -5,10 +5,14 @@ const viVN = {
installedPlugins: 'Plugin đã cài đặt',
pluginMarket: 'Chợ ứng dụng',
mcpServers: 'Máy chủ MCP',
addExtension: 'Thêm tiện ích mở rộng',
pluginPages: 'Trang plugin',
pluginPagesTooltip:
'Các trang trực quan được cung cấp bởi plugin đã cài đặt',
quickStart: 'Bắt đầu nhanh',
scrollToBottom: 'Cuộn xuống cuối',
editionCommunity: 'Bản cộng đồng',
editionCloud: 'Cloud',
},
common: {
login: 'Đăng nhập',
@@ -39,6 +43,7 @@ const viVN = {
delete: 'Xóa',
add: 'Thêm',
select: 'Chọn',
skill: 'Kỹ năng',
cancel: 'Hủy',
submit: 'Gửi',
error: 'Lỗi',
@@ -67,6 +72,7 @@ const viVN = {
test: 'Kiểm tra',
forgotPassword: 'Quên mật khẩu?',
agreementNotice: 'Bằng việc tiếp tục, bạn đồng ý với',
termsOfService: 'Điều khoản dịch vụ',
privacyPolicy: 'Chính sách bảo mật',
and: 'và',
dataCollectionPolicy: 'Chính sách thu thập dữ liệu',
@@ -439,6 +445,7 @@ const viVN = {
arrange: 'Sắp xếp Plugin',
install: 'Cài đặt',
installPlugin: 'Cài đặt Plugin',
newPlugin: 'Plugin mới',
onlySupportGithub: 'Hiện chỉ hỗ trợ cài đặt từ GitHub',
enterGithubLink: 'Nhập liên kết GitHub của plugin',
installing: 'Đang cài đặt plugin...',
@@ -453,6 +460,9 @@ const viVN = {
loading: 'Đang tải...',
getPluginListError: 'Lấy danh sách plugin thất bại:',
noPluginInstalled: 'Chưa cài đặt plugin nào',
noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào',
loadingExtensions: 'Đang tải tiện ích mở rộng...',
groupByType: 'Nhóm theo định dạng',
pluginConfig: 'Cấu hình Plugin',
pluginSort: 'Sắp xếp Plugin',
pluginSortDescription:
@@ -478,6 +488,19 @@ const viVN = {
noDebugKey: '(Chưa đặt)',
debugKeyDisabled:
'Khóa gỡ lỗi chưa được đặt, gỡ lỗi plugin không yêu cầu xác thực',
boxStatusTitle: 'Box Runtime',
boxStatus: 'Trạng thái',
boxConnected: 'Đã kết nối',
boxUnavailable: 'Không khả dụng',
boxBackend: 'Backend',
boxProfile: 'Hồ sơ',
boxSandboxes: 'Sandbox',
boxErrors: 'Lỗi',
boxSessionImage: 'Image',
boxSessionBackend: 'Backend',
boxSessionResources: 'Tài nguyên',
boxSessionNetwork: 'Mạng',
boxStatusLoadFailed: 'Tải trạng thái Box thất bại',
failedToGetDebugInfo: 'Lấy thông tin gỡ lỗi thất bại',
copiedToClipboard: 'Đã sao chép vào clipboard',
deleting: 'Đang xóa...',
@@ -493,6 +516,8 @@ const viVN = {
close: 'Đóng',
deleteConfirm: 'Xác nhận xóa',
deleteSuccess: 'Xóa thành công',
dangerZone: 'Vùng nguy hiểm',
dangerZoneDescription: 'Các thao tác không thể hoàn tác',
modifyFailed: 'Sửa đổi thất bại: ',
componentName: {
Tool: 'Công cụ',
@@ -505,6 +530,21 @@ const viVN = {
uploadLocal: 'Tải lên cục bộ',
debugging: 'Gỡ lỗi',
uploadLocalPlugin: 'Tải lên Plugin cục bộ',
localPreview: {
title: 'Xem trước gói plugin cục bộ',
unpacking: 'Đang giải nén để xem trước gói...',
unpackComplete: 'Bản xem trước gói đã sẵn sàng',
failed: 'Không thể xem trước gói',
pluginInfo: 'Thông tin plugin',
packageInfo: 'Thông tin gói',
name: 'Tên',
author: 'Tác giả',
version: 'Phiên bản',
fileCount: 'Tệp',
dependencies: 'Phụ thuộc',
components: 'Thành phần',
ready: 'Gói plugin đã được giải nén. Xác nhận để bắt đầu cài đặt.',
},
dragToUpload: 'Kéo tệp plugin vào đây để tải lên',
unsupportedFileType:
'Loại tệp không được hỗ trợ, chỉ hỗ trợ tệp .lbpkg và .zip',
@@ -514,6 +554,8 @@ const viVN = {
selectFileToUpload: 'Chọn tệp plugin để tải lên',
askConfirm:
'Bạn có chắc chắn muốn cài đặt plugin "{{name}}" ({{version}}) không?',
askConfirmNoVersion:
'Bạn có chắc chắn muốn cài đặt plugin "{{name}}" không?',
fromGithub: 'Từ GitHub',
fromLocal: 'Từ cục bộ',
fromMarketplace: 'Từ chợ ứng dụng',
@@ -584,7 +626,14 @@ const viVN = {
taskQueue: 'Tác vụ cài đặt',
clearCompleted: 'Xóa đã hoàn thành',
noTasks: 'Không có tác vụ cài đặt',
titlePlugin: 'Đang cài đặt plugin {{name}}',
titleMCP: 'Đang cài đặt máy chủ MCP {{name}}',
titleSkill: 'Đang cài đặt kỹ năng {{name}}',
installCompletePlugin: 'Đã cài đặt plugin thành công',
installCompleteMCP: 'Đã cài đặt máy chủ MCP thành công',
installCompleteSkill: 'Đã cài đặt kỹ năng thành công',
},
uploadPluginOnly: 'Chỉ hỗ trợ gói plugin .lbpkg',
},
market: {
searchPlaceholder: 'Tìm kiếm plugin...',
@@ -628,12 +677,35 @@ const viVN = {
markAsRead: 'Đánh dấu đã đọc',
markAsReadSuccess: 'Đã đánh dấu đã đọc',
markAsReadFailed: 'Đánh dấu đã đọc thất bại',
filterByComponent: 'Thành phần',
filterByComponent: 'Thành phần plugin',
filterByComponentHint:
'Các loại năng lực mà plugin cung cấp — Công cụ (Tool), Lệnh (Command), Trình lắng nghe sự kiện (EventListener), v.v. — dùng để mở rộng các khả năng của LangBot. Lọc theo thành phần để chỉ xem những plugin cung cấp năng lực đó.',
allComponents: 'Tất cả thành phần',
componentName: {
Tool: 'Công cụ',
EventListener: 'Trình lắng nghe sự kiện',
Command: 'Lệnh',
KnowledgeEngine: 'Công cụ tri thức',
Parser: 'Trình phân tích',
Page: 'Trang',
},
filterByType: 'Loại',
allTypes: 'Tất cả loại',
typePlugin: 'Plugin',
typeMCP: 'MCP',
typeSkill: 'Kỹ năng',
requestPlugin: 'Yêu cầu Plugin',
viewDetails: 'Xem chi tiết',
deprecated: 'Không còn hỗ trợ',
deprecatedTooltip: 'Vui lòng cài đặt plugin Công cụ tri thức tương ứng.',
filters: {
allFormats: 'Tất cả loại',
more: 'Thêm',
advancedTitle: 'Bộ lọc nâng cao',
advancedDescription: 'Lọc theo loại phần mở rộng',
technicalType: 'Loại kỹ thuật',
},
allExtensions: 'Tất cả phần mở rộng',
tags: {
filterByTags: 'Lọc theo thẻ',
selected: 'đã chọn',
@@ -641,10 +713,12 @@ const viVN = {
clearAll: 'Xóa tất cả',
noTags: 'Không có thẻ nào',
},
installCard: 'Cài đặt {{name}}',
},
mcp: {
title: 'MCP',
createServer: 'Thêm máy chủ MCP',
addMCPServer: 'Thêm máy chủ MCP',
editServer: 'Chỉnh sửa máy chủ MCP',
deleteServer: 'Xóa máy chủ MCP',
confirmDeleteServer: 'Bạn có chắc chắn muốn xóa máy chủ MCP này không?',
@@ -682,6 +756,15 @@ const viVN = {
connectionSuccess: 'Kết nối thành công',
connectionFailed: 'Kết nối thất bại, vui lòng kiểm tra URL',
connectionFailedStatus: 'Kết nối thất bại',
boxDisabledStdioRefused:
'MCP server ở chế độ stdio cần Sandbox Box, hiện đã bị tắt trong cấu hình (box.enabled = false).',
boxUnavailableStdioRefused:
'MCP server ở chế độ stdio cần Sandbox Box, hiện không thể kết nối.',
boxStdioRefusedSuggestion:
'Hãy bật Box (box.enabled = true) và đảm bảo runtime hoạt động, hoặc chuyển server này sang chế độ http/sse.',
boxRequired: 'cần Box',
stdioBlockedByBoxToast:
'Không thể lưu MCP ở chế độ stdio khi Sandbox Box bị tắt hoặc không khả dụng. Hãy bật Box hoặc chọn chế độ http/sse.',
toolsFound: 'công cụ',
unknownError: 'Lỗi không xác định',
noToolsFound: 'Không tìm thấy công cụ nào',
@@ -700,6 +783,8 @@ const viVN = {
loadFailed: 'Tải thất bại',
modifyFailed: 'Sửa đổi thất bại: ',
toolCount: 'Công cụ: {{count}}',
parameterCount: 'Tham số: {{count}}',
noParameters: 'Không có tham số',
statusConnected: 'Đã kết nối',
statusDisconnected: 'Đã ngắt kết nối',
statusError: 'Lỗi kết nối',
@@ -800,6 +885,13 @@ const viVN = {
enableAllMCPServers: 'Bật tất cả máy chủ MCP',
allPluginsEnabled: 'Đã bật tất cả plugin',
allMCPServersEnabled: 'Đã bật tất cả máy chủ MCP',
enableAllSkills: 'Bật tất cả kỹ năng',
allSkillsEnabled: 'Tất cả kỹ năng đã được bật',
skillsTitle: 'Kỹ năng',
noSkillsSelected: 'Chưa chọn kỹ năng',
addSkill: 'Thêm kỹ năng',
selectSkills: 'Chọn kỹ năng',
noSkillsAvailable: 'Không có kỹ năng khả dụng',
},
debugDialog: {
title: 'Trò chuyện Pipeline',
@@ -1232,6 +1324,25 @@ const viVN = {
sessions: 'Phiên',
feedback: 'Phản hồi người dùng',
},
systemStatus: 'Trạng thái hệ thống',
pluginRuntime: 'Plugin Runtime',
boxRuntime: 'Box Runtime',
connected: 'Đã kết nối',
disconnected: 'Chưa kết nối',
disabled: 'Đã tắt',
statusDetail: 'Trạng thái',
pluginDisabled: 'Hệ thống plugin đã tắt',
boxDisabled:
'Sandbox Box đã tắt trong cấu hình — công cụ sandbox, thêm/chỉnh sửa skill và stdio MCP đều không khả dụng',
boxUnavailable:
'Sandbox Box không khả dụng — công cụ sandbox, thêm/chỉnh sửa skill và stdio MCP đều không khả dụng',
boxRequiredHint:
'Tính năng này cần Box runtime. Hãy bật trong cấu hình (box.enabled = true) và đảm bảo runtime đang hoạt động.',
boxBackend: 'Backend',
boxProfile: 'Hồ sơ',
boxSandboxes: 'Sandbox',
boxSessionCreated: 'Đã tạo',
boxSessionLastUsed: 'Lần cuối sử dụng',
},
storageAnalysis: {
title: 'Phân tích lưu trữ',
@@ -1339,6 +1450,21 @@ const viVN = {
backToWorkbench: 'Quay lại bàn làm việc',
},
},
errorPage: {
unexpectedError: 'Đã xảy ra lỗi',
unexpectedErrorDescription:
'Đã xảy ra lỗi không mong muốn. Vui lòng thử lại sau.',
notFound: 'Không tìm thấy trang',
notFoundDescription:
'Trang bạn tìm kiếm không tồn tại hoặc đã được di chuyển.',
backendUnavailableStatus: 'Backend không khả dụng',
goBack: 'Quay lại',
backToHome: 'Về trang chủ',
backToLogin: 'Quay lại đăng nhập',
retrying: 'Đang thử lại',
retryFailed:
'Vẫn không thể kết nối backend. Hãy khởi động dịch vụ rồi thử lại.',
},
feishu: {
createApp: 'Tạo ứng dụng Feishu chỉ với một lần nhấp',
scanQRCode:
@@ -1389,6 +1515,131 @@ const viVN = {
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
invalidPage: 'Trang plugin không hợp lệ',
},
skills: {
title: 'Kỹ năng',
description:
'Tạo và quản lý các kỹ năng có thể được kích hoạt trong cuộc trò chuyện',
createSkill: 'Tạo kỹ năng',
createSkillDescription:
'Nhập thư mục cục bộ hoặc tạo bằng cách điền thông tin',
editSkill: 'Chỉnh sửa kỹ năng',
getSkillListError: 'Không thể lấy danh sách kỹ năng: ',
skillName: 'Tên kỹ năng',
displayName: 'Tên kỹ năng',
displayNamePlaceholder: 'Tên hiển thị (hỗ trợ mọi ngôn ngữ)',
skillSlug: 'Tên thư mục',
skillSlugPlaceholder: 'english-name-only',
skillSlugHelp:
'Dùng làm tên thư mục kỹ năng. Chỉ hỗ trợ chữ cái, số, dấu gạch nối và dấu gạch dưới.',
skillDescription: 'Mô tả kỹ năng',
skillInstructions: 'Hướng dẫn',
saveSuccess: 'Đã lưu thành công',
saveError: 'Lưu thất bại: ',
createSuccess: 'Đã tạo thành công',
createError: 'Tạo thất bại: ',
deleteSuccess: 'Đã xóa thành công',
deleteError: 'Xóa thất bại: ',
deleteConfirmation: 'Bạn có chắc muốn xóa kỹ năng này không?',
delete: 'Xóa kỹ năng',
skillNameRequired: 'Tên kỹ năng không được để trống',
skillDescriptionRequired: 'Mô tả kỹ năng không được để trống',
packageRootRequired: 'Đường dẫn gốc của gói không được để trống',
scan: 'Quét',
scanSuccess: 'Đã quét thư mục thành công',
scanError: 'Quét thư mục thất bại: ',
noSkills: 'Chưa cấu hình kỹ năng nào',
preview: 'Xem trước',
previewInstructions: 'Xem trước nội dung SKILL.md',
instructionsPlaceholder:
'Nhập hướng dẫn kỹ năng theo định dạng Markdown...',
descriptionPlaceholder:
'Mô tả ngắn về chức năng của kỹ năng này (hiển thị cho LLM)',
packageRoot: 'Thư mục gói',
packageRootHelp:
'Tùy chọn. Chỉ cần khi nhập một thư mục kỹ năng hiện có. Để trống với kỹ năng mới. Quá trình quét kiểm tra thư mục hiện tại và thư mục con sâu tối đa 2 cấp.',
importLocalDirectory: 'Nhập thư mục kỹ năng cục bộ',
chooseSkillDirectory: 'Chọn thư mục chứa SKILL.md',
chooseAnotherDirectory: 'Chọn thư mục khác',
importingDirectory: 'Đang tạo bản xem trước...',
clearDirectoryPreview: 'Xóa thư mục đã chọn',
noSkillMdInDirectory: 'Không tìm thấy SKILL.md trong thư mục đã chọn',
multipleSkillMdInDirectory:
'Thư mục đã chọn chứa nhiều tệp SKILL.md. Vui lòng chọn trực tiếp một thư mục kỹ năng duy nhất.',
importDirectoryError: 'Nhập thư mục thất bại: ',
advancedSettings: 'Cài đặt nâng cao',
searchSkills: 'Tìm kiếm kỹ năng...',
selectSkills: 'Chọn kỹ năng',
addSkill: 'Thêm kỹ năng',
builtin: 'Tích hợp sẵn',
importFromGithub: 'Cài đặt kỹ năng từ GitHub',
createManually: 'Tạo thủ công',
uploadZip: 'Tải lên gói ZIP',
uploadZipOnly: 'Chỉ hỗ trợ gói kỹ năng .zip',
installSuccess: 'Đã cài đặt kỹ năng thành công',
installError: 'Cài đặt kỹ năng thất bại: ',
enterRepoUrl: 'Nhập URL kho GitHub',
repoUrlPlaceholder: 'ví dụ: https://github.com/owner/repo',
fetchingReleases: 'Đang tải release...',
selectRelease: 'Chọn release',
noReleasesFound: 'Không tìm thấy release',
fetchReleasesError: 'Không thể tải release: ',
selectAsset: 'Chọn tệp để cài đặt',
sourceArchive: 'Mã nguồn (zip)',
noAssetsFound: 'Không có tệp có thể cài đặt trong release này',
fetchAssetsError: 'Không thể tải tệp: ',
backToReleases: 'Quay lại release',
backToRepoUrl: 'Quay lại URL kho',
backToAssets: 'Quay lại tệp',
releaseTag: 'Thẻ: {{tag}}',
publishedAt: 'Phát hành lúc: {{date}}',
prerelease: 'Bản phát hành trước',
assetSize: 'Kích thước: {{size}}',
confirmInstall: 'Xác nhận cài đặt',
installing: 'Đang cài đặt kỹ năng...',
loading: 'Đang tải...',
previewLoadError: 'Không thể tải bản xem trước',
selectFromSidebar: 'Chọn một kỹ năng từ thanh bên',
dangerZone: 'Vùng nguy hiểm',
dangerZoneDescription: 'Các thao tác không thể hoàn tác và có tính phá hủy',
files: 'Tệp',
noFiles: 'Không tìm thấy tệp',
loadFilesError: 'Không thể tải tệp: ',
readFileError: 'Không thể đọc tệp: ',
saveFile: 'Lưu tệp',
saveFileSuccess: 'Đã lưu tệp thành công',
saveFileError: 'Lưu tệp thất bại: ',
},
addExtension: {
installTitle: 'Cài đặt {{type}}',
installConfirm: 'Cài đặt {{type}} "{{name}}"?',
installInfoType: 'Loại',
installInfoId: 'ID',
installInfoVersion: 'Phiên bản',
installSuccess: 'Cài đặt thành công',
installStage: {
mcpInstalling: 'Đang thêm và kết nối máy chủ MCP…',
skillInstalling: 'Đang cài đặt kỹ năng…',
installed: 'Hoàn tất',
},
manualAdd: 'Thêm thủ công',
uploadExtension: 'Kéo thả hoặc nhấp để tải lên',
uploadHint: 'Hỗ trợ tệp .zip (kỹ năng) và .lbpkg (plugin)',
orContinueWith: 'hoặc chọn một thao tác bên dưới',
addMCPServerHint: 'Kết nối tiện ích máy chủ công cụ MCP',
installFromGithub: 'Cài đặt từ GitHub',
installFromGithubHint: 'Gói plugin hoặc kỹ năng (SKILL.md)',
githubUrlHelp: 'Dán URL GitHub',
githubUrlTooltip:
'Plugin: dán URL kho, Release hoặc Tag. Kỹ năng: dán URL trang SKILL.md trong thư mục kỹ năng.',
githubUrlPlaceholder: 'Kho GitHub, Release hoặc liên kết SKILL.md',
githubUrlRequired: 'Nhập URL GitHub',
previewSkill: 'Xem trước kỹ năng',
noSkillPreviewFound: 'Không tìm thấy kỹ năng có thể nhập',
createSkill: 'Tạo kỹ năng mới',
createSkillHint: 'Nhập từ thư mục cục bộ hoặc tạo thủ công',
unsupportedFileType:
'Loại tệp không được hỗ trợ. Chỉ hỗ trợ tệp .zip và .lbpkg',
},
};
export default viVN;

View File

@@ -2,12 +2,16 @@ const zhHans = {
sidebar: {
home: '首页',
extensions: '扩展',
installedPlugins: '已安装插件',
pluginMarket: '插件市场',
installedPlugins: '已安装扩展',
pluginMarket: '扩展市场',
mcpServers: 'MCP 服务器',
addExtension: '添加扩展',
pluginPages: '插件页面',
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
quickStart: '快速开始向导',
scrollToBottom: '滚动到底部',
editionCommunity: '社区版',
editionCloud: 'Cloud',
},
common: {
login: '登录',
@@ -37,6 +41,7 @@ const zhHans = {
delete: '删除',
add: '添加',
select: '请选择',
skill: '技能',
cancel: '取消',
submit: '提交',
error: '错误',
@@ -65,6 +70,7 @@ const zhHans = {
test: '测试',
forgotPassword: '忘记密码?',
agreementNotice: '继续即表示您同意我们的',
termsOfService: '服务条款',
privacyPolicy: '隐私政策',
and: '和',
dataCollectionPolicy: '数据收集政策',
@@ -412,10 +418,11 @@ const zhHans = {
createPlugin: '创建插件',
editPlugin: '编辑插件',
installed: '已安装',
marketplace: '插件市场',
marketplace: '扩展市场',
arrange: '编排',
install: '安装',
installPlugin: '安装插件',
newPlugin: '新建插件',
onlySupportGithub: '目前仅支持从 GitHub 安装',
enterGithubLink: '请输入插件的Github链接',
installing: '正在安装插件...',
@@ -431,6 +438,9 @@ const zhHans = {
getPluginListError: '获取插件列表失败:',
pluginConfig: '插件配置',
noPluginInstalled: '暂未安装任何插件',
noExtensionInstalled: '暂未安装任何扩展',
loadingExtensions: '正在加载扩展...',
groupByType: '按格式分组',
pluginSort: '插件排序',
pluginSortDescription:
'插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序',
@@ -451,6 +461,19 @@ const zhHans = {
debugKey: '调试密钥',
noDebugKey: '(未设置)',
debugKeyDisabled: '未设置调试密钥,插件调试无需认证',
boxStatusTitle: 'Box 运行时',
boxStatus: '状态',
boxConnected: '已连接',
boxUnavailable: '不可用',
boxBackend: '后端',
boxProfile: '配置',
boxSandboxes: '沙箱数',
boxErrors: '错误数',
boxSessionImage: '镜像',
boxSessionBackend: '后端',
boxSessionResources: '资源',
boxSessionNetwork: '网络',
boxStatusLoadFailed: '加载 Box 状态失败',
failedToGetDebugInfo: '获取调试信息失败',
copiedToClipboard: '已复制到剪贴板',
deleting: '删除中...',
@@ -465,6 +488,8 @@ const zhHans = {
close: '关闭',
deleteConfirm: '删除确认',
deleteSuccess: '删除成功',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
modifyFailed: '修改失败:',
componentName: {
Tool: '工具',
@@ -477,13 +502,30 @@ const zhHans = {
uploadLocal: '本地上传',
debugging: '调试中',
uploadLocalPlugin: '上传本地插件',
localPreview: {
title: '预览本地插件包',
unpacking: '正在解包预览...',
unpackComplete: '解包预览完成',
failed: '解包预览失败',
pluginInfo: '插件信息',
packageInfo: '包信息',
name: '名称',
author: '作者',
version: '版本',
fileCount: '文件数',
dependencies: '依赖',
components: '组件',
ready: '插件包已解包,确认后开始安装。',
},
uploadPluginOnly: '仅支持 .lbpkg 文件',
dragToUpload: '拖拽文件到此处上传',
unsupportedFileType: '不支持的文件类型,仅支持 .lbpkg 和 .zip 文件',
unsupportedFileType: '不支持的文件类型,仅支持 .lbpkg 文件',
uploadingPlugin: '正在上传插件...',
uploadSuccess: '上传成功',
uploadFailed: '上传失败',
selectFileToUpload: '选择要上传的插件文件',
askConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?',
askConfirmNoVersion: '确定要安装插件 "{{name}}" 吗?',
fromGithub: '来自 GitHub',
fromLocal: '本地安装',
fromMarketplace: '来自市场',
@@ -533,21 +575,27 @@ const zhHans = {
assetSize: '大小: {{size}}',
confirmInstall: '确认安装',
installFromGithubDesc: '从 GitHub Release 安装插件',
goToMarketplace: '前往插件市场',
goToMarketplace: '前往扩展市场',
installProgress: {
title: '正在安装 {{name}}',
titleGeneric: '插件安装',
titleGeneric: '扩展安装',
titlePlugin: '正在安装插件 {{name}}',
titleMCP: '正在安装 MCP 服务器 {{name}}',
titleSkill: '正在安装技能 {{name}}',
overallProgress: '总体进度',
downloading: '下载插件',
downloading: '下载',
installingDeps: '安装依赖',
initializing: '初始化配置',
launching: '启动插件',
launching: '启动',
completed: '已完成',
failed: '安装失败',
downloadSize: '包大小: {{size}}',
depsInfo: '共 {{count}} 个依赖需要安装',
depsProgress: '已安装 {{installed}}/{{total}} · 剩余 {{remaining}} 个',
installComplete: '插件安装成功',
installComplete: '安装成功',
installCompletePlugin: '插件安装成功',
installCompleteMCP: 'MCP 服务器安装成功',
installCompleteSkill: '技能安装成功',
dismiss: '关闭',
background: '后台运行',
taskQueue: '安装任务',
@@ -565,6 +613,7 @@ const zhHans = {
loading: '加载中...',
allLoaded: '已显示全部插件',
install: '安装',
installCard: '安装 {{name}}',
installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?',
downloadComplete: '插件 "{{name}}" 下载完成',
installFailed: '安装失败,请稍后重试',
@@ -596,8 +645,23 @@ const zhHans = {
markAsRead: '已读',
markAsReadSuccess: '已标记为已读',
markAsReadFailed: '标记为已读失败',
filterByComponent: '组件',
filterByComponent: '插件组件',
filterByComponentHint:
'插件提供的能力类型如工具Tool、命令Command、事件监听器EventListener用于扩展 LangBot 的各项能力。按组件筛选可只看提供对应能力的插件。',
allComponents: '全部组件',
componentName: {
Tool: '工具',
EventListener: '事件监听器',
Command: '命令',
KnowledgeEngine: '知识引擎',
Parser: '解析器',
Page: '页面',
},
filterByType: '类型',
allTypes: '全部类型',
typePlugin: '插件',
typeMCP: 'MCP',
typeSkill: '技能',
requestPlugin: '请求插件',
tags: {
filterByTags: '按标签筛选',
@@ -606,6 +670,15 @@ const zhHans = {
clearAll: '清空',
noTags: '暂无标签',
},
filters: {
allFormats: '全部格式',
more: '筛选',
advancedTitle: '高级筛选',
advancedDescription:
'普通用户通常不需要选择这些类型;仅在你明确知道扩展格式时使用。',
technicalType: '扩展格式',
},
allExtensions: '全部扩展',
viewDetails: '查看详情',
deprecated: '已弃用',
deprecatedTooltip: '请安装对应「知识引擎」插件',
@@ -613,6 +686,7 @@ const zhHans = {
mcp: {
title: 'MCP',
createServer: '添加 MCP 服务器',
addMCPServer: '添加 MCP 服务器',
editServer: '修改 MCP 服务器',
deleteServer: '删除 MCP 服务器',
confirmDeleteServer: '你确定要删除此 MCP 服务器吗?',
@@ -650,6 +724,15 @@ const zhHans = {
connectionSuccess: '连接成功',
connectionFailed: '连接失败请检查URL',
connectionFailedStatus: '连接失败',
boxDisabledStdioRefused:
'Stdio 模式的 MCP 服务器依赖 Box 沙箱目前已在配置中禁用box.enabled = false。',
boxUnavailableStdioRefused:
'Stdio 模式的 MCP 服务器依赖 Box 沙箱,目前无法连接。',
boxStdioRefusedSuggestion:
'请启用 Boxbox.enabled = true并确认运行时连接正常或将此服务器切换到 http/sse 模式。',
boxRequired: '需要 Box',
stdioBlockedByBoxToast:
'Box 沙箱已禁用或不可用,无法保存 stdio 模式的 MCP。请启用 Box 或改为 http/sse 模式。',
toolsFound: '个工具',
unknownError: '未知错误',
noToolsFound: '未找到任何工具',
@@ -668,8 +751,10 @@ const zhHans = {
loadFailed: '加载失败',
modifyFailed: '修改失败:',
toolCount: '工具:{{count}}',
statusConnected: '已打开',
statusDisconnected: '未打开',
parameterCount: '参数:{{count}}',
noParameters: '无参数',
statusConnected: '已连接',
statusDisconnected: '未连接',
statusError: '连接错误',
statusDisabled: '已禁用',
loading: '加载中...',
@@ -762,8 +847,15 @@ const zhHans = {
selectAll: '全选',
enableAllPlugins: '启用所有插件',
enableAllMCPServers: '启用所有 MCP 服务器',
enableAllSkills: '启用所有技能',
allPluginsEnabled: '已启用所有插件',
allMCPServersEnabled: '已启用所有 MCP 服务器',
allSkillsEnabled: '已启用所有技能',
skillsTitle: '技能',
noSkillsSelected: '未选择任何技能',
addSkill: '添加技能',
selectSkills: '选择技能',
noSkillsAvailable: '暂无可用技能',
},
debugDialog: {
title: '流水线对话',
@@ -862,7 +954,7 @@ const zhHans = {
builtInParser: '由知识引擎提供',
noParserAvailable:
'没有解析器支持此文件类型,请安装支持该格式的解析器插件。',
installParserHint: '前往插件市场安装解析器 →',
installParserHint: '前往扩展市场安装解析器 →',
confirmUpload: '上传',
cancelUpload: '取消',
},
@@ -1174,6 +1266,25 @@ const zhHans = {
sessions: '会话记录',
feedback: '用户反馈',
},
systemStatus: '系统状态',
pluginRuntime: '插件运行时',
boxRuntime: 'Box 运行时',
connected: '已连接',
disconnected: '未连接',
disabled: '已禁用',
statusDetail: '状态',
pluginDisabled: '插件系统已禁用',
boxDisabled:
'Box 沙箱已在配置中禁用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
boxUnavailable:
'Box 沙箱不可用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
boxRequiredHint:
'此功能依赖 Box 运行时。请在配置中启用box.enabled = true并确认运行时连接正常。',
boxBackend: '后端',
boxProfile: '配置',
boxSandboxes: '沙箱数',
boxSessionCreated: '创建时间',
boxSessionLastUsed: '最后使用',
},
storageAnalysis: {
title: '存储分析',
@@ -1216,7 +1327,96 @@ const zhHans = {
maxPipelinesReached:
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
maxExtensionsReached:
'已达到扩展数量上限({{max}}个)。请先删除已有的 MCP 服务器或插件后再添加新的。',
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
},
skills: {
title: '技能',
description: '创建和管理可在对话中激活的技能',
createSkill: '创建技能',
createSkillDescription: '导入本地目录或手动填写信息创建',
editSkill: '编辑技能',
getSkillListError: '获取技能列表失败:',
skillName: '技能名称',
displayName: '技能名称',
displayNamePlaceholder: '显示名称(支持中文)',
skillSlug: '目录名称',
skillSlugPlaceholder: 'english-name-only',
skillSlugHelp: '用作技能目录名,仅支持英文字母、数字、连字符和下划线。',
skillDescription: '技能描述',
skillInstructions: '指令内容',
saveSuccess: '保存成功',
saveError: '保存失败:',
createSuccess: '创建成功',
createError: '创建失败:',
deleteSuccess: '删除成功',
deleteError: '删除失败:',
deleteConfirmation: '你确定要删除这个技能吗?',
delete: '删除技能',
skillNameRequired: '技能名称不能为空',
skillDescriptionRequired: '技能描述不能为空',
packageRootRequired: '技能目录不能为空',
scan: '扫描',
scanSuccess: '目录扫描成功',
scanError: '扫描目录失败:',
noSkills: '暂未配置任何技能',
preview: '预览',
previewInstructions: 'SKILL.md 内容预览',
instructionsPlaceholder: '使用 Markdown 格式输入技能指令...',
descriptionPlaceholder: '简短描述此技能的功能(会展示给 LLM',
packageRoot: '技能目录',
packageRootHelp:
'非必填。仅在导入已有技能目录时需要填写,新建技能可留空。扫描会检查当前目录及两级子目录。',
importLocalDirectory: '导入本地技能目录',
chooseSkillDirectory: '选择 SKILL.md 所在目录',
chooseAnotherDirectory: '重新选择目录',
importingDirectory: '正在预览...',
clearDirectoryPreview: '清除已选目录',
noSkillMdInDirectory: '选择的目录中没有找到 SKILL.md',
multipleSkillMdInDirectory:
'选择的目录中包含多个 SKILL.md请直接选择单个技能目录。',
importDirectoryError: '导入目录失败:',
advancedSettings: '高级设置',
searchSkills: '搜索技能...',
selectSkills: '选择技能',
builtin: '内置',
addSkill: '添加技能',
importFromGithub: '从 GitHub 安装技能',
createManually: '手动创建',
uploadZip: '上传 ZIP 包',
uploadZipOnly: '仅支持 .zip 技能包',
installSuccess: '技能安装成功',
installError: '安装技能失败:',
enterRepoUrl: '输入 GitHub 仓库地址',
repoUrlPlaceholder: '例如 https://github.com/owner/repo',
fetchingReleases: '正在获取发布版本...',
selectRelease: '选择发布版本',
noReleasesFound: '未找到发布版本',
fetchReleasesError: '获取发布版本失败:',
selectAsset: '选择要安装的文件',
sourceArchive: '源码包 (zip)',
noAssetsFound: '此版本暂无可安装的文件',
fetchAssetsError: '获取文件列表失败:',
backToReleases: '返回版本列表',
backToRepoUrl: '返回仓库地址',
backToAssets: '返回文件列表',
releaseTag: '标签:{{tag}}',
publishedAt: '发布于:{{date}}',
prerelease: '预发布',
assetSize: '大小:{{size}}',
confirmInstall: '确认安装',
installing: '正在安装技能...',
loading: '加载中...',
previewLoadError: '加载预览失败',
selectFromSidebar: '从侧边栏选择一个技能',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
files: '文件',
noFiles: '暂无文件',
loadFilesError: '加载文件失败:',
readFileError: '读取文件失败:',
saveFile: '保存文件',
saveFileSuccess: '文件保存成功',
saveFileError: '保存文件失败:',
},
wizard: {
sidebarDescription: '通过引导步骤创建机器人',
@@ -1278,6 +1478,48 @@ const zhHans = {
backToWorkbench: '返回工作台',
},
},
addExtension: {
installTitle: '安装{{type}}',
installConfirm: '确定要安装{{type}} "{{name}}" 吗?',
installInfoType: '类型',
installInfoId: '标识',
installInfoVersion: '版本',
installSuccess: '安装成功',
installStage: {
mcpInstalling: '正在添加并连接 MCP 服务器…',
skillInstalling: '正在安装技能…',
installed: '已完成',
},
manualAdd: '手动添加',
uploadExtension: '拖拽或点击上传扩展包',
uploadHint: '支持 .zip技能和 .lbpkg插件文件',
orContinueWith: '或选择以下操作',
addMCPServerHint: '连接一个 MCP 工具服务器扩展',
installFromGithub: '从 GitHub 安装',
installFromGithubHint: '插件包或技能SKILL.md',
githubUrlHelp: '粘贴 GitHub 地址',
githubUrlTooltip:
'插件粘贴仓库、Release 或 Tag 地址。技能:粘贴技能目录里的 SKILL.md 页面地址。',
githubUrlPlaceholder: 'GitHub 仓库、Release 或 SKILL.md 链接',
githubUrlRequired: '请输入 GitHub 地址',
previewSkill: '预览技能',
noSkillPreviewFound: '未找到可导入的技能',
createSkill: '创建新的技能',
createSkillHint: '从本地目录导入或手动创建',
unsupportedFileType: '不支持的文件类型,仅支持 .zip 和 .lbpkg 文件',
},
errorPage: {
unexpectedError: '出错了',
unexpectedErrorDescription: '发生了意外错误,请稍后重试。',
notFound: '页面未找到',
notFoundDescription: '你访问的页面不存在或已被移动。',
backendUnavailableStatus: '后端服务不可用',
goBack: '返回上页',
backToHome: '返回首页',
backToLogin: '返回登录',
retrying: '正在重试',
retryFailed: '仍然无法连接后端,请确认服务已启动后再重试。',
},
feishu: {
createApp: '一键创建飞书应用',
scanQRCode: '请使用飞书扫描以下二维码,授权后将自动创建应用并填写凭据',

View File

@@ -5,9 +5,13 @@ const zhHant = {
installedPlugins: '已安裝外掛',
pluginMarket: '外掛市場',
mcpServers: 'MCP 伺服器',
addExtension: '添加擴展',
pluginPages: '插件頁面',
pluginPagesTooltip: '由已安裝的插件提供的視覺化頁面',
quickStart: '快速開始',
scrollToBottom: '捲動到底部',
editionCommunity: '社區版',
editionCloud: 'Cloud',
},
common: {
login: '登入',
@@ -37,6 +41,7 @@ const zhHant = {
delete: '刪除',
add: '新增',
select: '請選擇',
skill: '技能',
cancel: '取消',
submit: '提交',
error: '錯誤',
@@ -65,6 +70,7 @@ const zhHant = {
test: '測試',
forgotPassword: '忘記密碼?',
agreementNotice: '繼續即表示您同意我們的',
termsOfService: '服務條款',
privacyPolicy: '隱私政策',
and: '和',
dataCollectionPolicy: '數據收集政策',
@@ -416,6 +422,7 @@ const zhHant = {
arrange: '編排',
install: '安裝',
installPlugin: '安裝外掛',
newPlugin: '新建外掛',
installFromGithub: '來自 GitHub',
onlySupportGithub: '目前僅支援從 GitHub 安裝',
enterGithubLink: '請輸入外掛的Github連結',
@@ -432,6 +439,9 @@ const zhHant = {
getPluginListError: '取得外掛清單失敗:',
pluginConfig: '外掛設定',
noPluginInstalled: '暫未安裝任何外掛',
noExtensionInstalled: '暫未安裝任何擴充功能',
loadingExtensions: '正在載入擴充功能...',
groupByType: '依格式分組',
pluginSort: '外掛排序',
pluginSortDescription:
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',
@@ -452,6 +462,19 @@ const zhHant = {
debugKey: '偵錯金鑰',
noDebugKey: '(未設定)',
debugKeyDisabled: '未設定偵錯金鑰,外掛偵錯無需認證',
boxStatusTitle: 'Box 執行時',
boxStatus: '狀態',
boxConnected: '已連線',
boxUnavailable: '不可用',
boxBackend: '後端',
boxProfile: '設定檔',
boxSandboxes: '沙箱數',
boxErrors: '錯誤數',
boxSessionImage: '映像檔',
boxSessionBackend: '後端',
boxSessionResources: '資源',
boxSessionNetwork: '網路',
boxStatusLoadFailed: '載入 Box 狀態失敗',
failedToGetDebugInfo: '取得偵錯資訊失敗',
copiedToClipboard: '已複製到剪貼簿',
deleting: '刪除中...',
@@ -466,6 +489,8 @@ const zhHant = {
close: '關閉',
deleteConfirm: '刪除確認',
deleteSuccess: '刪除成功',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆的操作',
modifyFailed: '修改失敗:',
componentName: {
Tool: '工具',
@@ -478,6 +503,21 @@ const zhHant = {
uploadLocal: '本地上傳',
debugging: '調試中',
uploadLocalPlugin: '上傳本地插件',
localPreview: {
title: '預覽本地外掛包',
unpacking: '正在解包預覽...',
unpackComplete: '解包預覽完成',
failed: '解包預覽失敗',
pluginInfo: '外掛資訊',
packageInfo: '包資訊',
name: '名稱',
author: '作者',
version: '版本',
fileCount: '檔案數',
dependencies: '依賴',
components: '元件',
ready: '外掛包已解包,確認後開始安裝。',
},
dragToUpload: '拖拽文件到此處上傳',
unsupportedFileType: '不支持的文件類型,僅支持 .lbpkg 和 .zip 文件',
uploadingPlugin: '正在上傳插件...',
@@ -485,6 +525,7 @@ const zhHant = {
uploadFailed: '上傳失敗',
selectFileToUpload: '選擇要上傳的插件文件',
askConfirm: '確定要安裝插件 "{{name}}" ({{version}}) 嗎?',
askConfirmNoVersion: '確定要安裝插件 "{{name}}" 嗎?',
fromGithub: '來自 GitHub',
fromLocal: '本地安裝',
fromMarketplace: '來自市場',
@@ -553,7 +594,14 @@ const zhHant = {
taskQueue: '安裝任務',
clearCompleted: '清除已完成',
noTasks: '暫無安裝任務',
titlePlugin: '正在安裝外掛 {{name}}',
titleMCP: '正在安裝 MCP 伺服器 {{name}}',
titleSkill: '正在安裝技能 {{name}}',
installCompletePlugin: '外掛安裝成功',
installCompleteMCP: 'MCP 伺服器安裝成功',
installCompleteSkill: '技能安裝成功',
},
uploadPluginOnly: '僅支援 .lbpkg 外掛包',
},
market: {
searchPlaceholder: '搜尋插件...',
@@ -565,6 +613,7 @@ const zhHant = {
loading: '載入中...',
allLoaded: '已顯示全部插件',
install: '安裝',
installCard: '安裝 {{name}}',
installConfirm: '確定要安裝插件 "{{name}}" ({{version}}) 嗎?',
downloadComplete: '插件 "{{name}}" 下載完成',
installFailed: '安裝失敗,請稍後重試',
@@ -596,8 +645,23 @@ const zhHant = {
markAsRead: '已讀',
markAsReadSuccess: '已標記為已讀',
markAsReadFailed: '標記為已讀失敗',
filterByComponent: '組件',
filterByComponent: '插件組件',
filterByComponentHint:
'插件提供的能力類型如工具Tool、命令Command、事件監聽器EventListener用於擴展 LangBot 的各項能力。按組件篩選可只看提供對應能力的插件。',
allComponents: '全部組件',
componentName: {
Tool: '工具',
EventListener: '事件監聽器',
Command: '命令',
KnowledgeEngine: '知識引擎',
Parser: '解析器',
Page: '擴展頁',
},
filterByType: '類型',
allTypes: '全部類型',
typePlugin: '插件',
typeMCP: 'MCP',
typeSkill: '技能',
requestPlugin: '請求插件',
tags: {
filterByTags: '按標籤篩選',
@@ -606,6 +670,14 @@ const zhHant = {
clearAll: '清空',
noTags: '暫無標籤',
},
filters: {
allFormats: '全部類型',
more: '更多',
advancedTitle: '高級篩選',
advancedDescription: '按擴展類型篩選',
technicalType: '技術類型',
},
allExtensions: '全部擴展',
viewDetails: '查看詳情',
deprecated: '已棄用',
deprecatedTooltip: '請安裝對應「知識引擎」插件',
@@ -613,6 +685,7 @@ const zhHant = {
mcp: {
title: 'MCP',
createServer: '新增MCP伺服器',
addMCPServer: '新增 MCP 伺服器',
editServer: '編輯MCP伺服器',
deleteServer: '刪除MCP伺服器',
confirmDeleteServer: '您確定要刪除此MCP伺服器嗎',
@@ -650,6 +723,15 @@ const zhHant = {
connectionSuccess: '連接成功',
connectionFailed: '連接失敗請檢查URL',
connectionFailedStatus: '連接失敗',
boxDisabledStdioRefused:
'Stdio 模式的 MCP 伺服器依賴 Box 沙箱目前已在設定中停用box.enabled = false。',
boxUnavailableStdioRefused:
'Stdio 模式的 MCP 伺服器依賴 Box 沙箱,目前無法連線。',
boxStdioRefusedSuggestion:
'請啟用 Boxbox.enabled = true並確認執行時連線正常或將此伺服器切換到 http/sse 模式。',
boxRequired: '需要 Box',
stdioBlockedByBoxToast:
'Box 沙箱已停用或無法使用,無法儲存 stdio 模式的 MCP。請啟用 Box 或改為 http/sse 模式。',
toolsFound: '個工具',
unknownError: '未知錯誤',
noToolsFound: '未找到任何工具',
@@ -668,8 +750,10 @@ const zhHant = {
loadFailed: '載入失敗',
modifyFailed: '修改失敗:',
toolCount: '工具:{{count}}',
statusConnected: '已開啟',
statusDisconnected: '未開啟',
parameterCount: '參數:{{count}}',
noParameters: '無參數',
statusConnected: '已連線',
statusDisconnected: '未連線',
statusError: '連接錯誤',
statusDisabled: '已停用',
loading: '載入中...',
@@ -764,6 +848,13 @@ const zhHant = {
enableAllMCPServers: '啟用所有 MCP 伺服器',
allPluginsEnabled: '已啟用所有插件',
allMCPServersEnabled: '已啟用所有 MCP 伺服器',
enableAllSkills: '啟用全部技能',
allSkillsEnabled: '已啟用全部技能',
skillsTitle: '技能',
noSkillsSelected: '未選擇技能',
addSkill: '新增技能',
selectSkills: '選擇技能',
noSkillsAvailable: '暫無可用技能',
},
debugDialog: {
title: '流程線對話',
@@ -1174,6 +1265,25 @@ const zhHant = {
sessions: '會話記錄',
feedback: '使用者回饋',
},
systemStatus: '系統狀態',
pluginRuntime: '外掛執行時',
boxRuntime: 'Box 執行時',
connected: '已連線',
disconnected: '未連線',
disabled: '已停用',
statusDetail: '狀態',
pluginDisabled: '外掛系統已停用',
boxDisabled:
'Box 沙箱已在設定中停用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
boxUnavailable:
'Box 沙箱無法使用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
boxRequiredHint:
'此功能需要 Box 執行時。請在設定中啟用box.enabled = true並確認執行時連線正常。',
boxBackend: '後端',
boxProfile: '設定檔',
boxSandboxes: '沙箱數',
boxSessionCreated: '建立時間',
boxSessionLastUsed: '最後使用',
},
storageAnalysis: {
title: '儲存分析',
@@ -1216,7 +1326,7 @@ const zhHant = {
maxPipelinesReached:
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
maxExtensionsReached:
'已達到擴充功能數量上限({{max}}個)。請先刪除已有的 MCP 伺服器或外掛後再新增。',
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
},
wizard: {
sidebarDescription: '透過引導步驟建立機器人',
@@ -1278,6 +1388,48 @@ const zhHant = {
backToWorkbench: '返回工作台',
},
},
addExtension: {
installTitle: '安裝{{type}}',
installConfirm: '確定要安裝{{type}}「{{name}}」嗎?',
installInfoType: '類型',
installInfoId: 'ID',
installInfoVersion: '版本',
installSuccess: '安裝成功',
installStage: {
mcpInstalling: '正在新增並連接 MCP 伺服器…',
skillInstalling: '正在安裝技能…',
installed: '完成',
},
manualAdd: '手動新增',
uploadExtension: '拖拽或點擊上傳擴充套件',
uploadHint: '支援 .zip技能和 .lbpkg插件檔案',
orContinueWith: '或選擇以下操作',
addMCPServerHint: '連接一個 MCP 工具伺服器擴充',
installFromGithub: '從 GitHub 安裝',
installFromGithubHint: '插件包或技能SKILL.md',
githubUrlHelp: '貼上 GitHub 地址',
githubUrlTooltip:
'插件貼上倉庫、Release 或 Tag 地址。技能:貼上技能目錄裡的 SKILL.md 頁面地址。',
githubUrlPlaceholder: 'GitHub 倉庫、Release 或 SKILL.md 連結',
githubUrlRequired: '請輸入 GitHub 地址',
previewSkill: '預覽技能',
noSkillPreviewFound: '未找到可匯入的技能',
createSkill: '建立新的技能',
createSkillHint: '從本地目錄匯入或手動建立',
unsupportedFileType: '不支援的檔案類型,僅支援 .zip 和 .lbpkg 檔案',
},
errorPage: {
unexpectedError: '出錯了',
unexpectedErrorDescription: '發生了意外錯誤,請稍後重試。',
notFound: '頁面未找到',
notFoundDescription: '你訪問的頁面不存在或已被移動。',
backendUnavailableStatus: '後端服務不可用',
goBack: '返回上頁',
backToHome: '返回首頁',
backToLogin: '返回登入',
retrying: '正在重試',
retryFailed: '仍然無法連接後端,請確認服務已啟動後再重試。',
},
feishu: {
createApp: '一鍵建立飛書應用',
scanQRCode: '請使用飛書掃描以下 QR Code授權後將自動建立應用並填寫憑證',
@@ -1323,6 +1475,94 @@ const zhHant = {
selectFromSidebar: '從側邊欄選擇一個插件頁面',
invalidPage: '無效的插件頁面',
},
skills: {
title: '技能',
description: '創建和管理可在對話中激活的技能',
createSkill: '創建技能',
createSkillDescription: '匯入本機目錄或手動填寫資訊建立',
editSkill: '編輯技能',
getSkillListError: '獲取技能列表失敗:',
skillName: '技能名稱',
displayName: '技能名稱',
displayNamePlaceholder: '顯示名稱',
skillSlug: '目錄名稱',
skillSlugPlaceholder: 'english-name-only',
skillSlugHelp: '用作技能目錄名,僅支援英文字母、數字、連字符和底線。',
skillDescription: '技能描述',
skillInstructions: '指令內容',
saveSuccess: '儲存成功',
saveError: '儲存失敗:',
createSuccess: '創建成功',
createError: '創建失敗:',
deleteSuccess: '刪除成功',
deleteError: '刪除失敗:',
deleteConfirmation: '你確定要刪除這個技能嗎?',
delete: '刪除技能',
skillNameRequired: '技能名稱不能為空',
skillDescriptionRequired: '技能描述不能為空',
packageRootRequired: '技能目錄不能為空',
scan: '掃描',
scanSuccess: '目錄掃描成功',
scanError: '掃描目錄失敗:',
noSkills: '暫未配置任何技能',
preview: '預覽',
previewInstructions: 'SKILL.md 內容預覽',
instructionsPlaceholder: '使用 Markdown 格式輸入技能指令...',
descriptionPlaceholder: '簡短描述此技能的功能',
packageRoot: '技能目錄',
packageRootHelp: '非必填。僅在導入已有技能目錄時需要填寫。',
importLocalDirectory: '匯入本地技能目錄',
chooseSkillDirectory: '選擇 SKILL.md 所在目錄',
chooseAnotherDirectory: '重新選擇目錄',
importingDirectory: '正在預覽...',
clearDirectoryPreview: '清除已選目錄',
noSkillMdInDirectory: '選擇的目錄中沒有找到 SKILL.md',
multipleSkillMdInDirectory:
'選擇的目錄中包含多個 SKILL.md請直接選擇單個技能目錄。',
importDirectoryError: '匯入目錄失敗:',
advancedSettings: '進階設定',
searchSkills: '搜尋技能...',
selectSkills: '選擇技能',
builtin: '內建',
addSkill: '添加技能',
importFromGithub: '從 GitHub 安裝技能',
createManually: '手動創建',
uploadZip: '上傳 ZIP 包',
uploadZipOnly: '僅支援 .zip 技能包',
installSuccess: '技能安裝成功',
installError: '安裝技能失敗:',
enterRepoUrl: '輸入 GitHub 倉庫地址',
repoUrlPlaceholder: '例如 https://github.com/owner/repo',
fetchingReleases: '正在獲取發布版本...',
selectRelease: '選擇發布版本',
noReleasesFound: '未找到發布版本',
fetchReleasesError: '獲取發布版本失敗:',
selectAsset: '選擇要安裝的檔案',
sourceArchive: '源碼包 (zip)',
noAssetsFound: '此版本暫無可安裝的檔案',
fetchAssetsError: '獲取檔案列表失敗:',
backToReleases: '返回版本列表',
backToRepoUrl: '返回倉庫地址',
selectFromSidebar: '從側邊欄選擇一個技能',
backToAssets: '返回檔案列表',
releaseTag: '標籤:{{tag}}',
publishedAt: '發布時間:{{date}}',
prerelease: '預發布',
assetSize: '大小:{{size}}',
confirmInstall: '確認安裝',
installing: '正在安裝技能...',
loading: '載入中...',
previewLoadError: '載入預覽失敗',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆且具破壞性的操作',
files: '檔案',
noFiles: '未找到檔案',
loadFilesError: '載入檔案失敗:',
readFileError: '讀取檔案失敗:',
saveFile: '儲存檔案',
saveFileSuccess: '檔案儲存成功',
saveFileError: '檔案儲存失敗:',
},
};
export default zhHant;

View File

@@ -18,138 +18,160 @@ import MonitoringPage from '@/app/home/monitoring/page';
import BotsPage from '@/app/home/bots/page';
import PipelinesPage from '@/app/home/pipelines/page';
import PluginsPage from '@/app/home/plugins/page';
import MarketPage from '@/app/home/market/page';
import AddExtensionPage from '@/app/home/add-extension/page';
import MCPPage from '@/app/home/mcp/page';
import KnowledgePage from '@/app/home/knowledge/page';
import SkillsPage from '@/app/home/skills/page';
import ErrorPage from '@/components/ErrorPage';
import BackendUnavailablePage from '@/components/BackendUnavailablePage';
import PluginPagesPage from '@/app/home/plugin-pages/page';
const Loading = () => <div>Loading...</div>;
export const router = createBrowserRouter([
{
path: '/',
element: <Navigate to="/login" replace />,
},
{
path: '/login',
element: (
<LoginLayout>
<LoginPage />
</LoginLayout>
),
},
{
path: '/register',
element: (
<RegisterLayout>
<RegisterPage />
</RegisterLayout>
),
},
{
path: '/reset-password',
element: (
<ResetPasswordLayout>
<ResetPasswordPage />
</ResetPasswordLayout>
),
},
{
path: '/wizard',
element: <WizardPage />,
},
{
path: '/auth/space/callback',
element: <SpaceCallbackPage />,
},
{
path: '/home',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<HomePage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/monitoring',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<MonitoringPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/bots',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<BotsPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/pipelines',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<PipelinesPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/plugins',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<PluginsPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/market',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<MarketPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/mcp',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<MCPPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/knowledge',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<KnowledgePage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/plugin-pages',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<PluginPagesPage />
</HomeLayout>
</Suspense>
),
errorElement: <ErrorPage />,
children: [
{
path: '/',
element: <Navigate to="/login" replace />,
},
{
path: '/login',
element: (
<LoginLayout>
<LoginPage />
</LoginLayout>
),
},
{
path: '/register',
element: (
<RegisterLayout>
<RegisterPage />
</RegisterLayout>
),
},
{
path: '/reset-password',
element: (
<ResetPasswordLayout>
<ResetPasswordPage />
</ResetPasswordLayout>
),
},
{
path: '/wizard',
element: <WizardPage />,
},
{
path: '/backend-unavailable',
element: <BackendUnavailablePage />,
},
{
path: '/auth/space/callback',
element: <SpaceCallbackPage />,
},
{
path: '/home',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<HomePage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/monitoring',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<MonitoringPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/bots',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<BotsPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/pipelines',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<PipelinesPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/extensions',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<PluginsPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/add-extension',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<AddExtensionPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/mcp',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<MCPPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/knowledge',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<KnowledgePage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/skills',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<SkillsPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/plugin-pages',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<PluginPagesPage />
</HomeLayout>
</Suspense>
),
},
],
},
]);