mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 04:54:36 +00:00
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:
1389
web/src/app/home/add-extension/page.tsx
Normal file
1389
web/src/app/home/add-extension/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
53
web/src/app/home/components/BoxUnavailableNotice.tsx
Normal file
53
web/src/app/home/components/BoxUnavailableNotice.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 图片 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
203
web/src/app/home/plugins/components/PluginLocalPreviewPanel.tsx
Normal file
203
web/src/app/home/plugins/components/PluginLocalPreviewPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
220
web/src/app/home/skills/SkillDetailContent.tsx
Normal file
220
web/src/app/home/skills/SkillDetailContent.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
277
web/src/app/home/skills/components/SkillZipPreviewPanel.tsx
Normal file
277
web/src/app/home/skills/components/SkillZipPreviewPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1053
web/src/app/home/skills/components/skill-form/SkillForm.tsx
Normal file
1053
web/src/app/home/skills/components/skill-form/SkillForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
85
web/src/app/home/skills/page.tsx
Normal file
85
web/src/app/home/skills/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
64
web/src/app/infra/hooks/useBoxStatus.ts
Normal file
64
web/src/app/infra/hooks/useBoxStatus.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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** - 仅用于向后兼容的文件(已废弃)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
96
web/src/components/BackendUnavailablePage.tsx
Normal file
96
web/src/components/BackendUnavailablePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
web/src/components/ErrorPage.tsx
Normal file
59
web/src/components/ErrorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
'请启用 Box(box.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: '请使用飞书扫描以下二维码,授权后将自动创建应用并填写凭据',
|
||||
|
||||
@@ -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:
|
||||
'請啟用 Box(box.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;
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user