mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 20:14:36 +00:00
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>
This commit is contained in:
@@ -127,6 +127,9 @@ dev = [
|
||||
"ruff>=0.11.9",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
langbot-plugin = { path = "../langbot-plugin-sdk", editable = true }
|
||||
|
||||
[tool.ruff]
|
||||
# Exclude a variety of commonly ignored directories.
|
||||
exclude = [
|
||||
@@ -220,4 +223,3 @@ skip-magic-trailing-comma = false
|
||||
|
||||
# Like Black, automatically detect the appropriate line ending.
|
||||
line-ending = "auto"
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import argparse
|
||||
import sys
|
||||
import os
|
||||
|
||||
from langbot.pkg.utils import paths
|
||||
|
||||
# ASCII art banner
|
||||
asciiart = r"""
|
||||
_ ___ _
|
||||
@@ -87,7 +89,7 @@ def main():
|
||||
# Set up the working directory
|
||||
# When installed as a package, we need to handle the working directory differently
|
||||
# We'll create data directory in current working directory if not exists
|
||||
os.makedirs('data', exist_ok=True)
|
||||
os.makedirs(paths.get_data_root(), exist_ok=True)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
|
||||
@@ -73,15 +73,21 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
|
||||
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||
|
||||
# Get available skills
|
||||
available_skills = await self.ap.skill_service.list_skills()
|
||||
|
||||
extensions_prefs = pipeline.get('extensions_preferences', {})
|
||||
return self.success(
|
||||
data={
|
||||
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
|
||||
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
|
||||
'enable_all_skills': extensions_prefs.get('enable_all_skills', True),
|
||||
'bound_plugins': extensions_prefs.get('plugins', []),
|
||||
'available_plugins': plugins,
|
||||
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
||||
'available_mcp_servers': mcp_servers,
|
||||
'bound_skills': extensions_prefs.get('skills', []),
|
||||
'available_skills': available_skills,
|
||||
}
|
||||
)
|
||||
elif quart.request.method == 'PUT':
|
||||
@@ -89,11 +95,19 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
enable_all_plugins = json_data.get('enable_all_plugins', True)
|
||||
enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
|
||||
enable_all_skills = json_data.get('enable_all_skills', True)
|
||||
bound_plugins = json_data.get('bound_plugins', [])
|
||||
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
||||
bound_skills = json_data.get('bound_skills', [])
|
||||
|
||||
await self.ap.pipeline_service.update_pipeline_extensions(
|
||||
pipeline_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers
|
||||
pipeline_uuid,
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
enable_all_plugins,
|
||||
enable_all_mcp_servers,
|
||||
bound_skills=bound_skills,
|
||||
enable_all_skills=enable_all_skills,
|
||||
)
|
||||
|
||||
return self.success()
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
import httpx
|
||||
import uuid
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .....core import taskmgr
|
||||
from .. import group
|
||||
@@ -14,6 +15,43 @@ from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
@group.group_class('plugins', '/api/v1/plugins')
|
||||
class PluginsRouterGroup(group.RouterGroup):
|
||||
@staticmethod
|
||||
def _parse_github_repo_url(repo_url: str) -> dict | None:
|
||||
raw_url = str(repo_url or '').strip()
|
||||
if not raw_url:
|
||||
return None
|
||||
|
||||
if not re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', raw_url):
|
||||
raw_url = f'https://{raw_url}'
|
||||
|
||||
parsed = urlparse(raw_url)
|
||||
if parsed.netloc.lower() not in ('github.com', 'www.github.com'):
|
||||
return None
|
||||
|
||||
parts = [part for part in parsed.path.strip('/').split('/') if part]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
owner = parts[0]
|
||||
repo = parts[1]
|
||||
if repo.endswith('.git'):
|
||||
repo = repo[:-4]
|
||||
if not owner or not repo:
|
||||
return None
|
||||
|
||||
ref = ''
|
||||
subdir = ''
|
||||
if len(parts) >= 4 and parts[2] in ('tree', 'blob'):
|
||||
ref = parts[3]
|
||||
subdir = '/'.join(parts[4:]).strip('/')
|
||||
|
||||
return {
|
||||
'owner': owner,
|
||||
'repo': repo,
|
||||
'ref': ref,
|
||||
'subdir': subdir,
|
||||
}
|
||||
|
||||
async def _check_extensions_limit(self) -> str | None:
|
||||
"""Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise."""
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
@@ -151,17 +189,37 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
data = await quart.request.json
|
||||
repo_url = data.get('repo_url', '')
|
||||
|
||||
# Parse GitHub repository URL to extract owner and repo
|
||||
# Supports: https://github.com/owner/repo or github.com/owner/repo
|
||||
pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$'
|
||||
match = re.search(pattern, repo_url)
|
||||
|
||||
if not match:
|
||||
parsed_repo = self._parse_github_repo_url(repo_url)
|
||||
if not parsed_repo:
|
||||
return self.http_status(400, -1, 'Invalid GitHub repository URL')
|
||||
|
||||
owner, repo = match.groups()
|
||||
owner = parsed_repo['owner']
|
||||
repo = parsed_repo['repo']
|
||||
requested_ref = parsed_repo['ref']
|
||||
requested_subdir = parsed_repo['subdir']
|
||||
|
||||
try:
|
||||
if requested_ref:
|
||||
return self.success(
|
||||
data={
|
||||
'releases': [
|
||||
{
|
||||
'id': 0,
|
||||
'tag_name': requested_ref,
|
||||
'name': requested_ref,
|
||||
'published_at': '',
|
||||
'prerelease': False,
|
||||
'draft': False,
|
||||
'source_type': 'branch',
|
||||
'archive_url': f'https://api.github.com/repos/{owner}/{repo}/zipball/{requested_ref}',
|
||||
}
|
||||
],
|
||||
'owner': owner,
|
||||
'repo': repo,
|
||||
'source_subdir': requested_subdir,
|
||||
}
|
||||
)
|
||||
|
||||
# Fetch releases from GitHub API
|
||||
url = f'https://api.github.com/repos/{owner}/{repo}/releases'
|
||||
async with httpx.AsyncClient(
|
||||
@@ -187,7 +245,14 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo})
|
||||
return self.success(
|
||||
data={
|
||||
'releases': formatted_releases,
|
||||
'owner': owner,
|
||||
'repo': repo,
|
||||
'source_subdir': requested_subdir,
|
||||
}
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')
|
||||
|
||||
|
||||
146
src/langbot/pkg/api/http/controller/groups/skills.py
Normal file
146
src/langbot/pkg/api/http/controller/groups/skills.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('skills', '/api/v1/skills')
|
||||
class SkillsRouterGroup(group.RouterGroup):
|
||||
"""Skills management API endpoints."""
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def list_or_create_skills() -> quart.Response:
|
||||
if quart.request.method == 'GET':
|
||||
skills = await self.ap.skill_service.list_skills()
|
||||
return self.success(data={'skills': skills})
|
||||
|
||||
data = await quart.request.json
|
||||
if 'name' not in data or not data['name']:
|
||||
return self.http_status(400, -1, 'Missing required field: name')
|
||||
|
||||
try:
|
||||
skill = await self.ap.skill_service.create_skill(data)
|
||||
return self.success(data={'skill': skill})
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route('/<skill_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def get_update_delete_skill(skill_name: str) -> quart.Response:
|
||||
if quart.request.method == 'GET':
|
||||
skill = await self.ap.skill_service.get_skill(skill_name)
|
||||
if not skill:
|
||||
return self.http_status(404, -1, 'Skill not found')
|
||||
return self.success(data={'skill': skill})
|
||||
|
||||
if quart.request.method == 'PUT':
|
||||
data = await quart.request.json
|
||||
try:
|
||||
skill = await self.ap.skill_service.update_skill(skill_name, data)
|
||||
return self.success(data={'skill': skill})
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
try:
|
||||
await self.ap.skill_service.delete_skill(skill_name)
|
||||
return self.success()
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route('/<skill_name>/preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def preview_skill(skill_name: str) -> quart.Response:
|
||||
runtime_data = self.ap.skill_mgr.get_skill_runtime_data(skill_name)
|
||||
if not runtime_data:
|
||||
return self.http_status(404, -1, 'Skill not found')
|
||||
return self.success(data={'instructions': runtime_data['instructions']})
|
||||
|
||||
@self.route('/index', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def get_skill_index() -> quart.Response:
|
||||
pipeline_uuid = quart.request.args.get('pipeline_uuid')
|
||||
bound_skills = quart.request.args.getlist('bound_skills')
|
||||
skill_index = self.ap.skill_mgr.get_skill_index(
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
bound_skills=bound_skills if bound_skills else None,
|
||||
)
|
||||
return self.success(data={'index': skill_index})
|
||||
|
||||
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def install_skill_from_github() -> quart.Response:
|
||||
data = await quart.request.json
|
||||
required_fields = ['asset_url', 'owner', 'repo', 'release_tag']
|
||||
for field in required_fields:
|
||||
if field not in data or not data[field]:
|
||||
return self.http_status(400, -1, f'Missing required field: {field}')
|
||||
|
||||
try:
|
||||
skill = await self.ap.skill_service.install_from_github(data)
|
||||
return self.success(data={'skills': skill})
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception as exc:
|
||||
return self.http_status(500, -1, f'Failed to install skill: {exc}')
|
||||
|
||||
@self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def preview_skill_from_github() -> quart.Response:
|
||||
data = await quart.request.json
|
||||
required_fields = ['asset_url', 'owner', 'repo', 'release_tag']
|
||||
for field in required_fields:
|
||||
if field not in data or not data[field]:
|
||||
return self.http_status(400, -1, f'Missing required field: {field}')
|
||||
|
||||
try:
|
||||
preview = await self.ap.skill_service.preview_install_from_github(data)
|
||||
return self.success(data={'skills': preview})
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception as exc:
|
||||
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
|
||||
|
||||
@self.route('/install/upload', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def install_skill_from_upload() -> quart.Response:
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
form = await quart.request.form
|
||||
|
||||
try:
|
||||
skill = await self.ap.skill_service.install_from_zip_upload(
|
||||
file_bytes=file.read(),
|
||||
filename=file.filename or '',
|
||||
source_paths=form.getlist('source_paths'),
|
||||
)
|
||||
return self.success(data={'skills': skill})
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception as exc:
|
||||
return self.http_status(500, -1, f'Failed to install skill: {exc}')
|
||||
|
||||
@self.route('/install/upload/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def preview_skill_from_upload() -> quart.Response:
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
|
||||
try:
|
||||
preview = await self.ap.skill_service.preview_install_from_zip_upload(
|
||||
file_bytes=file.read(),
|
||||
filename=file.filename or '',
|
||||
)
|
||||
return self.success(data={'skills': preview})
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception as exc:
|
||||
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
|
||||
|
||||
@self.route('/scan', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def scan_skill_directory() -> quart.Response:
|
||||
path = quart.request.args.get('path', '').strip()
|
||||
if not path:
|
||||
return self.http_status(400, -1, 'Missing required parameter: path')
|
||||
|
||||
try:
|
||||
result = self.ap.skill_service.scan_directory(path)
|
||||
return self.success(data=result)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
@@ -220,6 +220,8 @@ class PipelineService:
|
||||
bound_mcp_servers: list[str] = None,
|
||||
enable_all_plugins: bool = True,
|
||||
enable_all_mcp_servers: bool = True,
|
||||
bound_skills: list[str] = None,
|
||||
enable_all_skills: bool = True,
|
||||
) -> None:
|
||||
"""Update the bound plugins and MCP servers for a pipeline"""
|
||||
# Get current pipeline
|
||||
@@ -237,9 +239,12 @@ class PipelineService:
|
||||
extensions_preferences = pipeline.extensions_preferences or {}
|
||||
extensions_preferences['enable_all_plugins'] = enable_all_plugins
|
||||
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
||||
extensions_preferences['enable_all_skills'] = enable_all_skills
|
||||
extensions_preferences['plugins'] = bound_plugins
|
||||
if bound_mcp_servers is not None:
|
||||
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
||||
if bound_skills is not None:
|
||||
extensions_preferences['skills'] = bound_skills
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
|
||||
743
src/langbot/pkg/api/http/service/skill.py
Normal file
743
src/langbot/pkg/api/http/service/skill.py
Normal file
@@ -0,0 +1,743 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import inspect
|
||||
import os
|
||||
import posixpath
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from ....core import app
|
||||
from ....skill.utils import parse_frontmatter
|
||||
from ....utils import paths
|
||||
|
||||
_FRONTMATTER_FIELDS = (
|
||||
'name',
|
||||
'display_name',
|
||||
'description',
|
||||
'auto_activate',
|
||||
)
|
||||
|
||||
_PUBLIC_SKILL_FIELDS = (
|
||||
'name',
|
||||
'display_name',
|
||||
'description',
|
||||
'instructions',
|
||||
'package_root',
|
||||
'auto_activate',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
)
|
||||
|
||||
_GITHUB_ASSET_HOSTS = {
|
||||
'github.com',
|
||||
'api.github.com',
|
||||
'objects.githubusercontent.com',
|
||||
'githubusercontent.com',
|
||||
'raw.githubusercontent.com',
|
||||
'codeload.github.com',
|
||||
}
|
||||
|
||||
|
||||
def _build_skill_md(metadata: dict, instructions: str) -> str:
|
||||
frontmatter = {}
|
||||
for key in _FRONTMATTER_FIELDS:
|
||||
value = metadata.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if key == 'auto_activate' and value is True:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
frontmatter[key] = value
|
||||
|
||||
if not frontmatter:
|
||||
return instructions
|
||||
|
||||
frontmatter_text = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True, sort_keys=False).strip()
|
||||
return f'---\n{frontmatter_text}\n---\n\n{instructions}'
|
||||
|
||||
|
||||
class SkillService:
|
||||
"""Filesystem-backed skill management service."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
@staticmethod
|
||||
def _serialize_skill(skill: dict) -> dict:
|
||||
return {field: skill.get(field) for field in _PUBLIC_SKILL_FIELDS if field in skill}
|
||||
|
||||
async def list_skills(self) -> list[dict]:
|
||||
skills = [dict(skill) for skill in getattr(self.ap.skill_mgr, 'skills', {}).values()]
|
||||
skills.sort(key=lambda item: item.get('updated_at', ''), reverse=True)
|
||||
return [self._serialize_skill(skill) for skill in skills]
|
||||
|
||||
async def get_skill(self, skill_name: str) -> Optional[dict]:
|
||||
skill = getattr(self.ap.skill_mgr, 'get_skill_by_name', lambda _name: None)(skill_name)
|
||||
return self._serialize_skill(skill) if skill else None
|
||||
|
||||
async def get_skill_by_name(self, name: str) -> Optional[dict]:
|
||||
return await self.get_skill(name)
|
||||
|
||||
async def create_skill(self, data: dict) -> dict:
|
||||
name = self._validate_skill_name(data.get('name', ''))
|
||||
if await self.get_skill_by_name(name):
|
||||
raise ValueError(f'Skill with name "{name}" already exists')
|
||||
|
||||
package_root = self._normalize_package_root(data.get('package_root', ''))
|
||||
managed_root = self._managed_skill_path(name)
|
||||
target_root = managed_root
|
||||
imported_skill_data: dict | None = None
|
||||
|
||||
if package_root and self._managed_install_root_for_package(package_root):
|
||||
if not os.path.isdir(package_root):
|
||||
raise ValueError(f'Directory does not exist: {package_root}')
|
||||
target_root = package_root
|
||||
imported_skill_data = self._read_skill_package(target_root)
|
||||
elif package_root and package_root != managed_root:
|
||||
if not os.path.isdir(package_root):
|
||||
raise ValueError(f'Directory does not exist: {package_root}')
|
||||
if os.path.exists(managed_root):
|
||||
raise ValueError(f'Skill directory already exists: {managed_root}')
|
||||
os.makedirs(os.path.dirname(managed_root), exist_ok=True)
|
||||
shutil.copytree(package_root, managed_root)
|
||||
imported_skill_data = self._read_skill_package(managed_root)
|
||||
else:
|
||||
os.makedirs(managed_root, exist_ok=True)
|
||||
|
||||
metadata = {
|
||||
'name': name,
|
||||
'display_name': self._resolve_create_field(data, 'display_name', imported_skill_data, default=''),
|
||||
'description': self._resolve_create_field(data, 'description', imported_skill_data, default=''),
|
||||
'auto_activate': self._resolve_create_bool(data, 'auto_activate', imported_skill_data, default=True),
|
||||
}
|
||||
instructions = self._resolve_create_field(data, 'instructions', imported_skill_data, default='')
|
||||
self._write_skill_md(target_root, metadata, instructions)
|
||||
|
||||
await self._reload_skills()
|
||||
created = await self.get_skill(name)
|
||||
if not created:
|
||||
raise ValueError(f'Failed to create skill "{name}"')
|
||||
return created
|
||||
|
||||
async def update_skill(self, skill_name: str, data: dict) -> dict:
|
||||
skill = await self.get_skill(skill_name)
|
||||
if not skill:
|
||||
raise ValueError(f'Skill "{skill_name}" not found')
|
||||
|
||||
requested_name = str(data.get('name', skill['name']) or skill['name']).strip()
|
||||
if requested_name != skill['name']:
|
||||
raise ValueError('Renaming skills is not supported')
|
||||
|
||||
requested_package_root = str(data.get('package_root', '') or '').strip()
|
||||
existing_package_root = self._normalize_package_root(skill['package_root'])
|
||||
if requested_package_root and self._normalize_package_root(requested_package_root) != existing_package_root:
|
||||
raise ValueError('Updating package_root is not supported; recreate the skill to import a different package')
|
||||
|
||||
metadata = {
|
||||
'name': skill['name'],
|
||||
'display_name': data.get('display_name', skill.get('display_name', '')),
|
||||
'description': data.get('description', skill.get('description', '')),
|
||||
'auto_activate': data.get('auto_activate', skill.get('auto_activate', True)),
|
||||
}
|
||||
instructions = str(data.get('instructions', skill.get('instructions', '')) or '')
|
||||
self._write_skill_md(skill['package_root'], metadata, instructions)
|
||||
|
||||
await self._reload_skills()
|
||||
updated = await self.get_skill(skill_name)
|
||||
if not updated:
|
||||
raise ValueError(f'Skill "{skill_name}" not found after update')
|
||||
return updated
|
||||
|
||||
async def delete_skill(self, skill_name: str) -> bool:
|
||||
skill = await self.get_skill(skill_name)
|
||||
if not skill:
|
||||
raise ValueError(f'Skill "{skill_name}" not found')
|
||||
|
||||
package_root = self._normalize_package_root(skill['package_root'])
|
||||
managed_install_root = self._managed_install_root_for_package(package_root)
|
||||
if not managed_install_root:
|
||||
raise ValueError('Only managed skills under data/skills can be deleted via LangBot')
|
||||
|
||||
shutil.rmtree(managed_install_root, ignore_errors=True)
|
||||
await self._reload_skills()
|
||||
return True
|
||||
|
||||
async def list_skill_files(
|
||||
self,
|
||||
skill_name: str,
|
||||
path: str = '.',
|
||||
include_hidden: bool = False,
|
||||
max_entries: int = 200,
|
||||
) -> dict:
|
||||
skill = await self.get_skill(skill_name)
|
||||
if not skill:
|
||||
raise ValueError(f'Skill "{skill_name}" not found')
|
||||
|
||||
target_dir, relative_path = self._resolve_skill_path(skill, path, expect_directory=True)
|
||||
entries: list[dict] = []
|
||||
with os.scandir(target_dir) as iterator:
|
||||
for entry in sorted(iterator, key=lambda item: item.name):
|
||||
if not include_hidden and entry.name.startswith('.'):
|
||||
continue
|
||||
entry_rel_path = entry.name if relative_path in ('', '.') else os.path.join(relative_path, entry.name)
|
||||
is_dir = entry.is_dir()
|
||||
entries.append(
|
||||
{
|
||||
'path': entry_rel_path.replace(os.sep, '/'),
|
||||
'name': entry.name,
|
||||
'is_dir': is_dir,
|
||||
'size': None if is_dir else entry.stat().st_size,
|
||||
}
|
||||
)
|
||||
if len(entries) >= max_entries:
|
||||
break
|
||||
|
||||
return {
|
||||
'skill': {'name': skill['name']},
|
||||
'base_path': '.' if relative_path in ('', '.') else relative_path.replace(os.sep, '/'),
|
||||
'entries': entries,
|
||||
'truncated': len(entries) >= max_entries,
|
||||
}
|
||||
|
||||
async def read_skill_file(self, skill_name: str, path: str) -> dict:
|
||||
skill = await self.get_skill(skill_name)
|
||||
if not skill:
|
||||
raise ValueError(f'Skill "{skill_name}" not found')
|
||||
|
||||
target_path, relative_path = self._resolve_skill_path(skill, path, expect_directory=False)
|
||||
if not os.path.isfile(target_path):
|
||||
raise ValueError(f'Skill file not found: {relative_path}')
|
||||
|
||||
try:
|
||||
with open(target_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError(f'Skill file is not valid UTF-8 text: {relative_path}') from exc
|
||||
|
||||
return {
|
||||
'skill': {'name': skill['name']},
|
||||
'path': relative_path.replace(os.sep, '/'),
|
||||
'content': content,
|
||||
}
|
||||
|
||||
async def write_skill_file(self, skill_name: str, path: str, content: str) -> dict:
|
||||
skill = await self.get_skill(skill_name)
|
||||
if not skill:
|
||||
raise ValueError(f'Skill "{skill_name}" not found')
|
||||
|
||||
target_path, relative_path = self._resolve_skill_path(skill, path, expect_directory=False)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is not None:
|
||||
refresh_skill = getattr(skill_mgr, 'refresh_skill_from_disk', None)
|
||||
if callable(refresh_skill):
|
||||
refresh_skill(skill.get('name', ''))
|
||||
|
||||
return {
|
||||
'skill': {'name': skill['name']},
|
||||
'path': relative_path.replace(os.sep, '/'),
|
||||
'bytes_written': len(content.encode('utf-8')),
|
||||
}
|
||||
|
||||
async def install_from_github(self, data: dict) -> list[dict]:
|
||||
owner = str(data['owner']).strip()
|
||||
repo = str(data['repo']).strip()
|
||||
release_tag = str(data.get('release_tag', '')).strip()
|
||||
asset_url = self._validate_github_asset_url(data['asset_url'], owner=owner, repo=repo, release_tag=release_tag)
|
||||
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_')
|
||||
try:
|
||||
skill_root = await self._download_github_skill_to_temp(asset_url, tmp_dir)
|
||||
skill_root = self._resolve_github_source_root(skill_root, source_subdir)
|
||||
previews = self._preview_skill_candidates(
|
||||
skill_root,
|
||||
base_target_name=repo,
|
||||
suffix=release_tag.lstrip('v').replace('/', '-') or 'source',
|
||||
)
|
||||
selected_previews = self._select_preview_candidates(previews, data)
|
||||
scanned = self._install_preview_candidates(skill_root, selected_previews)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
await self._reload_skills()
|
||||
return await self._resolve_installed_skills(scanned)
|
||||
|
||||
async def preview_install_from_github(self, data: dict) -> list[dict]:
|
||||
owner = str(data['owner']).strip()
|
||||
repo = str(data['repo']).strip()
|
||||
release_tag = str(data.get('release_tag', '')).strip()
|
||||
asset_url = self._validate_github_asset_url(data['asset_url'], owner=owner, repo=repo, release_tag=release_tag)
|
||||
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_preview_')
|
||||
try:
|
||||
skill_root = await self._download_github_skill_to_temp(asset_url, tmp_dir)
|
||||
skill_root = self._resolve_github_source_root(skill_root, source_subdir)
|
||||
return self._preview_skill_candidates(
|
||||
skill_root,
|
||||
base_target_name=repo,
|
||||
suffix=release_tag.lstrip('v').replace('/', '-') or 'source',
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
async def install_from_zip_upload(
|
||||
self,
|
||||
*,
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
source_paths: list[str] | None = None,
|
||||
source_path: str = '',
|
||||
) -> list[dict]:
|
||||
if not file_bytes:
|
||||
raise ValueError('Uploaded file is empty')
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_upload_')
|
||||
try:
|
||||
skill_root = self._extract_uploaded_skill_to_temp(file_bytes, tmp_dir)
|
||||
base_target_name = self._uploaded_skill_target_stem(filename)
|
||||
previews = self._preview_skill_candidates(
|
||||
skill_root,
|
||||
base_target_name=base_target_name,
|
||||
suffix='upload',
|
||||
)
|
||||
selected_previews = self._select_preview_candidates(
|
||||
previews,
|
||||
{'source_paths': source_paths or [], 'source_path': source_path},
|
||||
)
|
||||
scanned = self._install_preview_candidates(skill_root, selected_previews)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
await self._reload_skills()
|
||||
return await self._resolve_installed_skills(scanned)
|
||||
|
||||
async def preview_install_from_zip_upload(self, *, file_bytes: bytes, filename: str) -> list[dict]:
|
||||
if not file_bytes:
|
||||
raise ValueError('Uploaded file is empty')
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_upload_preview_')
|
||||
try:
|
||||
skill_root = self._extract_uploaded_skill_to_temp(file_bytes, tmp_dir)
|
||||
return self._preview_skill_candidates(
|
||||
skill_root,
|
||||
base_target_name=self._uploaded_skill_target_stem(filename),
|
||||
suffix='upload',
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
async def reload_skills(self) -> list[dict]:
|
||||
await self._reload_skills()
|
||||
return await self.list_skills()
|
||||
|
||||
def scan_directory(self, path: str) -> dict:
|
||||
if not os.path.isdir(path):
|
||||
raise ValueError(f'Directory does not exist: {path}')
|
||||
|
||||
discovered = self._discover_skill_directories(path, max_depth=2)
|
||||
if not discovered:
|
||||
raise ValueError(f'No SKILL.md found in {path} or its subdirectories (max depth: 2)')
|
||||
if len(discovered) > 1:
|
||||
candidates = ', '.join(found_path for found_path, _entry in discovered)
|
||||
raise ValueError(
|
||||
f'Multiple skill directories found in {path}. Please choose a more specific path: {candidates}'
|
||||
)
|
||||
|
||||
package_root, entry_file = discovered[0]
|
||||
entry_path = os.path.join(package_root, entry_file)
|
||||
with open(entry_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
metadata, instructions = parse_frontmatter(content)
|
||||
dir_name = os.path.basename(os.path.normpath(package_root))
|
||||
return {
|
||||
'package_root': os.path.abspath(package_root),
|
||||
'entry_file': entry_file,
|
||||
'name': str(metadata.get('name') or dir_name).strip(),
|
||||
'display_name': str(metadata.get('display_name') or '').strip(),
|
||||
'description': str(metadata.get('description') or '').strip(),
|
||||
'instructions': instructions,
|
||||
'auto_activate': bool(metadata.get('auto_activate', True)),
|
||||
}
|
||||
|
||||
async def _reload_skills(self) -> None:
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
reload_skills = getattr(skill_mgr, 'reload_skills', None)
|
||||
if not callable(reload_skills):
|
||||
return
|
||||
result = reload_skills()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
def _read_skill_package(self, package_root: str) -> dict:
|
||||
entry = self._find_skill_entry(package_root)
|
||||
if entry is None:
|
||||
raise ValueError(f'No SKILL.md found in {package_root}')
|
||||
|
||||
resolved_root, entry_file = entry
|
||||
entry_path = os.path.join(resolved_root, entry_file)
|
||||
with open(entry_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
metadata, instructions = parse_frontmatter(content)
|
||||
return {
|
||||
'entry_file': entry_file,
|
||||
'display_name': str(metadata.get('display_name') or '').strip(),
|
||||
'description': str(metadata.get('description') or '').strip(),
|
||||
'instructions': instructions,
|
||||
'auto_activate': bool(metadata.get('auto_activate', True)),
|
||||
}
|
||||
|
||||
async def _download_github_skill_to_temp(self, asset_url: str, tmp_dir: str) -> str:
|
||||
zip_path = os.path.join(tmp_dir, 'skill.zip')
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=120) as client:
|
||||
resp = await client.get(asset_url)
|
||||
resp.raise_for_status()
|
||||
with open(zip_path, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
|
||||
extract_dir = os.path.join(tmp_dir, 'extracted')
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
self._safe_extract_zip(zf, extract_dir)
|
||||
|
||||
entries = os.listdir(extract_dir)
|
||||
if len(entries) == 1 and os.path.isdir(os.path.join(extract_dir, entries[0])):
|
||||
return os.path.join(extract_dir, entries[0])
|
||||
return extract_dir
|
||||
|
||||
def _extract_uploaded_skill_to_temp(self, file_bytes: bytes, tmp_dir: str) -> str:
|
||||
extract_dir = os.path.join(tmp_dir, 'extracted')
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_bytes), 'r') as zf:
|
||||
self._safe_extract_zip(zf, extract_dir)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise ValueError('Uploaded file must be a valid .zip archive') from exc
|
||||
|
||||
entries = os.listdir(extract_dir)
|
||||
if len(entries) == 1 and os.path.isdir(os.path.join(extract_dir, entries[0])):
|
||||
return os.path.join(extract_dir, entries[0])
|
||||
return extract_dir
|
||||
|
||||
def _resolve_github_source_root(self, root_path: str, source_subdir: str) -> str:
|
||||
normalized = str(source_subdir or '').strip().replace('\\', '/').strip('/')
|
||||
if not normalized:
|
||||
return root_path
|
||||
|
||||
target_path = os.path.realpath(os.path.join(root_path, normalized))
|
||||
root_path = os.path.realpath(root_path)
|
||||
if target_path != root_path and not target_path.startswith(f'{root_path}{os.sep}'):
|
||||
raise ValueError('source_subdir must stay within the downloaded repository')
|
||||
if not os.path.isdir(target_path):
|
||||
raise ValueError(f'source_subdir does not exist in the downloaded repository: {normalized}')
|
||||
return target_path
|
||||
|
||||
def _uploaded_skill_target_stem(self, filename: str) -> str:
|
||||
stem = os.path.splitext(os.path.basename(str(filename or '').strip()))[0]
|
||||
safe_stem = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '-' for ch in stem).strip('-_')
|
||||
if not safe_stem:
|
||||
safe_stem = 'uploaded-skill'
|
||||
return safe_stem
|
||||
|
||||
def _build_preview_target_dir(self, base_target_name: str, source_path: str, suffix: str) -> str:
|
||||
relative = str(source_path or '').strip().replace('\\', '/').strip('/')
|
||||
leaf_name = relative.split('/')[-1] if relative else ''
|
||||
target_name = base_target_name
|
||||
if leaf_name and leaf_name != base_target_name:
|
||||
target_name = f'{base_target_name}-{leaf_name}'
|
||||
if suffix:
|
||||
target_name = f'{target_name}-{suffix}'
|
||||
return paths.get_data_path('skills', target_name)
|
||||
|
||||
def _preview_skill_candidates(self, root_path: str, *, base_target_name: str, suffix: str) -> list[dict]:
|
||||
discovered = self._discover_skill_directories(root_path, max_depth=2)
|
||||
if not discovered:
|
||||
raise ValueError(f'No SKILL.md found in {root_path} or its subdirectories (max depth: 2)')
|
||||
|
||||
previews: list[dict] = []
|
||||
for package_root, entry_file in discovered:
|
||||
entry_path = os.path.join(package_root, entry_file)
|
||||
with open(entry_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
metadata, instructions = parse_frontmatter(content)
|
||||
relative_path = os.path.relpath(package_root, root_path)
|
||||
if relative_path in ('', '.'):
|
||||
relative_path = ''
|
||||
|
||||
dir_name = os.path.basename(os.path.normpath(package_root))
|
||||
previews.append(
|
||||
{
|
||||
'source_path': relative_path.replace(os.sep, '/'),
|
||||
'entry_file': entry_file,
|
||||
'name': str(metadata.get('name') or dir_name).strip(),
|
||||
'display_name': str(metadata.get('display_name') or '').strip(),
|
||||
'description': str(metadata.get('description') or '').strip(),
|
||||
'instructions': instructions,
|
||||
'auto_activate': bool(metadata.get('auto_activate', True)),
|
||||
'package_root': self._build_preview_target_dir(base_target_name, relative_path, suffix),
|
||||
}
|
||||
)
|
||||
|
||||
previews.sort(key=lambda item: item['source_path'])
|
||||
return previews
|
||||
|
||||
def _select_preview_candidates(self, previews: list[dict], data: dict) -> list[dict]:
|
||||
normalized_paths: list[str] = []
|
||||
raw_source_paths = data.get('source_paths', [])
|
||||
if isinstance(raw_source_paths, list):
|
||||
for source_path in raw_source_paths:
|
||||
normalized = str(source_path or '').strip().replace('\\', '/').strip('/')
|
||||
if normalized not in normalized_paths:
|
||||
normalized_paths.append(normalized)
|
||||
|
||||
legacy_source_path = str(data.get('source_path', '') or '').strip().replace('\\', '/').strip('/')
|
||||
if legacy_source_path and legacy_source_path not in normalized_paths:
|
||||
normalized_paths.append(legacy_source_path)
|
||||
|
||||
if len(previews) == 1 and not normalized_paths:
|
||||
return previews
|
||||
|
||||
if not normalized_paths:
|
||||
candidates = ', '.join(item['source_path'] or '.' for item in previews)
|
||||
raise ValueError(f'Multiple skills found. Please choose one or more source_paths: {candidates}')
|
||||
|
||||
selected: list[dict] = []
|
||||
available = {preview['source_path']: preview for preview in previews}
|
||||
for normalized_path in normalized_paths:
|
||||
preview = available.get(normalized_path)
|
||||
if preview is None:
|
||||
candidates = ', '.join(item['source_path'] or '.' for item in previews)
|
||||
raise ValueError(f'Invalid source_path "{normalized_path}". Available: {candidates}')
|
||||
selected.append(preview)
|
||||
|
||||
return selected
|
||||
|
||||
def _install_preview_candidates(self, root_path: str, selected_previews: list[dict]) -> list[dict]:
|
||||
target_dirs: list[str] = []
|
||||
for preview in selected_previews:
|
||||
target_dir = self._normalize_package_root(preview['package_root'])
|
||||
if target_dir in target_dirs:
|
||||
raise ValueError(f'Duplicate target directory selected: {target_dir}')
|
||||
if os.path.exists(target_dir):
|
||||
raise ValueError(f'Skill directory already exists: {target_dir}')
|
||||
target_dirs.append(target_dir)
|
||||
|
||||
installed_scans: list[dict] = []
|
||||
created_dirs: list[str] = []
|
||||
try:
|
||||
for preview in selected_previews:
|
||||
target_dir = self._normalize_package_root(preview['package_root'])
|
||||
source_root = self._preview_source_root(root_path, preview['source_path'])
|
||||
os.makedirs(os.path.dirname(target_dir), exist_ok=True)
|
||||
shutil.copytree(source_root, target_dir)
|
||||
created_dirs.append(target_dir)
|
||||
installed_scans.append(self.scan_directory(target_dir))
|
||||
except Exception:
|
||||
for target_dir in created_dirs:
|
||||
shutil.rmtree(target_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return installed_scans
|
||||
|
||||
async def _resolve_installed_skills(self, scanned_skills: list[dict]) -> list[dict]:
|
||||
installed_skills: list[dict] = []
|
||||
for scanned in scanned_skills:
|
||||
installed = await self.get_skill(scanned['name'])
|
||||
if not installed:
|
||||
installed = self._serialize_skill(scanned)
|
||||
installed_skills.append(installed)
|
||||
return installed_skills
|
||||
|
||||
@staticmethod
|
||||
def _preview_source_root(root_path: str, source_path: str) -> str:
|
||||
normalized = str(source_path or '').strip().replace('\\', '/').strip('/')
|
||||
if not normalized:
|
||||
return root_path
|
||||
return os.path.join(root_path, normalized)
|
||||
|
||||
@staticmethod
|
||||
def _validate_github_asset_url(asset_url: str, *, owner: str, repo: str, release_tag: str) -> str:
|
||||
parsed = urlparse(str(asset_url).strip())
|
||||
if parsed.scheme != 'https' or not parsed.netloc:
|
||||
raise ValueError('asset_url must be a valid HTTPS GitHub asset URL')
|
||||
|
||||
host = parsed.netloc.lower()
|
||||
if host not in _GITHUB_ASSET_HOSTS:
|
||||
raise ValueError('asset_url must point to a GitHub-hosted release asset or archive')
|
||||
|
||||
normalized_path = posixpath.normpath(parsed.path or '/')
|
||||
allowed_prefixes = [
|
||||
f'/repos/{owner}/{repo}/',
|
||||
f'/{owner}/{repo}/',
|
||||
]
|
||||
if not any(normalized_path.startswith(prefix) for prefix in allowed_prefixes):
|
||||
raise ValueError('asset_url does not match the requested owner/repo')
|
||||
|
||||
if release_tag and release_tag not in parsed.path and release_tag not in parsed.query:
|
||||
raise ValueError('asset_url does not match the requested release_tag')
|
||||
|
||||
return parsed.geturl()
|
||||
|
||||
@staticmethod
|
||||
def _safe_extract_zip(archive: zipfile.ZipFile, target_dir: str) -> None:
|
||||
target_root = os.path.realpath(target_dir)
|
||||
os.makedirs(target_root, exist_ok=True)
|
||||
|
||||
for member in archive.infolist():
|
||||
member_name = member.filename
|
||||
if not member_name or member_name.endswith('/'):
|
||||
continue
|
||||
|
||||
normalized = posixpath.normpath(member_name)
|
||||
if normalized.startswith('../') or normalized == '..' or os.path.isabs(normalized):
|
||||
raise ValueError(f'Archive contains an unsafe path: {member_name}')
|
||||
|
||||
destination = os.path.realpath(os.path.join(target_root, normalized))
|
||||
if destination != target_root and not destination.startswith(f'{target_root}{os.sep}'):
|
||||
raise ValueError(f'Archive contains an unsafe path: {member_name}')
|
||||
|
||||
archive.extractall(target_root)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_create_field(data: dict, field: str, imported_skill_data: dict | None, *, default: str) -> str:
|
||||
raw_value = data.get(field) if field in data else None
|
||||
if raw_value is None:
|
||||
if imported_skill_data is not None:
|
||||
return str(imported_skill_data.get(field, default) or default)
|
||||
return default
|
||||
|
||||
value = str(raw_value or '')
|
||||
if imported_skill_data is not None and not value.strip():
|
||||
return str(imported_skill_data.get(field, default) or default)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _resolve_create_bool(data: dict, field: str, imported_skill_data: dict | None, *, default: bool) -> bool:
|
||||
if field in data and data[field] is not None:
|
||||
return bool(data[field])
|
||||
if imported_skill_data is not None:
|
||||
return bool(imported_skill_data.get(field, default))
|
||||
return default
|
||||
|
||||
def _write_skill_md(self, package_root: str, metadata: dict, instructions: str) -> None:
|
||||
package_root = self._normalize_package_root(package_root)
|
||||
os.makedirs(package_root, exist_ok=True)
|
||||
content = _build_skill_md(metadata, instructions)
|
||||
with open(os.path.join(package_root, 'SKILL.md'), 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
def _managed_skill_path(self, skill_name: str) -> str:
|
||||
return self._normalize_package_root(paths.get_data_path('skills', skill_name))
|
||||
|
||||
def _managed_install_root_for_package(self, package_root: str) -> str:
|
||||
managed_root = self._normalize_package_root(paths.get_data_path('skills'))
|
||||
if not package_root or package_root == managed_root:
|
||||
return ''
|
||||
|
||||
prefix = f'{managed_root}{os.sep}'
|
||||
if not package_root.startswith(prefix):
|
||||
return ''
|
||||
|
||||
relative = os.path.relpath(package_root, managed_root)
|
||||
top_level = relative.split(os.sep, 1)[0]
|
||||
if top_level in ('', '.', '..'):
|
||||
return ''
|
||||
return os.path.join(managed_root, top_level)
|
||||
|
||||
@staticmethod
|
||||
def _validate_skill_name(name: str) -> str:
|
||||
name = str(name or '').strip()
|
||||
if not name:
|
||||
raise ValueError('Skill name is required')
|
||||
if not name.replace('-', '').replace('_', '').isalnum():
|
||||
raise ValueError('Skill name can only contain letters, numbers, hyphens and underscores')
|
||||
if len(name) > 64:
|
||||
raise ValueError('Skill name cannot exceed 64 characters')
|
||||
return name
|
||||
|
||||
@staticmethod
|
||||
def _normalize_package_root(package_root: str) -> str:
|
||||
package_root = str(package_root).strip()
|
||||
if not package_root:
|
||||
return ''
|
||||
return os.path.realpath(os.path.abspath(package_root))
|
||||
|
||||
@staticmethod
|
||||
def _find_skill_entry(path: str) -> Optional[tuple[str, str]]:
|
||||
for candidate in ('SKILL.md', 'skill.md'):
|
||||
if os.path.isfile(os.path.join(path, candidate)):
|
||||
return path, candidate
|
||||
return None
|
||||
|
||||
def _discover_skill_directories(self, root_path: str, max_depth: int = 2) -> list[tuple[str, str]]:
|
||||
discovered: list[tuple[str, str]] = []
|
||||
queue: list[tuple[str, int]] = [(root_path, 0)]
|
||||
seen: set[str] = set()
|
||||
|
||||
while queue:
|
||||
current_path, depth = queue.pop(0)
|
||||
normalized_path = os.path.abspath(current_path)
|
||||
if normalized_path in seen:
|
||||
continue
|
||||
seen.add(normalized_path)
|
||||
|
||||
found = self._find_skill_entry(normalized_path)
|
||||
if found:
|
||||
discovered.append(found)
|
||||
continue
|
||||
|
||||
if depth >= max_depth:
|
||||
continue
|
||||
|
||||
try:
|
||||
entries = sorted(os.scandir(normalized_path), key=lambda entry: entry.name)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for entry in entries:
|
||||
if entry.is_dir():
|
||||
queue.append((entry.path, depth + 1))
|
||||
|
||||
return discovered
|
||||
|
||||
def _resolve_skill_path(self, skill: dict, path: str, *, expect_directory: bool) -> tuple[str, str]:
|
||||
package_root = self._normalize_package_root(skill.get('package_root', ''))
|
||||
if not package_root:
|
||||
raise ValueError(f'Skill "{skill.get("name", "")}" has no package_root')
|
||||
|
||||
relative_path = str(path or '.').strip() or '.'
|
||||
if os.path.isabs(relative_path):
|
||||
raise ValueError('path must be relative to the skill package root')
|
||||
|
||||
normalized_relative = os.path.normpath(relative_path)
|
||||
if normalized_relative.startswith('..') or normalized_relative == '..':
|
||||
raise ValueError('path must stay within the skill package root')
|
||||
|
||||
target_path = os.path.realpath(os.path.join(package_root, normalized_relative))
|
||||
if target_path != package_root and not target_path.startswith(f'{package_root}{os.sep}'):
|
||||
raise ValueError('path must stay within the skill package root')
|
||||
|
||||
if expect_directory:
|
||||
if not os.path.isdir(target_path):
|
||||
raise ValueError(f'Skill directory not found: {relative_path}')
|
||||
else:
|
||||
parent_dir = os.path.dirname(target_path) or package_root
|
||||
if parent_dir != package_root and not parent_dir.startswith(f'{package_root}{os.sep}'):
|
||||
raise ValueError('path must stay within the skill package root')
|
||||
|
||||
return target_path, normalized_relative
|
||||
@@ -32,6 +32,11 @@ def _is_path_under(path: str, root: str) -> bool:
|
||||
return path == root or path.startswith(f'{root}{os.sep}')
|
||||
|
||||
|
||||
|
||||
def _is_path_under(path: str, root: str) -> bool:
|
||||
"""Check whether *path* equals *root* or is a child of *root*."""
|
||||
return path == root or path.startswith(f'{root}{os.sep}')
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core import app as core_app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
|
||||
@@ -32,7 +32,7 @@ from ..api.http.service import mcp as mcp_service
|
||||
from ..api.http.service import apikey as apikey_service
|
||||
from ..api.http.service import webhook as webhook_service
|
||||
from ..api.http.service import monitoring as monitoring_service
|
||||
|
||||
from ..api.http.service import skill as skill_service
|
||||
from ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
from ..utils import logcache
|
||||
@@ -43,6 +43,7 @@ from ..rag.service import RAGRuntimeService
|
||||
from ..vector import mgr as vectordb_mgr
|
||||
from ..telemetry import telemetry as telemetry_module
|
||||
from ..survey import manager as survey_module
|
||||
from ..skill import manager as skill_mgr
|
||||
|
||||
|
||||
class Application:
|
||||
@@ -157,6 +158,10 @@ class Application:
|
||||
|
||||
monitoring_service: monitoring_service.MonitoringService = None
|
||||
|
||||
skill_service: skill_service.SkillService = None
|
||||
|
||||
skill_mgr: skill_mgr.SkillManager = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ from ...api.http.service import mcp as mcp_service
|
||||
from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import webhook as webhook_service
|
||||
from ...api.http.service import monitoring as monitoring_service
|
||||
from ...api.http.service import skill as skill_service
|
||||
from ...skill import manager as skill_mgr
|
||||
from ...discover import engine as discover_engine
|
||||
from ...storage import mgr as storagemgr
|
||||
from ...utils import logcache
|
||||
@@ -86,6 +88,9 @@ class BuildAppStage(stage.BootingStage):
|
||||
webhook_service_inst = webhook_service.WebhookService(ap)
|
||||
ap.webhook_service = webhook_service_inst
|
||||
|
||||
skill_service_inst = skill_service.SkillService(ap)
|
||||
ap.skill_service = skill_service_inst
|
||||
|
||||
proxy_mgr = proxy.ProxyManager(ap)
|
||||
await proxy_mgr.initialize()
|
||||
ap.proxy_mgr = proxy_mgr
|
||||
@@ -153,6 +158,11 @@ class BuildAppStage(stage.BootingStage):
|
||||
msg_aggregator_inst = message_aggregator.MessageAggregator(ap)
|
||||
ap.msg_aggregator = msg_aggregator_inst
|
||||
|
||||
# Initialize skill manager
|
||||
skill_mgr_inst = skill_mgr.SkillManager(ap)
|
||||
await skill_mgr_inst.initialize()
|
||||
ap.skill_mgr = skill_mgr_inst
|
||||
|
||||
rag_mgr_inst = rag_mgr.RAGManager(ap)
|
||||
await rag_mgr_inst.initialize()
|
||||
ap.rag_mgr = rag_mgr_inst
|
||||
|
||||
@@ -32,6 +32,9 @@ class PreProcessor(stage.PipelineStage):
|
||||
) -> entities.StageProcessResult:
|
||||
"""Process"""
|
||||
selected_runner = query.pipeline_config['ai']['runner']['runner']
|
||||
include_skill_authoring = (
|
||||
selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None
|
||||
)
|
||||
|
||||
session = await self.ap.sess_mgr.get_session(query)
|
||||
|
||||
@@ -89,7 +92,11 @@ class PreProcessor(stage.PipelineStage):
|
||||
# Get bound plugins and MCP servers for filtering tools
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=include_skill_authoring,
|
||||
)
|
||||
|
||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
||||
@@ -100,7 +107,11 @@ class PreProcessor(stage.PipelineStage):
|
||||
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=include_skill_authoring,
|
||||
)
|
||||
|
||||
sender_name = ''
|
||||
|
||||
@@ -210,4 +221,58 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.prompt.messages = event_ctx.event.default_prompt
|
||||
query.messages = event_ctx.event.prompt
|
||||
|
||||
# =========== Inject skill index into system prompt ===========
|
||||
if selected_runner == 'local-agent' and self.ap.skill_mgr:
|
||||
# Get bound skills from pipeline extensions_preferences
|
||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
||||
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
||||
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
||||
|
||||
if enable_all_skills:
|
||||
bound_skills = None # None = all skills available
|
||||
else:
|
||||
# Get specific bound skill names
|
||||
bound_skills = extensions_prefs.get('skills', [])
|
||||
|
||||
# Store bound skills in query variables for runtime path visibility checks
|
||||
query.variables['_pipeline_bound_skills'] = bound_skills
|
||||
|
||||
# Build skill awareness addition
|
||||
skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition(
|
||||
pipeline_uuid=query.pipeline_uuid,
|
||||
bound_skills=bound_skills,
|
||||
)
|
||||
|
||||
if skill_addition:
|
||||
self.ap.logger.info(
|
||||
f'Skill index injected into system prompt: '
|
||||
f'pipeline={query.pipeline_uuid} '
|
||||
f'bound_skills={bound_skills or "all"} '
|
||||
f'available_skills=[{", ".join(s["name"] for s in self.ap.skill_mgr.skills.values() if s.get("auto_activate", True))}]'
|
||||
)
|
||||
# Append skill instruction to the first system message
|
||||
if query.prompt.messages and query.prompt.messages[0].role == 'system':
|
||||
if isinstance(query.prompt.messages[0].content, str):
|
||||
query.prompt.messages[0].content += skill_addition
|
||||
elif isinstance(query.prompt.messages[0].content, list):
|
||||
# Handle content as list of ContentElements
|
||||
for ce in query.prompt.messages[0].content:
|
||||
if ce.type == 'text':
|
||||
ce.text += skill_addition
|
||||
break
|
||||
else:
|
||||
# Insert a new system message with skill instructions
|
||||
query.prompt.messages.insert(
|
||||
0,
|
||||
provider_message.Message(role='system', content=skill_addition.strip()),
|
||||
)
|
||||
else:
|
||||
loaded_count = len(self.ap.skill_mgr.skills)
|
||||
self.ap.logger.debug(
|
||||
f'No skills available for injection: '
|
||||
f'pipeline={query.pipeline_uuid} '
|
||||
f'loaded_skills={loaded_count} '
|
||||
f'bound_skills={bound_skills}'
|
||||
)
|
||||
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
|
||||
@@ -9,6 +9,7 @@ from ..tools.loaders.native import EXEC_TOOL_NAME
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
||||
from ...skill.activation import get_skill_activation_coordinator
|
||||
|
||||
|
||||
rag_combined_prompt_template = """
|
||||
@@ -25,6 +26,14 @@ Respond in the same language as the user's input.
|
||||
</user_message>
|
||||
"""
|
||||
|
||||
SANDBOX_EXEC_TOOL_NAME = 'sandbox_exec'
|
||||
SANDBOX_EXEC_SYSTEM_GUIDANCE = (
|
||||
'When sandbox_exec is available, use it for exact calculations, statistics, structured data parsing, '
|
||||
'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, '
|
||||
'JSON, or other data and asks for a computed answer, prefer running a short Python script in sandbox_exec '
|
||||
'and then answer from the tool result.'
|
||||
)
|
||||
|
||||
|
||||
@runner.runner_class('local-agent')
|
||||
class LocalAgentRunner(runner.RequestRunner):
|
||||
@@ -150,6 +159,8 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||
"""Run request"""
|
||||
pending_tool_calls = []
|
||||
initial_response_emitted = False
|
||||
skill_activation = get_skill_activation_coordinator(self.ap)
|
||||
|
||||
# Get knowledge bases list from query variables (set by PreProcessor,
|
||||
# may have been modified by plugins during PromptPreProcessing)
|
||||
@@ -283,7 +294,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
query.use_funcs,
|
||||
remove_think,
|
||||
)
|
||||
yield msg
|
||||
final_msg = msg
|
||||
else:
|
||||
# Streaming: invoke with fallback
|
||||
@@ -292,6 +302,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
accumulated_content = ''
|
||||
last_role = 'assistant'
|
||||
msg_sequence = 1
|
||||
suppress_initial_stream = False
|
||||
|
||||
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
|
||||
query,
|
||||
@@ -322,7 +333,31 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
if tool_call.function and tool_call.function.arguments:
|
||||
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||
|
||||
if msg_idx % 8 == 0 or msg.is_final:
|
||||
emitted_this_round = False
|
||||
if skill_activation is not None:
|
||||
activation_prefix_state = skill_activation.inspect_initial_content(
|
||||
accumulated_content,
|
||||
msg.is_final,
|
||||
)
|
||||
if activation_prefix_state == 'buffer':
|
||||
suppress_initial_stream = True
|
||||
elif (
|
||||
activation_prefix_state == 'emit'
|
||||
and suppress_initial_stream is False
|
||||
and not initial_response_emitted
|
||||
):
|
||||
msg_sequence += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role=last_role,
|
||||
content=accumulated_content,
|
||||
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
|
||||
is_final=msg.is_final,
|
||||
msg_sequence=msg_sequence,
|
||||
)
|
||||
initial_response_emitted = True
|
||||
emitted_this_round = True
|
||||
|
||||
if not suppress_initial_stream and not emitted_this_round and (msg_idx % 8 == 0 or msg.is_final):
|
||||
msg_sequence += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role=last_role,
|
||||
@@ -331,6 +366,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
is_final=msg.is_final,
|
||||
msg_sequence=msg_sequence,
|
||||
)
|
||||
initial_response_emitted = True
|
||||
|
||||
final_msg = provider_message.MessageChunk(
|
||||
role=last_role,
|
||||
@@ -344,6 +380,118 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
if isinstance(final_msg, provider_message.MessageChunk):
|
||||
first_end_sequence = final_msg.msg_sequence
|
||||
|
||||
# =========== Skill activation detection ===========
|
||||
# Check if the LLM response contains a skill activation marker
|
||||
if first_content and skill_activation is not None:
|
||||
activation_plan = None
|
||||
original_req_messages_len = len(req_messages)
|
||||
|
||||
try:
|
||||
activation_plan = skill_activation.prepare_followup(query, first_content)
|
||||
if activation_plan:
|
||||
self.ap.logger.info(f'Skill activations detected: {activation_plan.activated_skill_names}')
|
||||
|
||||
# Reconstruct messages with a sanitized activation response, then add the skill prompt.
|
||||
sanitized_activation_msg = provider_message.Message(
|
||||
role=getattr(final_msg, 'role', 'assistant'),
|
||||
content=activation_plan.cleaned_content,
|
||||
tool_calls=getattr(final_msg, 'tool_calls', None),
|
||||
)
|
||||
req_messages.append(sanitized_activation_msg)
|
||||
req_messages.append(activation_plan.system_message)
|
||||
|
||||
# Make another request to let the LLM execute the skill
|
||||
if is_stream:
|
||||
tool_calls_map = {}
|
||||
msg_idx = 0
|
||||
accumulated_content = ''
|
||||
last_role = 'assistant'
|
||||
msg_sequence = first_end_sequence
|
||||
|
||||
async for msg in use_llm_model.provider.invoke_llm_stream(
|
||||
query,
|
||||
use_llm_model,
|
||||
req_messages,
|
||||
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
|
||||
extra_args=use_llm_model.model_entity.extra_args,
|
||||
remove_think=remove_think,
|
||||
):
|
||||
msg_idx += 1
|
||||
|
||||
if msg.role:
|
||||
last_role = msg.role
|
||||
|
||||
if msg.content:
|
||||
accumulated_content += msg.content
|
||||
|
||||
if msg.tool_calls:
|
||||
for tool_call in msg.tool_calls:
|
||||
if tool_call.id not in tool_calls_map:
|
||||
tool_calls_map[tool_call.id] = provider_message.ToolCall(
|
||||
id=tool_call.id,
|
||||
type=tool_call.type,
|
||||
function=provider_message.FunctionCall(
|
||||
name=tool_call.function.name if tool_call.function else '',
|
||||
arguments='',
|
||||
),
|
||||
)
|
||||
if tool_call.function and tool_call.function.arguments:
|
||||
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||
|
||||
if msg_idx % 8 == 0 or msg.is_final:
|
||||
msg_sequence += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role=last_role,
|
||||
content=accumulated_content,
|
||||
tool_calls=list(tool_calls_map.values())
|
||||
if (tool_calls_map and msg.is_final)
|
||||
else None,
|
||||
is_final=msg.is_final,
|
||||
msg_sequence=msg_sequence,
|
||||
)
|
||||
initial_response_emitted = True
|
||||
|
||||
final_msg = provider_message.MessageChunk(
|
||||
role=last_role,
|
||||
content=accumulated_content,
|
||||
tool_calls=list(tool_calls_map.values()) if tool_calls_map else None,
|
||||
msg_sequence=msg_sequence,
|
||||
)
|
||||
first_content = accumulated_content
|
||||
first_end_sequence = msg_sequence
|
||||
else:
|
||||
msg = await use_llm_model.provider.invoke_llm(
|
||||
query,
|
||||
use_llm_model,
|
||||
req_messages,
|
||||
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
|
||||
extra_args=use_llm_model.model_entity.extra_args,
|
||||
remove_think=remove_think,
|
||||
)
|
||||
final_msg = msg
|
||||
first_content = msg.content
|
||||
|
||||
# Update pending tool calls from the new response
|
||||
pending_tool_calls = final_msg.tool_calls
|
||||
# Remove the sanitized activation message and follow-up system prompt.
|
||||
req_messages = req_messages[:-2]
|
||||
except Exception:
|
||||
self.ap.logger.exception('Skill activation failed, falling back to normal execution')
|
||||
skill_activation.rollback(
|
||||
query,
|
||||
activation_plan.snapshot if activation_plan is not None else None,
|
||||
final_msg,
|
||||
)
|
||||
req_messages = req_messages[:original_req_messages_len]
|
||||
first_content = final_msg.content
|
||||
|
||||
if not is_stream:
|
||||
yield final_msg
|
||||
initial_response_emitted = True
|
||||
elif not initial_response_emitted:
|
||||
yield final_msg
|
||||
initial_response_emitted = True
|
||||
|
||||
req_messages.append(final_msg)
|
||||
|
||||
# Once a model succeeds, commit to it for the tool call loop
|
||||
|
||||
@@ -7,6 +7,7 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
from .. import loader
|
||||
from . import skill as skill_loader
|
||||
|
||||
EXEC_TOOL_NAME = 'exec'
|
||||
READ_TOOL_NAME = 'read'
|
||||
@@ -43,44 +44,116 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
f'query_id={query.query_id} '
|
||||
f'parameters={json.dumps(self._summarize_parameters(parameters), ensure_ascii=False)}'
|
||||
)
|
||||
return await self.ap.box_service.execute_tool(parameters, query)
|
||||
elif name == READ_TOOL_NAME:
|
||||
return await self._invoke_exec(parameters, query)
|
||||
if name == READ_TOOL_NAME:
|
||||
return await self._invoke_read(parameters, query)
|
||||
elif name == WRITE_TOOL_NAME:
|
||||
if name == WRITE_TOOL_NAME:
|
||||
return await self._invoke_write(parameters, query)
|
||||
elif name == EDIT_TOOL_NAME:
|
||||
if name == EDIT_TOOL_NAME:
|
||||
return await self._invoke_edit(parameters, query)
|
||||
else:
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
|
||||
async def shutdown(self):
|
||||
pass
|
||||
|
||||
# ── File tool implementations ────────────────────────────────────
|
||||
async def _invoke_exec(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
command = str(parameters['command'])
|
||||
workdir = str(parameters.get('workdir', '/workspace') or '/workspace')
|
||||
|
||||
selected_skill, rewritten_workdir = skill_loader.resolve_virtual_skill_path(
|
||||
self.ap,
|
||||
query,
|
||||
workdir,
|
||||
include_visible=False,
|
||||
include_activated=True,
|
||||
)
|
||||
referenced_skill_names = skill_loader.find_referenced_skill_names(command)
|
||||
|
||||
if selected_skill is None and referenced_skill_names:
|
||||
if len(referenced_skill_names) > 1:
|
||||
raise ValueError('exec can target at most one activated skill package per call.')
|
||||
selected_skill = skill_loader.get_activated_skill(query, referenced_skill_names[0])
|
||||
if selected_skill is None:
|
||||
raise ValueError(
|
||||
f'Skill "{referenced_skill_names[0]}" must be activated before exec can run in its package.'
|
||||
)
|
||||
rewritten_workdir = '/workspace'
|
||||
|
||||
if selected_skill is None:
|
||||
return await self.ap.box_service.execute_tool(parameters, query)
|
||||
|
||||
selected_skill_name = str(selected_skill.get('name', '') or '')
|
||||
if referenced_skill_names and any(name != selected_skill_name for name in referenced_skill_names):
|
||||
raise ValueError('exec can reference files from only one activated skill package per call.')
|
||||
|
||||
package_root = str(selected_skill.get('package_root', '') or '').strip()
|
||||
if not package_root:
|
||||
raise ValueError(f'Activated skill "{selected_skill_name}" has no package_root.')
|
||||
|
||||
rewritten_command = skill_loader.rewrite_command_for_skill_mount(command, selected_skill_name)
|
||||
if skill_loader.should_prepare_skill_python_env(package_root):
|
||||
rewritten_command = skill_loader.wrap_skill_command_with_python_env(rewritten_command)
|
||||
|
||||
spec_payload: dict = {
|
||||
'cmd': rewritten_command,
|
||||
'workdir': rewritten_workdir,
|
||||
'host_path': package_root,
|
||||
'host_path_mode': 'rw',
|
||||
'session_id': skill_loader.build_skill_session_id(selected_skill, query),
|
||||
}
|
||||
for key in ('timeout_sec', 'env'):
|
||||
if key in parameters:
|
||||
spec_payload[key] = parameters[key]
|
||||
|
||||
result = await self.ap.box_service.execute_spec_payload(spec_payload, query)
|
||||
self._refresh_skill_from_disk(selected_skill)
|
||||
return result
|
||||
|
||||
def _resolve_host_path(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
sandbox_path: str,
|
||||
*,
|
||||
include_visible: bool,
|
||||
include_activated: bool,
|
||||
) -> tuple[str, dict | None]:
|
||||
selected_skill, rewritten_path = skill_loader.resolve_virtual_skill_path(
|
||||
self.ap,
|
||||
query,
|
||||
sandbox_path,
|
||||
include_visible=include_visible,
|
||||
include_activated=include_activated,
|
||||
)
|
||||
|
||||
def _resolve_host_path(self, sandbox_path: str) -> str:
|
||||
"""Map a sandbox /workspace path to the host filesystem path."""
|
||||
box_service = self.ap.box_service
|
||||
host_root = box_service.default_host_workspace
|
||||
if host_root is None:
|
||||
raise ValueError('No default host workspace configured for file operations.')
|
||||
host_root = (
|
||||
selected_skill.get('package_root') if selected_skill is not None else box_service.default_host_workspace
|
||||
)
|
||||
if not host_root:
|
||||
raise ValueError('No host workspace configured for file operations.')
|
||||
|
||||
mount_path = '/workspace'
|
||||
if not sandbox_path.startswith(mount_path):
|
||||
if not rewritten_path.startswith(mount_path):
|
||||
raise ValueError(f'Path must be under {mount_path}.')
|
||||
|
||||
relative = sandbox_path[len(mount_path):].lstrip('/')
|
||||
relative = rewritten_path[len(mount_path) :].lstrip('/')
|
||||
host_path = os.path.realpath(os.path.join(host_root, relative))
|
||||
host_root = os.path.realpath(host_root)
|
||||
|
||||
if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
|
||||
raise ValueError('Path escapes the workspace boundary.')
|
||||
|
||||
return host_path
|
||||
return host_path, selected_skill
|
||||
|
||||
async def _invoke_read(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
path = parameters['path']
|
||||
self.ap.logger.info(f'read tool invoked: query_id={query.query_id} path={path}')
|
||||
host_path = self._resolve_host_path(path)
|
||||
host_path, _selected_skill = self._resolve_host_path(
|
||||
query,
|
||||
path,
|
||||
include_visible=True,
|
||||
include_activated=True,
|
||||
)
|
||||
if not os.path.exists(host_path):
|
||||
return {'ok': False, 'error': f'File not found: {path}'}
|
||||
if os.path.isdir(host_path):
|
||||
@@ -94,10 +167,16 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
path = parameters['path']
|
||||
content = parameters['content']
|
||||
self.ap.logger.info(f'write tool invoked: query_id={query.query_id} path={path} length={len(content)}')
|
||||
host_path = self._resolve_host_path(path)
|
||||
host_path, selected_skill = self._resolve_host_path(
|
||||
query,
|
||||
path,
|
||||
include_visible=False,
|
||||
include_activated=True,
|
||||
)
|
||||
os.makedirs(os.path.dirname(host_path), exist_ok=True)
|
||||
with open(host_path, 'w') as f:
|
||||
with open(host_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
self._refresh_skill_from_disk(selected_skill)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
async def _invoke_edit(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
@@ -108,10 +187,15 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
f'edit tool invoked: query_id={query.query_id} path={path} '
|
||||
f'old_len={len(old_string)} new_len={len(new_string)}'
|
||||
)
|
||||
host_path = self._resolve_host_path(path)
|
||||
host_path, selected_skill = self._resolve_host_path(
|
||||
query,
|
||||
path,
|
||||
include_visible=False,
|
||||
include_activated=True,
|
||||
)
|
||||
if not os.path.isfile(host_path):
|
||||
return {'ok': False, 'error': f'File not found: {path}'}
|
||||
with open(host_path, 'r', errors='replace') as f:
|
||||
with open(host_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
count = content.count(old_string)
|
||||
if count == 0:
|
||||
@@ -119,11 +203,22 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
if count > 1:
|
||||
return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
with open(host_path, 'w') as f:
|
||||
with open(host_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
self._refresh_skill_from_disk(selected_skill)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
# ── Internals ────────────────────────────────────────────────────
|
||||
def _refresh_skill_from_disk(self, selected_skill: dict | None) -> None:
|
||||
if selected_skill is None:
|
||||
return
|
||||
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return
|
||||
|
||||
refresh_skill = getattr(skill_mgr, 'refresh_skill_from_disk', None)
|
||||
if callable(refresh_skill):
|
||||
refresh_skill(selected_skill.get('name', ''))
|
||||
|
||||
def _is_sandbox_available(self) -> bool:
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
@@ -135,8 +230,10 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
human_desc='Execute a command in an isolated environment',
|
||||
description=(
|
||||
'Run shell commands in an isolated execution environment. '
|
||||
'Use this tool for bash commands, Python execution, and exact calculations '
|
||||
'over user-provided data.'
|
||||
'Use this tool for bash commands, Python execution, and exact calculations over '
|
||||
'user-provided data. Activated skill packages are addressable under '
|
||||
'/workspace/.skills/<skill-name>; when running inside one, set workdir to that path. '
|
||||
'To create a new skill package, prepare it under /workspace first, then use import_skill_from_directory.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
@@ -147,9 +244,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
},
|
||||
'workdir': {
|
||||
'type': 'string',
|
||||
'description': (
|
||||
'Working directory for the command. Defaults to /workspace.'
|
||||
),
|
||||
'description': 'Working directory for the command. Defaults to /workspace.',
|
||||
'default': '/workspace',
|
||||
},
|
||||
'timeout_sec': {
|
||||
@@ -179,7 +274,10 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
return resource_tool.LLMTool(
|
||||
name=READ_TOOL_NAME,
|
||||
human_desc='Read a file from the workspace',
|
||||
description='Read the contents of a file at the given path under /workspace.',
|
||||
description=(
|
||||
'Read the contents of a file at the given path under /workspace. '
|
||||
'Visible skill packages can be inspected through /workspace/.skills/<skill-name>/... .'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
@@ -198,7 +296,11 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
return resource_tool.LLMTool(
|
||||
name=WRITE_TOOL_NAME,
|
||||
human_desc='Write a file to the workspace',
|
||||
description='Create or overwrite a file at the given path under /workspace with the provided content.',
|
||||
description=(
|
||||
'Create or overwrite a file at the given path under /workspace with the provided content. '
|
||||
'Activated skill packages can be modified through /workspace/.skills/<skill-name>/... . '
|
||||
'For new skills, write files under /workspace and then call import_skill_from_directory.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
@@ -223,7 +325,9 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
human_desc='Edit a file in the workspace',
|
||||
description=(
|
||||
'Perform an exact string replacement in a file under /workspace. '
|
||||
'The old_string must appear exactly once in the file.'
|
||||
'The old_string must appear exactly once in the file. Activated skill packages '
|
||||
'can be edited through /workspace/.skills/<skill-name>/... . '
|
||||
'For new skills, edit files under /workspace and then call import_skill_from_directory.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
|
||||
285
src/langbot/pkg/provider/tools/loaders/skill.py
Normal file
285
src/langbot/pkg/provider/tools/loaders/skill.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import textwrap
|
||||
import typing
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core import app
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
ACTIVATED_SKILLS_KEY = '_activated_skills'
|
||||
PIPELINE_BOUND_SKILLS_KEY = '_pipeline_bound_skills'
|
||||
SKILL_MOUNT_PREFIX = '/workspace/.skills'
|
||||
_SKILL_MOUNT_PATTERN = re.compile(r'/workspace/\.skills/([A-Za-z0-9_-]+)')
|
||||
_PYTHON_SKILL_MANIFESTS = (
|
||||
'requirements.txt',
|
||||
'pyproject.toml',
|
||||
'setup.py',
|
||||
'setup.cfg',
|
||||
)
|
||||
|
||||
|
||||
def _normalize_host_path(path: str | None) -> str:
|
||||
if path is None:
|
||||
return ''
|
||||
stripped = str(path).strip()
|
||||
if not stripped:
|
||||
return ''
|
||||
return os.path.realpath(os.path.abspath(stripped))
|
||||
|
||||
|
||||
def get_virtual_skill_mount_path(skill_name: str) -> str:
|
||||
return f'{SKILL_MOUNT_PREFIX}/{skill_name}'
|
||||
|
||||
|
||||
def get_bound_skill_names(query: pipeline_query.Query) -> list[str] | None:
|
||||
if query.variables is None:
|
||||
return None
|
||||
|
||||
bound_skills = query.variables.get(PIPELINE_BOUND_SKILLS_KEY)
|
||||
if bound_skills is None:
|
||||
return None
|
||||
if isinstance(bound_skills, list):
|
||||
return [str(item) for item in bound_skills]
|
||||
return None
|
||||
|
||||
|
||||
def get_visible_skills(ap: app.Application, query: pipeline_query.Query) -> dict[str, dict]:
|
||||
skill_mgr = getattr(ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return {}
|
||||
|
||||
visible_skills = getattr(skill_mgr, 'skills', {})
|
||||
bound_skills = get_bound_skill_names(query)
|
||||
if bound_skills is None:
|
||||
return visible_skills
|
||||
|
||||
return {skill_name: skill_data for skill_name, skill_data in visible_skills.items() if skill_name in bound_skills}
|
||||
|
||||
|
||||
def get_visible_skill(ap: app.Application, query: pipeline_query.Query, skill_name: str) -> dict | None:
|
||||
return get_visible_skills(ap, query).get(skill_name)
|
||||
|
||||
|
||||
def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]:
|
||||
if query.variables is None:
|
||||
return {}
|
||||
|
||||
activated = query.variables.get(ACTIVATED_SKILLS_KEY, {})
|
||||
if not isinstance(activated, dict):
|
||||
return {}
|
||||
return activated
|
||||
|
||||
|
||||
def get_activated_skill(query: pipeline_query.Query, skill_name: str) -> dict | None:
|
||||
return get_activated_skills(query).get(skill_name)
|
||||
|
||||
|
||||
def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> None:
|
||||
if query.variables is None:
|
||||
query.variables = {}
|
||||
|
||||
activated = query.variables.setdefault(ACTIVATED_SKILLS_KEY, {})
|
||||
skill_name = str(skill_data.get('name', '') or '').strip()
|
||||
if skill_name and skill_name not in activated:
|
||||
activated[skill_name] = skill_data
|
||||
|
||||
|
||||
def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]:
|
||||
normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace'
|
||||
if normalized_path == SKILL_MOUNT_PREFIX:
|
||||
raise ValueError(f'Path must include a skill name under {SKILL_MOUNT_PREFIX}/<skill-name>.')
|
||||
prefix = f'{SKILL_MOUNT_PREFIX}/'
|
||||
if not normalized_path.startswith(prefix):
|
||||
return None, normalized_path
|
||||
|
||||
remainder = normalized_path[len(prefix) :]
|
||||
skill_name, separator, tail = remainder.partition('/')
|
||||
if not skill_name:
|
||||
raise ValueError(f'Path must include a skill name under {SKILL_MOUNT_PREFIX}/<skill-name>.')
|
||||
|
||||
rewritten_path = '/workspace'
|
||||
if separator:
|
||||
rewritten_path = f'/workspace/{tail}'
|
||||
return skill_name, rewritten_path
|
||||
|
||||
|
||||
def resolve_virtual_skill_path(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
sandbox_path: str,
|
||||
*,
|
||||
include_visible: bool,
|
||||
include_activated: bool,
|
||||
) -> tuple[dict | None, str]:
|
||||
skill_name, rewritten_path = parse_skill_mount_path(sandbox_path)
|
||||
if skill_name is None:
|
||||
return None, rewritten_path
|
||||
|
||||
if include_activated:
|
||||
activated_skill = get_activated_skill(query, skill_name)
|
||||
if activated_skill is not None:
|
||||
return activated_skill, rewritten_path
|
||||
|
||||
if include_visible:
|
||||
visible_skill = get_visible_skill(ap, query, skill_name)
|
||||
if visible_skill is not None:
|
||||
return visible_skill, rewritten_path
|
||||
|
||||
activated_names = ', '.join(sorted(get_activated_skills(query).keys())) or 'none'
|
||||
visible_names = ', '.join(sorted(get_visible_skills(ap, query).keys())) or 'none'
|
||||
raise ValueError(
|
||||
f'Skill "{skill_name}" is not available at this path. '
|
||||
f'Activated skills: {activated_names}. Visible skills: {visible_names}.'
|
||||
)
|
||||
|
||||
|
||||
def find_referenced_skill_names(text: str) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
|
||||
seen: list[str] = []
|
||||
for match in _SKILL_MOUNT_PATTERN.findall(text):
|
||||
if match not in seen:
|
||||
seen.append(match)
|
||||
return seen
|
||||
|
||||
|
||||
def rewrite_command_for_skill_mount(command: str, skill_name: str) -> str:
|
||||
virtual_root = get_virtual_skill_mount_path(skill_name)
|
||||
rewritten = command.replace(f'{virtual_root}/', '/workspace/')
|
||||
return rewritten.replace(virtual_root, '/workspace')
|
||||
|
||||
|
||||
def build_skill_session_id(skill_data: dict, query: pipeline_query.Query) -> str:
|
||||
skill_identifier = str(skill_data.get('name', 'unknown') or 'unknown')
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
launcher_id = getattr(query, 'launcher_id', None)
|
||||
query_id = getattr(query, 'query_id', 'unknown')
|
||||
|
||||
if launcher_type is not None and launcher_id is not None:
|
||||
return f'skill-{launcher_type}_{launcher_id}-{skill_identifier}'
|
||||
return f'skill-{query_id}-{skill_identifier}'
|
||||
|
||||
|
||||
def should_prepare_skill_python_env(package_root: str | None) -> bool:
|
||||
normalized_root = _normalize_host_path(package_root)
|
||||
if not normalized_root:
|
||||
return False
|
||||
if os.path.isdir(os.path.join(normalized_root, '.venv')):
|
||||
return True
|
||||
return any(os.path.isfile(os.path.join(normalized_root, filename)) for filename in _PYTHON_SKILL_MANIFESTS)
|
||||
|
||||
|
||||
def wrap_skill_command_with_python_env(command: str) -> str:
|
||||
bootstrap = textwrap.dedent(
|
||||
"""
|
||||
set -e
|
||||
|
||||
_LB_VENV_DIR="/workspace/.venv"
|
||||
_LB_META_DIR="/workspace/.langbot"
|
||||
_LB_META_FILE="$_LB_META_DIR/python-env.json"
|
||||
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
|
||||
_LB_TMP_DIR="/workspace/.tmp"
|
||||
_LB_PIP_CACHE_DIR="/workspace/.cache/pip"
|
||||
|
||||
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
|
||||
export TMPDIR="$_LB_TMP_DIR"
|
||||
export TEMP="$_LB_TMP_DIR"
|
||||
export TMP="$_LB_TMP_DIR"
|
||||
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
||||
|
||||
_lb_python_meta() {
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
root = "/workspace"
|
||||
digest = hashlib.sha256()
|
||||
manifest_files = []
|
||||
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
|
||||
path = os.path.join(root, rel)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
manifest_files.append(rel)
|
||||
with open(path, "rb") as handle:
|
||||
digest.update(rel.encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
digest.update(handle.read())
|
||||
digest.update(b"\0")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"python_executable": sys.executable,
|
||||
"python_version": list(sys.version_info[:3]),
|
||||
"manifest_files": manifest_files,
|
||||
"manifest_sha256": digest.hexdigest(),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
PY
|
||||
}
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
_LB_LOCK_WAIT=0
|
||||
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
|
||||
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
|
||||
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
|
||||
done
|
||||
|
||||
_lb_cleanup_lock() {
|
||||
rmdir "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap _lb_cleanup_lock EXIT INT TERM
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
rm -rf "$_LB_VENV_DIR"
|
||||
python -m venv "$_LB_VENV_DIR"
|
||||
|
||||
if [ -f /workspace/requirements.txt ]; then
|
||||
"$_LB_VENV_DIR/bin/python" -m pip install -r /workspace/requirements.txt
|
||||
elif [ -f /workspace/pyproject.toml ] || [ -f /workspace/setup.py ] || [ -f /workspace/setup.cfg ]; then
|
||||
"$_LB_VENV_DIR/bin/python" -m pip install -e /workspace
|
||||
fi
|
||||
|
||||
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
export VIRTUAL_ENV="$_LB_VENV_DIR"
|
||||
export PATH="$_LB_VENV_DIR/bin:$PATH"
|
||||
"""
|
||||
).strip()
|
||||
|
||||
return f'{bootstrap}\n\n{command}'
|
||||
391
src/langbot/pkg/provider/tools/loaders/skill_authoring.py
Normal file
391
src/langbot/pkg/provider/tools/loaders/skill_authoring.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import typing
|
||||
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
from .. import loader
|
||||
|
||||
# Skill authoring needs a managed abstraction above the generic box tools.
|
||||
# Pure prompt skills are just metadata plus SKILL.md instructions, so creating
|
||||
# or updating them should not require /workspace mounts, shell access, or box
|
||||
# to be enabled at all. These higher-level tools let local agents manage skills
|
||||
# directly through SkillService, while import_skill_from_directory remains the
|
||||
# path for file-based skills that actually need scripts or assets from box.
|
||||
|
||||
CREATE_SKILL_TOOL_NAME = 'create_skill'
|
||||
LIST_SKILLS_TOOL_NAME = 'list_skills'
|
||||
GET_SKILL_TOOL_NAME = 'get_skill'
|
||||
UPDATE_SKILL_TOOL_NAME = 'update_skill'
|
||||
DELETE_SKILL_TOOL_NAME = 'delete_skill'
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME = 'import_skill_from_directory'
|
||||
RELOAD_SKILLS_TOOL_NAME = 'reload_skills'
|
||||
|
||||
AUTHORING_TOOL_NAMES = {
|
||||
CREATE_SKILL_TOOL_NAME,
|
||||
LIST_SKILLS_TOOL_NAME,
|
||||
GET_SKILL_TOOL_NAME,
|
||||
UPDATE_SKILL_TOOL_NAME,
|
||||
DELETE_SKILL_TOOL_NAME,
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
RELOAD_SKILLS_TOOL_NAME,
|
||||
}
|
||||
|
||||
|
||||
class SkillAuthoringToolLoader(loader.ToolLoader):
|
||||
"""Minimal system actions for filesystem-backed skills."""
|
||||
|
||||
def __init__(self, ap):
|
||||
super().__init__(ap)
|
||||
self._tools: list[resource_tool.LLMTool] = []
|
||||
|
||||
async def initialize(self):
|
||||
self._tools = [
|
||||
self._build_create_skill_tool(),
|
||||
self._build_list_skills_tool(),
|
||||
self._build_get_skill_tool(),
|
||||
self._build_update_skill_tool(),
|
||||
self._build_delete_skill_tool(),
|
||||
self._build_import_skill_from_directory_tool(),
|
||||
self._build_reload_skills_tool(),
|
||||
]
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
if not self._has_authoring_services():
|
||||
return []
|
||||
return list(self._tools)
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
return self._has_authoring_services() and name in AUTHORING_TOOL_NAMES
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
|
||||
if name == CREATE_SKILL_TOOL_NAME:
|
||||
return await self._invoke_create_skill(parameters)
|
||||
if name == LIST_SKILLS_TOOL_NAME:
|
||||
return await self._invoke_list_skills()
|
||||
if name == GET_SKILL_TOOL_NAME:
|
||||
return await self._invoke_get_skill(parameters)
|
||||
if name == UPDATE_SKILL_TOOL_NAME:
|
||||
return await self._invoke_update_skill(parameters)
|
||||
if name == DELETE_SKILL_TOOL_NAME:
|
||||
return await self._invoke_delete_skill(parameters)
|
||||
if name == IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME:
|
||||
return await self._invoke_import_skill_from_directory(parameters)
|
||||
if name == RELOAD_SKILLS_TOOL_NAME:
|
||||
return await self._invoke_reload_skills()
|
||||
raise ValueError(f'Unknown skill authoring tool: {name}')
|
||||
|
||||
async def shutdown(self):
|
||||
pass
|
||||
|
||||
def _has_authoring_services(self) -> bool:
|
||||
return getattr(self.ap, 'skill_service', None) is not None
|
||||
|
||||
async def _invoke_reload_skills(self) -> typing.Any:
|
||||
await self.ap.skill_service.reload_skills()
|
||||
skills = await self.ap.skill_service.list_skills()
|
||||
return {
|
||||
'reloaded': True,
|
||||
'skill_names': [skill['name'] for skill in skills],
|
||||
'count': len(skills),
|
||||
}
|
||||
|
||||
async def _invoke_create_skill(self, parameters: dict) -> typing.Any:
|
||||
name = str(parameters.get('name', '') or '').strip()
|
||||
instructions = str(parameters.get('instructions', '') or '')
|
||||
if not name:
|
||||
raise ValueError('name is required')
|
||||
if not instructions.strip():
|
||||
raise ValueError('instructions is required')
|
||||
|
||||
created = await self.ap.skill_service.create_skill(
|
||||
{
|
||||
'name': name,
|
||||
'display_name': str(parameters.get('display_name', '') or '').strip(),
|
||||
'description': str(parameters.get('description', '') or '').strip(),
|
||||
'instructions': instructions,
|
||||
'auto_activate': parameters.get('auto_activate', True),
|
||||
}
|
||||
)
|
||||
return {
|
||||
'created': True,
|
||||
'skill': created,
|
||||
}
|
||||
|
||||
async def _invoke_list_skills(self) -> typing.Any:
|
||||
skills = await self.ap.skill_service.list_skills()
|
||||
return {
|
||||
'skills': skills,
|
||||
'skill_names': [skill['name'] for skill in skills],
|
||||
'count': len(skills),
|
||||
}
|
||||
|
||||
async def _invoke_get_skill(self, parameters: dict) -> typing.Any:
|
||||
name = str(parameters.get('name', '') or '').strip()
|
||||
if not name:
|
||||
raise ValueError('name is required')
|
||||
|
||||
skill = await self.ap.skill_service.get_skill(name)
|
||||
if not skill:
|
||||
raise ValueError(f'Skill "{name}" not found')
|
||||
return {'skill': skill}
|
||||
|
||||
async def _invoke_update_skill(self, parameters: dict) -> typing.Any:
|
||||
name = str(parameters.get('name', '') or '').strip()
|
||||
if not name:
|
||||
raise ValueError('name is required')
|
||||
|
||||
data = {'name': name}
|
||||
for field in ('display_name', 'description', 'instructions', 'auto_activate'):
|
||||
if field in parameters:
|
||||
data[field] = parameters[field]
|
||||
|
||||
updated = await self.ap.skill_service.update_skill(name, data)
|
||||
return {
|
||||
'updated': True,
|
||||
'skill': updated,
|
||||
}
|
||||
|
||||
async def _invoke_delete_skill(self, parameters: dict) -> typing.Any:
|
||||
name = str(parameters.get('name', '') or '').strip()
|
||||
if not name:
|
||||
raise ValueError('name is required')
|
||||
|
||||
await self.ap.skill_service.delete_skill(name)
|
||||
return {
|
||||
'deleted': True,
|
||||
'skill_name': name,
|
||||
}
|
||||
|
||||
async def _invoke_import_skill_from_directory(self, parameters: dict) -> typing.Any:
|
||||
sandbox_path = str(parameters.get('path', '') or '').strip()
|
||||
if not sandbox_path:
|
||||
raise ValueError('path is required')
|
||||
|
||||
host_path = self._resolve_workspace_directory(sandbox_path)
|
||||
scanned = self.ap.skill_service.scan_directory(host_path)
|
||||
created = await self.ap.skill_service.create_skill(
|
||||
{
|
||||
'name': str(parameters.get('name') or scanned['name']).strip(),
|
||||
'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(),
|
||||
'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
|
||||
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
|
||||
'package_root': host_path,
|
||||
'auto_activate': parameters.get('auto_activate', scanned.get('auto_activate', True)),
|
||||
}
|
||||
)
|
||||
return {
|
||||
'imported': True,
|
||||
'source_path': sandbox_path,
|
||||
'skill': created,
|
||||
}
|
||||
|
||||
def _resolve_workspace_directory(self, sandbox_path: str) -> str:
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
workspace_root = getattr(box_service, 'default_host_workspace', None)
|
||||
if not workspace_root:
|
||||
raise ValueError('No default host workspace configured for importing skills')
|
||||
|
||||
normalized_path = str(sandbox_path).strip() or '/workspace'
|
||||
if not normalized_path.startswith('/workspace'):
|
||||
raise ValueError('path must be under /workspace')
|
||||
|
||||
relative = normalized_path[len('/workspace') :].lstrip('/')
|
||||
host_root = os.path.realpath(workspace_root)
|
||||
host_path = os.path.realpath(os.path.join(host_root, relative))
|
||||
if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
|
||||
raise ValueError('path escapes the workspace boundary')
|
||||
if not os.path.isdir(host_path):
|
||||
raise ValueError(f'Directory does not exist: {sandbox_path}')
|
||||
return host_path
|
||||
|
||||
def _build_create_skill_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=CREATE_SKILL_TOOL_NAME,
|
||||
human_desc='Create a managed skill',
|
||||
description=(
|
||||
'Create a new managed skill directly in the skills store without using /workspace. '
|
||||
'Use this for prompt-only skills or simple skills whose main content is the SKILL.md instructions. '
|
||||
'Pure prompt skills should not depend on box or a workspace directory just to be created or edited later.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Skill name. Use lowercase letters, numbers, hyphens, or underscores.',
|
||||
},
|
||||
'display_name': {
|
||||
'type': 'string',
|
||||
'description': 'Optional human-friendly display name.',
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'description': 'Optional concise description of what the skill does and when to use it.',
|
||||
},
|
||||
'instructions': {
|
||||
'type': 'string',
|
||||
'description': 'The SKILL.md body instructions for the new skill.',
|
||||
},
|
||||
'auto_activate': {
|
||||
'type': 'boolean',
|
||||
'description': 'Whether the skill should be considered for automatic activation. Defaults to true.',
|
||||
},
|
||||
},
|
||||
'required': ['name', 'instructions'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_list_skills_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=LIST_SKILLS_TOOL_NAME,
|
||||
human_desc='List managed skills',
|
||||
description='List all managed skills so you can inspect what already exists before creating, updating, or deleting one.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_get_skill_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=GET_SKILL_TOOL_NAME,
|
||||
human_desc='Get a managed skill',
|
||||
description='Fetch one managed skill by name, including its current metadata and instructions, without relying on /workspace or skill activation.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Existing skill name to fetch.',
|
||||
},
|
||||
},
|
||||
'required': ['name'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_update_skill_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=UPDATE_SKILL_TOOL_NAME,
|
||||
human_desc='Update a managed skill',
|
||||
description=(
|
||||
'Update an existing managed skill directly in the skills store without using /workspace. '
|
||||
'Use this for prompt-only skills or for metadata and instruction changes to an existing skill. '
|
||||
'Pure prompt skills should remain editable through managed skill tools instead of depending on box.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Existing skill name to update.',
|
||||
},
|
||||
'display_name': {
|
||||
'type': 'string',
|
||||
'description': 'Optional new human-friendly display name.',
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'description': 'Optional new concise description.',
|
||||
},
|
||||
'instructions': {
|
||||
'type': 'string',
|
||||
'description': 'Optional replacement SKILL.md body instructions.',
|
||||
},
|
||||
'auto_activate': {
|
||||
'type': 'boolean',
|
||||
'description': 'Optional new auto_activate value.',
|
||||
},
|
||||
},
|
||||
'required': ['name'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_delete_skill_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=DELETE_SKILL_TOOL_NAME,
|
||||
human_desc='Delete a managed skill',
|
||||
description='Delete an existing managed skill by name from the managed skills store.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Existing skill name to delete.',
|
||||
},
|
||||
},
|
||||
'required': ['name'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_import_skill_from_directory_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
human_desc='Import skill from workspace directory',
|
||||
description=(
|
||||
'Import a skill package from a directory under /workspace into the managed skills store. '
|
||||
'Use this after cloning or preparing a skill repository in the default workspace. '
|
||||
'This is for file-based skills that actually need scripts, assets, or extra files. '
|
||||
'Pure prompt skills should use create_skill or update_skill instead of depending on box. '
|
||||
'If the source directory is already under the managed skills root, it will be registered in place instead of copied again.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'path': {
|
||||
'type': 'string',
|
||||
'description': 'Directory path under /workspace that contains a skill package or a nested SKILL.md.',
|
||||
},
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Optional skill name override. Defaults to the scanned skill name.',
|
||||
},
|
||||
'display_name': {
|
||||
'type': 'string',
|
||||
'description': 'Optional display name override.',
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'description': 'Optional description override.',
|
||||
},
|
||||
'instructions': {
|
||||
'type': 'string',
|
||||
'description': 'Optional instructions override.',
|
||||
},
|
||||
'auto_activate': {
|
||||
'type': 'boolean',
|
||||
'description': 'Optional auto_activate override.',
|
||||
},
|
||||
},
|
||||
'required': ['path'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_reload_skills_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=RELOAD_SKILLS_TOOL_NAME,
|
||||
human_desc='Reload filesystem skills',
|
||||
description=(
|
||||
'Reload skills from the filesystem after using the standard exec/read/write/edit tools '
|
||||
'to create, rename, or modify skill packages under the managed skills directory.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
@@ -8,7 +8,12 @@ from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core import app
|
||||
from langbot.pkg.provider.tools.loaders import mcp as mcp_loader, native as native_loader, plugin as plugin_loader
|
||||
from langbot.pkg.provider.tools.loaders import (
|
||||
mcp as mcp_loader,
|
||||
native as native_loader,
|
||||
plugin as plugin_loader,
|
||||
skill_authoring as skill_authoring_loader,
|
||||
)
|
||||
|
||||
|
||||
class ToolManager:
|
||||
@@ -19,6 +24,7 @@ class ToolManager:
|
||||
native_tool_loader: native_loader.NativeToolLoader
|
||||
plugin_tool_loader: plugin_loader.PluginToolLoader
|
||||
mcp_tool_loader: mcp_loader.MCPLoader
|
||||
skill_authoring_tool_loader: skill_authoring_loader.SkillAuthoringToolLoader
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
@@ -26,7 +32,12 @@ class ToolManager:
|
||||
async def initialize(self):
|
||||
from langbot.pkg.utils import importutil
|
||||
from langbot.pkg.provider.tools import loaders
|
||||
from langbot.pkg.provider.tools.loaders import mcp as mcp_loader, native as native_loader, plugin as plugin_loader
|
||||
from langbot.pkg.provider.tools.loaders import (
|
||||
mcp as mcp_loader,
|
||||
native as native_loader,
|
||||
plugin as plugin_loader,
|
||||
skill_authoring as skill_authoring_loader,
|
||||
)
|
||||
|
||||
importutil.import_modules_in_pkg(loaders)
|
||||
|
||||
@@ -36,21 +47,26 @@ class ToolManager:
|
||||
await self.plugin_tool_loader.initialize()
|
||||
self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap)
|
||||
await self.mcp_tool_loader.initialize()
|
||||
self.skill_authoring_tool_loader = skill_authoring_loader.SkillAuthoringToolLoader(self.ap)
|
||||
await self.skill_authoring_tool_loader.initialize()
|
||||
|
||||
async def get_all_tools(
|
||||
self, bound_plugins: list[str] | None = None, bound_mcp_servers: list[str] | None = None
|
||||
self,
|
||||
bound_plugins: list[str] | None = None,
|
||||
bound_mcp_servers: list[str] | None = None,
|
||||
include_skill_authoring: bool = False,
|
||||
) -> list[resource_tool.LLMTool]:
|
||||
"""获取所有函数"""
|
||||
all_functions: list[resource_tool.LLMTool] = []
|
||||
|
||||
all_functions.extend(await self.native_tool_loader.get_tools())
|
||||
if include_skill_authoring:
|
||||
all_functions.extend(await self.skill_authoring_tool_loader.get_tools())
|
||||
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
|
||||
all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers))
|
||||
|
||||
return all_functions
|
||||
|
||||
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
"""生成函数列表"""
|
||||
tools = []
|
||||
|
||||
for function in use_funcs:
|
||||
@@ -67,28 +83,6 @@ class ToolManager:
|
||||
return tools
|
||||
|
||||
async def generate_tools_for_anthropic(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
"""为anthropic生成函数列表
|
||||
|
||||
e.g.
|
||||
|
||||
[
|
||||
{
|
||||
"name": "get_stock_price",
|
||||
"description": "Get the current stock price for a given ticker symbol.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ticker": {
|
||||
"type": "string",
|
||||
"description": "The stock ticker symbol, e.g. AAPL for Apple Inc."
|
||||
}
|
||||
},
|
||||
"required": ["ticker"]
|
||||
}
|
||||
}
|
||||
]
|
||||
"""
|
||||
|
||||
tools = []
|
||||
|
||||
for function in use_funcs:
|
||||
@@ -102,19 +96,18 @@ class ToolManager:
|
||||
return tools
|
||||
|
||||
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
"""执行函数调用"""
|
||||
|
||||
if await self.native_tool_loader.has_tool(name):
|
||||
return await self.native_tool_loader.invoke_tool(name, parameters, query)
|
||||
elif await self.plugin_tool_loader.has_tool(name):
|
||||
if await self.plugin_tool_loader.has_tool(name):
|
||||
return await self.plugin_tool_loader.invoke_tool(name, parameters, query)
|
||||
elif await self.mcp_tool_loader.has_tool(name):
|
||||
if await self.mcp_tool_loader.has_tool(name):
|
||||
return await self.mcp_tool_loader.invoke_tool(name, parameters, query)
|
||||
else:
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
if await self.skill_authoring_tool_loader.has_tool(name):
|
||||
return await self.skill_authoring_tool_loader.invoke_tool(name, parameters, query)
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
|
||||
async def shutdown(self):
|
||||
"""关闭所有工具"""
|
||||
await self.native_tool_loader.shutdown()
|
||||
await self.plugin_tool_loader.shutdown()
|
||||
await self.mcp_tool_loader.shutdown()
|
||||
await self.skill_authoring_tool_loader.shutdown()
|
||||
|
||||
3
src/langbot/pkg/skill/__init__.py
Normal file
3
src/langbot/pkg/skill/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .manager import SkillManager
|
||||
|
||||
__all__ = ['SkillManager']
|
||||
154
src/langbot/pkg/skill/activation.py
Normal file
154
src/langbot/pkg/skill/activation.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import typing
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from ..provider.tools.loaders import skill as skill_loader
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..core import app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreparedSkillActivation:
|
||||
activated_skill_names: list[str]
|
||||
cleaned_content: str
|
||||
prompt: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillActivationSnapshot:
|
||||
use_funcs: list | None
|
||||
variables: dict | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillActivationPlan:
|
||||
activated_skill_names: list[str]
|
||||
cleaned_content: str
|
||||
system_message: provider_message.Message
|
||||
snapshot: SkillActivationSnapshot
|
||||
|
||||
|
||||
class SkillActivationCoordinator:
|
||||
"""Owns the skill activation protocol around the local-agent runner."""
|
||||
|
||||
def __init__(self, ap: app.Application, skill_mgr: typing.Any):
|
||||
self.ap = ap
|
||||
self.skill_mgr = skill_mgr
|
||||
|
||||
def inspect_initial_content(self, content: str | None, is_final: bool) -> str:
|
||||
if not content:
|
||||
return 'emit'
|
||||
|
||||
stripped = content.lstrip()
|
||||
if not stripped:
|
||||
return 'undecided'
|
||||
|
||||
marker = str(getattr(self.skill_mgr, 'SKILL_ACTIVATION_MARKER', '[ACTIVATE_SKILL:'))
|
||||
if stripped.startswith(marker):
|
||||
return 'buffer'
|
||||
if not is_final and marker.startswith(stripped):
|
||||
return 'undecided'
|
||||
return 'emit'
|
||||
|
||||
def prepare_followup(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
response_content: str | None,
|
||||
) -> SkillActivationPlan | None:
|
||||
snapshot = self._snapshot_query_state(query)
|
||||
try:
|
||||
activation = prepare_skill_activation(self.ap, query, response_content)
|
||||
except Exception:
|
||||
self._restore_query_state(query, snapshot)
|
||||
raise
|
||||
|
||||
if not activation:
|
||||
return None
|
||||
|
||||
return SkillActivationPlan(
|
||||
activated_skill_names=activation.activated_skill_names,
|
||||
cleaned_content=activation.cleaned_content,
|
||||
system_message=provider_message.Message(role='system', content=activation.prompt),
|
||||
snapshot=snapshot,
|
||||
)
|
||||
|
||||
def rollback(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
snapshot: SkillActivationSnapshot | None,
|
||||
response_message: provider_message.Message | provider_message.MessageChunk | None,
|
||||
) -> None:
|
||||
if snapshot is not None:
|
||||
self._restore_query_state(query, snapshot)
|
||||
|
||||
if response_message is None or not isinstance(response_message.content, str):
|
||||
return
|
||||
|
||||
response_message.content = self.skill_mgr.remove_activation_marker(response_message.content)
|
||||
|
||||
@staticmethod
|
||||
def _snapshot_use_funcs(use_funcs: list | None) -> list | None:
|
||||
if use_funcs is None:
|
||||
return None
|
||||
return list(use_funcs)
|
||||
|
||||
def _snapshot_query_state(self, query: pipeline_query.Query) -> SkillActivationSnapshot:
|
||||
return SkillActivationSnapshot(
|
||||
use_funcs=self._snapshot_use_funcs(query.use_funcs),
|
||||
variables=copy.deepcopy(query.variables) if query.variables is not None else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _restore_query_state(query: pipeline_query.Query, snapshot: SkillActivationSnapshot) -> None:
|
||||
query.use_funcs = snapshot.use_funcs
|
||||
query.variables = snapshot.variables
|
||||
|
||||
|
||||
def prepare_skill_activation(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
response_content: str | None,
|
||||
) -> PreparedSkillActivation | None:
|
||||
"""Prepare multi-skill activation state on the query."""
|
||||
if not response_content or not getattr(ap, 'skill_mgr', None):
|
||||
return None
|
||||
|
||||
activated_skill_names = ap.skill_mgr.detect_skill_activations(response_content)
|
||||
if not activated_skill_names:
|
||||
return None
|
||||
|
||||
prompt = ap.skill_mgr.build_activation_prompt_for_skills(activated_skill_names)
|
||||
if not prompt:
|
||||
return None
|
||||
|
||||
for skill_name in activated_skill_names:
|
||||
skill_data = ap.skill_mgr.get_skill_by_name(skill_name)
|
||||
if skill_data:
|
||||
skill_loader.register_activated_skill(query, skill_data)
|
||||
|
||||
return PreparedSkillActivation(
|
||||
activated_skill_names=activated_skill_names,
|
||||
cleaned_content=ap.skill_mgr.remove_activation_marker(response_content),
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
|
||||
def get_skill_activation_coordinator(ap: app.Application) -> SkillActivationCoordinator | None:
|
||||
skill_mgr = getattr(ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return None
|
||||
|
||||
required_methods = (
|
||||
'detect_skill_activations',
|
||||
'remove_activation_marker',
|
||||
)
|
||||
if any(not hasattr(skill_mgr, method_name) for method_name in required_methods):
|
||||
return None
|
||||
|
||||
return SkillActivationCoordinator(ap, skill_mgr)
|
||||
287
src/langbot/pkg/skill/manager.py
Normal file
287
src/langbot/pkg/skill/manager.py
Normal file
@@ -0,0 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
import re
|
||||
import typing
|
||||
|
||||
from ..core import app
|
||||
from .utils import parse_frontmatter
|
||||
from ..utils import paths
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class SkillManager:
|
||||
"""Skill manager backed purely by filesystem packages under data/skills."""
|
||||
|
||||
SKILL_ACTIVATION_MARKER = '[ACTIVATE_SKILL:'
|
||||
|
||||
ap: app.Application
|
||||
skills: dict[str, dict]
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
self.skills = {}
|
||||
|
||||
async def initialize(self):
|
||||
await self.reload_skills()
|
||||
|
||||
async def reload_skills(self):
|
||||
self.skills = {}
|
||||
|
||||
skills_root = self.get_managed_skills_root()
|
||||
if not os.path.isdir(skills_root):
|
||||
self.ap.logger.info('Loaded 0 skills')
|
||||
return
|
||||
|
||||
for package_root, entry_file in self._discover_skill_directories(skills_root):
|
||||
skill_data = {
|
||||
'package_root': package_root,
|
||||
'entry_file': entry_file,
|
||||
}
|
||||
if not self._load_skill_file(skill_data):
|
||||
continue
|
||||
|
||||
skill_name = skill_data['name']
|
||||
if skill_name in self.skills:
|
||||
self.ap.logger.warning(
|
||||
f'Duplicate skill name "{skill_name}" found at {package_root}, skipping later entry'
|
||||
)
|
||||
continue
|
||||
|
||||
self.skills[skill_name] = skill_data
|
||||
|
||||
self.ap.logger.info(f'Loaded {len(self.skills)} skills')
|
||||
|
||||
def refresh_skill_from_disk(self, skill_name: str) -> bool:
|
||||
if not skill_name:
|
||||
return False
|
||||
|
||||
skill_data = self.skills.get(skill_name)
|
||||
if not skill_data:
|
||||
return False
|
||||
|
||||
if not self._load_skill_file(skill_data):
|
||||
return False
|
||||
|
||||
self.skills[skill_name] = skill_data
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_managed_skills_root() -> str:
|
||||
return paths.get_data_path('skills')
|
||||
|
||||
def _discover_skill_directories(self, root_path: str, max_depth: int = 6) -> list[tuple[str, str]]:
|
||||
discovered: list[tuple[str, str]] = []
|
||||
root_path = os.path.realpath(os.path.abspath(root_path))
|
||||
root_depth = root_path.rstrip(os.sep).count(os.sep)
|
||||
|
||||
for current_root, dirs, _files in os.walk(root_path):
|
||||
current_root = os.path.realpath(current_root)
|
||||
depth = current_root.rstrip(os.sep).count(os.sep) - root_depth
|
||||
if depth > max_depth:
|
||||
dirs[:] = []
|
||||
continue
|
||||
|
||||
found = self._find_skill_entry(current_root)
|
||||
if found is not None:
|
||||
discovered.append(found)
|
||||
dirs[:] = []
|
||||
|
||||
discovered.sort(key=lambda item: item[0])
|
||||
return discovered
|
||||
|
||||
@staticmethod
|
||||
def _find_skill_entry(path: str) -> tuple[str, str] | None:
|
||||
for candidate in ('SKILL.md', 'skill.md'):
|
||||
if os.path.isfile(os.path.join(path, candidate)):
|
||||
return path, candidate
|
||||
return None
|
||||
|
||||
def _load_skill_file(self, skill_data: dict) -> bool:
|
||||
package_root = self._normalize_package_root(skill_data.get('package_root', ''))
|
||||
entry_file = skill_data.get('entry_file', 'SKILL.md')
|
||||
if not package_root:
|
||||
self.ap.logger.warning('Skill package_root is empty, skipping')
|
||||
return False
|
||||
|
||||
entry_path = os.path.join(package_root, entry_file)
|
||||
try:
|
||||
with open(entry_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
self.ap.logger.warning(f'Skill entry file not found: {entry_path}, skipping')
|
||||
return False
|
||||
except OSError as exc:
|
||||
self.ap.logger.warning(f'Failed to read skill entry file {entry_path}: {exc}, skipping')
|
||||
return False
|
||||
|
||||
metadata, instructions = parse_frontmatter(content)
|
||||
name = str(metadata.get('name') or os.path.basename(os.path.normpath(package_root))).strip()
|
||||
if not name:
|
||||
self.ap.logger.warning(f'Skill at {package_root} has no valid name, skipping')
|
||||
return False
|
||||
|
||||
stat = os.stat(entry_path)
|
||||
skill_data.clear()
|
||||
skill_data.update(
|
||||
{
|
||||
'name': name,
|
||||
'display_name': str(metadata.get('display_name') or name).strip(),
|
||||
'description': str(metadata.get('description') or '').strip(),
|
||||
'instructions': instructions,
|
||||
'raw_content': content,
|
||||
'package_root': package_root,
|
||||
'entry_file': entry_file,
|
||||
'auto_activate': bool(metadata.get('auto_activate', True)),
|
||||
'created_at': dt.datetime.fromtimestamp(stat.st_ctime, tz=dt.timezone.utc).isoformat(),
|
||||
'updated_at': dt.datetime.fromtimestamp(stat.st_mtime, tz=dt.timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _normalize_package_root(package_root: str) -> str:
|
||||
if not package_root:
|
||||
return ''
|
||||
return os.path.realpath(os.path.abspath(package_root))
|
||||
|
||||
def get_skill_by_name(self, name: str) -> dict | None:
|
||||
return self.skills.get(name)
|
||||
|
||||
def get_skill_index(self, pipeline_uuid: str | None = None, bound_skills: list[str] | None = None) -> str:
|
||||
skills_to_index = []
|
||||
for skill in self.skills.values():
|
||||
if not skill.get('auto_activate', True):
|
||||
continue
|
||||
if bound_skills is not None and skill['name'] not in bound_skills:
|
||||
continue
|
||||
skills_to_index.append(skill)
|
||||
|
||||
if not skills_to_index:
|
||||
return ''
|
||||
|
||||
lines = ['Available Skills:']
|
||||
for skill in skills_to_index:
|
||||
display = skill.get('display_name') or skill['name']
|
||||
lines.append(f'- {skill["name"]} ({display}): {skill.get("description", "")}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_skill_aware_prompt_addition(
|
||||
self, pipeline_uuid: str | None = None, bound_skills: list[str] | None = None
|
||||
) -> str:
|
||||
skill_index = self.get_skill_index(pipeline_uuid, bound_skills)
|
||||
if not skill_index:
|
||||
return ''
|
||||
|
||||
return f"""
|
||||
|
||||
{skill_index}
|
||||
|
||||
When the user's request clearly matches one or more skills based on their descriptions, you should activate them.
|
||||
To activate a skill, include this marker at the beginning of your response: [ACTIVATE_SKILL: skill-name]
|
||||
If multiple skills are needed, include multiple activation markers at the beginning of your response, one per line.
|
||||
After activation, the selected skills' detailed instructions will be loaded for you to follow.
|
||||
Use the first activated skill as the primary skill. Use any additional activated skills as supporting guidance.
|
||||
If you need to inspect a visible skill before activation, use `read` on `/workspace/.skills/<skill-name>/SKILL.md` or other files under that path.
|
||||
For prompt-only skills or skills that mainly consist of instructions, use `create_skill` to create them directly in the managed skills store.
|
||||
Use `list_skills` or `get_skill` before editing when you need to inspect what already exists.
|
||||
Use `update_skill` to modify an existing managed skill's metadata or instructions without relying on `/workspace`, box, or skill activation.
|
||||
Use `delete_skill` when the user explicitly wants to remove a managed skill.
|
||||
Pure prompt skills should not depend on box just to be created or modified later.
|
||||
When creating a new skill package with extra files, scripts, or assets, first prepare it under `/workspace` with the standard `exec`, `read`, `write`, and `edit` tools.
|
||||
Then use `import_skill_from_directory` to import that prepared directory into the managed skills store.
|
||||
Use `reload_skills` when you need LangBot to rescan managed skills after filesystem changes.
|
||||
If no skill matches, respond normally without activation.
|
||||
"""
|
||||
|
||||
def detect_skill_activations(self, response: str) -> list[str]:
|
||||
if self.SKILL_ACTIVATION_MARKER not in response:
|
||||
return []
|
||||
|
||||
activated: list[str] = []
|
||||
for skill_name in re.findall(r'\[ACTIVATE_SKILL:\s*(\S+?)\s*\]', response):
|
||||
if skill_name in self.skills and skill_name not in activated:
|
||||
activated.append(skill_name)
|
||||
return activated
|
||||
|
||||
def detect_skill_activation(self, response: str) -> str | None:
|
||||
activations = self.detect_skill_activations(response)
|
||||
return activations[0] if activations else None
|
||||
|
||||
def get_skill_runtime_data(self, skill_name: str) -> dict | None:
|
||||
skill = self.skills.get(skill_name)
|
||||
if not skill:
|
||||
return None
|
||||
return {'skill': skill, 'instructions': skill.get('instructions', '')}
|
||||
|
||||
def build_activation_prompt(self, skill_name: str) -> str:
|
||||
resolved = self.get_skill_runtime_data(skill_name)
|
||||
if not resolved:
|
||||
return ''
|
||||
|
||||
instructions = resolved['instructions']
|
||||
return f"""
|
||||
<activated_skill name=\"{skill_name}\">
|
||||
|
||||
## Instructions
|
||||
{instructions}
|
||||
|
||||
## Runtime Context
|
||||
The activated skill package is available through the standard runtime tools under `/workspace/.skills/{skill_name}`.
|
||||
Use `read` to inspect files there. Use `exec` with `workdir` set to `/workspace/.skills/{skill_name}` to run commands in that package.
|
||||
Use `write` and `edit` on that path when the instructions require updating files.
|
||||
Do not create a new skill by writing directly into `/workspace/.skills/...`; use `create_skill` for prompt-only skills, `update_skill` to change an existing managed skill, `list_skills` or `get_skill` to inspect managed skills, or prepare the new skill under `/workspace` and import it with `import_skill_from_directory`.
|
||||
|
||||
</activated_skill>
|
||||
|
||||
Now execute the above skill instructions step by step to complete the user's request.
|
||||
Use the standard `exec`, `read`, `write`, and `edit` tools against `/workspace/.skills/{skill_name}` when you need to inspect or modify the skill package.
|
||||
Respond to the user based on the skill's guidance.
|
||||
"""
|
||||
|
||||
def build_activation_prompt_for_skills(self, skill_names: list[str]) -> str:
|
||||
if not skill_names:
|
||||
return ''
|
||||
|
||||
activated_skill_names: list[str] = []
|
||||
for skill_name in skill_names:
|
||||
if skill_name in self.skills and skill_name not in activated_skill_names:
|
||||
activated_skill_names.append(skill_name)
|
||||
if not activated_skill_names:
|
||||
return ''
|
||||
|
||||
blocks: list[str] = []
|
||||
for skill_name in activated_skill_names:
|
||||
resolved = self.get_skill_runtime_data(skill_name)
|
||||
if not resolved:
|
||||
continue
|
||||
instructions = resolved['instructions']
|
||||
role = 'primary' if skill_name == activated_skill_names[0] else 'auxiliary'
|
||||
blocks.append(
|
||||
f"""
|
||||
<activated_skill name=\"{skill_name}\" role=\"{role}\">\n\n## Instructions\n{instructions}\n\n## Runtime Context\nUse the standard `exec`, `read`, `write`, and `edit` tools for activated skills.\nEach activated skill package is available under `/workspace/.skills/<skill-name>`.\nFor a given skill, set `exec.workdir` to `/workspace/.skills/<skill-name>` and use that prefix in file tool paths.\nDo not create a new skill under `/workspace/.skills/...`; use `create_skill` for prompt-only skills, `list_skills` or `get_skill` to inspect managed skills, `update_skill` to change an existing managed skill, or prepare new skill directories under `/workspace` and import them with `import_skill_from_directory`.\n\n</activated_skill>
|
||||
""".strip()
|
||||
)
|
||||
if not blocks:
|
||||
return ''
|
||||
|
||||
activated_list = ', '.join(activated_skill_names)
|
||||
return f"""
|
||||
Activated skills: {activated_list}
|
||||
|
||||
{chr(10).join(blocks)}
|
||||
|
||||
Now execute the activated skills to complete the user's request.
|
||||
Treat the first activated skill as the primary skill.
|
||||
Treat additional activated skills as supporting guidance when they do not conflict with the primary skill.
|
||||
If guidance conflicts, prefer: primary skill > auxiliary skills.
|
||||
Use the standard `exec`, `read`, `write`, and `edit` tools against the corresponding `/workspace/.skills/<skill-name>` path whenever you need to inspect or modify an activated skill package.
|
||||
Respond to the user with one coherent answer that integrates the activated skills.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def remove_activation_marker(response: str) -> str:
|
||||
return re.sub(r'\[ACTIVATE_SKILL:\s*\S+?\s*\]\s*', '', response).lstrip()
|
||||
37
src/langbot/pkg/skill/utils.py
Normal file
37
src/langbot/pkg/skill/utils.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Shared utilities for skill file parsing."""
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
||||
"""Parse YAML frontmatter from markdown content.
|
||||
|
||||
Expects format:
|
||||
---
|
||||
name: my-skill
|
||||
description: Does something
|
||||
---
|
||||
# Actual instructions...
|
||||
|
||||
Returns:
|
||||
Tuple of (metadata dict, remaining content)
|
||||
"""
|
||||
if not content.startswith('---'):
|
||||
return {}, content
|
||||
|
||||
parts = content.split('---', 2)
|
||||
if len(parts) < 3:
|
||||
return {}, content
|
||||
|
||||
frontmatter_str = parts[1].strip()
|
||||
instructions = parts[2].strip()
|
||||
|
||||
try:
|
||||
metadata = yaml.safe_load(frontmatter_str) or {}
|
||||
except yaml.YAMLError:
|
||||
metadata = {}
|
||||
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
return metadata, instructions
|
||||
@@ -1,37 +1,70 @@
|
||||
"""Utility functions for finding package resources"""
|
||||
"""Utility functions for finding package resources and runtime data roots."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_is_source_install = None
|
||||
_source_root = None
|
||||
|
||||
|
||||
def _find_source_root() -> Path | None:
|
||||
"""Locate the LangBot repository root when running from source."""
|
||||
global _source_root
|
||||
|
||||
if _source_root is not None:
|
||||
return _source_root
|
||||
|
||||
current = Path(__file__).resolve()
|
||||
for parent in current.parents:
|
||||
if (parent / 'pyproject.toml').exists() and (parent / 'main.py').exists():
|
||||
_source_root = parent
|
||||
return parent
|
||||
|
||||
_source_root = None
|
||||
return None
|
||||
|
||||
|
||||
def _check_if_source_install() -> bool:
|
||||
"""
|
||||
Check if we're running from source directory or an installed package.
|
||||
Cached to avoid repeated file I/O.
|
||||
Check if we're running from the LangBot source tree.
|
||||
Cached to avoid repeated filesystem scans.
|
||||
"""
|
||||
global _is_source_install
|
||||
|
||||
if _is_source_install is not None:
|
||||
return _is_source_install
|
||||
|
||||
# Check if main.py exists in current directory with LangBot marker
|
||||
if os.path.exists('main.py'):
|
||||
try:
|
||||
with open('main.py', 'r', encoding='utf-8') as f:
|
||||
# Only read first 500 chars to check for marker
|
||||
content = f.read(500)
|
||||
if 'LangBot/main.py' in content:
|
||||
_is_source_install = True
|
||||
return True
|
||||
except (IOError, OSError, UnicodeDecodeError):
|
||||
# If we can't read the file, assume not a source install
|
||||
pass
|
||||
_is_source_install = _find_source_root() is not None
|
||||
return _is_source_install
|
||||
|
||||
_is_source_install = False
|
||||
return False
|
||||
|
||||
def get_data_root() -> str:
|
||||
"""
|
||||
Get the runtime data root.
|
||||
|
||||
Priority:
|
||||
1. LANGBOT_DATA_ROOT environment override
|
||||
2. Source checkout root /data when running from source
|
||||
3. Current working directory /data for installed-package usage
|
||||
"""
|
||||
env_root = os.environ.get('LANGBOT_DATA_ROOT', '').strip()
|
||||
if env_root:
|
||||
return str(Path(env_root).expanduser().resolve())
|
||||
|
||||
source_root = _find_source_root()
|
||||
if source_root is not None:
|
||||
return str((source_root / 'data').resolve())
|
||||
|
||||
return str((Path.cwd() / 'data').resolve())
|
||||
|
||||
|
||||
def get_data_path(*parts: str) -> str:
|
||||
"""Join path segments under the resolved data root."""
|
||||
data_root = Path(get_data_root())
|
||||
if not parts:
|
||||
return str(data_root)
|
||||
return str((data_root.joinpath(*parts)).resolve())
|
||||
|
||||
|
||||
def get_frontend_path() -> str:
|
||||
@@ -76,8 +109,11 @@ def get_resource_path(resource: str) -> str:
|
||||
Absolute path to the resource
|
||||
"""
|
||||
# First, check if resource exists in current directory (source install)
|
||||
if _check_if_source_install() and os.path.exists(resource):
|
||||
return resource
|
||||
source_root = _find_source_root()
|
||||
if source_root is not None:
|
||||
source_resource = source_root / resource
|
||||
if source_resource.exists():
|
||||
return str(source_resource)
|
||||
|
||||
# Second, check current directory anyway
|
||||
if os.path.exists(resource):
|
||||
|
||||
@@ -93,6 +93,7 @@ box:
|
||||
shared_host_root: './data/box' # For Docker deployment, use '/workspaces'
|
||||
default_host_workspace: '' # Defaults to '<shared_host_root>/default'
|
||||
allowed_host_mount_roots: # Defaults to ['<shared_host_root>'] when left empty
|
||||
- './data/box'
|
||||
- '/tmp'
|
||||
space:
|
||||
# Space service URL for OAuth and API
|
||||
|
||||
@@ -91,6 +91,101 @@ class RecordingStreamProvider:
|
||||
return _stream()
|
||||
|
||||
|
||||
class ActivationProvider:
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
|
||||
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.requests.append(
|
||||
{
|
||||
'messages': list(messages),
|
||||
'funcs': list(funcs),
|
||||
'remove_think': remove_think,
|
||||
}
|
||||
)
|
||||
if len(self.requests) == 1:
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='[ACTIVATE_SKILL: demo]\nI will use the skill.',
|
||||
)
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='final answer after activation',
|
||||
)
|
||||
|
||||
|
||||
class FailingActivationProvider:
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
|
||||
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.requests.append(
|
||||
{
|
||||
'messages': list(messages),
|
||||
'funcs': list(funcs),
|
||||
'remove_think': remove_think,
|
||||
}
|
||||
)
|
||||
if len(self.requests) == 1:
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='[ACTIVATE_SKILL: demo]\nI will use the skill.',
|
||||
)
|
||||
raise RuntimeError('activation failed')
|
||||
|
||||
|
||||
class ActivationStreamProvider:
|
||||
def __init__(self):
|
||||
self.stream_requests: list[dict] = []
|
||||
|
||||
def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.stream_requests.append(
|
||||
{
|
||||
'messages': list(messages),
|
||||
'funcs': list(funcs),
|
||||
'remove_think': remove_think,
|
||||
}
|
||||
)
|
||||
|
||||
async def _stream():
|
||||
if len(self.stream_requests) == 1:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content='[ACTIVATE_SKILL: demo]\nI will use the skill.',
|
||||
is_final=True,
|
||||
)
|
||||
return
|
||||
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content='final answer after activation',
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
return _stream()
|
||||
|
||||
|
||||
def make_skill_manager():
|
||||
skill_data = {
|
||||
'uuid': 'skill-demo',
|
||||
'name': 'demo',
|
||||
'instructions': 'Do the demo task.',
|
||||
'type': 'skill',
|
||||
'package_root': '/tmp/demo-skill',
|
||||
'sandbox_timeout_sec': 120,
|
||||
'sandbox_network': False,
|
||||
}
|
||||
return SimpleNamespace(
|
||||
SKILL_ACTIVATION_MARKER='[ACTIVATE_SKILL:',
|
||||
detect_skill_activations=Mock(
|
||||
side_effect=lambda content: ['demo'] if '[ACTIVATE_SKILL: demo]' in (content or '') else []
|
||||
),
|
||||
build_activation_prompt_for_skills=Mock(return_value='skill prompt'),
|
||||
get_skill_by_name=Mock(side_effect=lambda name: skill_data if name == 'demo' else None),
|
||||
remove_activation_marker=Mock(side_effect=lambda content: (content or '').replace('[ACTIVATE_SKILL: demo]\n', '')),
|
||||
)
|
||||
|
||||
|
||||
def make_query() -> pipeline_query.Query:
|
||||
adapter = AsyncMock()
|
||||
adapter.is_stream_output_supported = AsyncMock(return_value=False)
|
||||
@@ -168,6 +263,11 @@ async def test_localagent_uses_exec_for_exact_calculation():
|
||||
)
|
||||
),
|
||||
),
|
||||
skill_mgr=SimpleNamespace(
|
||||
get_skills_for_pipeline=AsyncMock(return_value=[]),
|
||||
detect_skill_activation=AsyncMock(return_value=None),
|
||||
build_activation_prompt=Mock(return_value=None),
|
||||
),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
@@ -222,6 +322,11 @@ async def test_localagent_streaming_tool_error_yields_message_chunks():
|
||||
box_service=SimpleNamespace(
|
||||
get_system_guidance=Mock(return_value='sandbox guidance'),
|
||||
),
|
||||
skill_mgr=SimpleNamespace(
|
||||
get_skills_for_pipeline=AsyncMock(return_value=[]),
|
||||
detect_skill_activation=AsyncMock(return_value=None),
|
||||
build_activation_prompt=Mock(return_value=None),
|
||||
),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
@@ -230,3 +335,110 @@ async def test_localagent_streaming_tool_error_yields_message_chunks():
|
||||
|
||||
assert all(isinstance(message, provider_message.MessageChunk) for message in results)
|
||||
assert any(message.role == 'tool' and message.content == 'err: boom' for message in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localagent_hides_activation_marker_before_follow_up_request():
|
||||
provider = ActivationProvider()
|
||||
model = SimpleNamespace(
|
||||
provider=provider,
|
||||
model_entity=SimpleNamespace(
|
||||
uuid='test-model-uuid',
|
||||
name='test-model',
|
||||
abilities=['func_call'],
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock()),
|
||||
rag_mgr=SimpleNamespace(),
|
||||
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
|
||||
skill_mgr=make_skill_manager(),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = make_query()
|
||||
query.use_funcs = []
|
||||
|
||||
results = [message async for message in runner.run(query)]
|
||||
|
||||
assert [(message.role, message.content) for message in results] == [
|
||||
('assistant', 'final answer after activation')
|
||||
]
|
||||
assert len(provider.requests) == 2
|
||||
assert provider.requests[1]['messages'][-2].content == 'I will use the skill.'
|
||||
assert '[ACTIVATE_SKILL:' not in provider.requests[1]['messages'][-2].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localagent_activation_failure_rolls_back_query_state_and_sanitizes_response():
|
||||
provider = FailingActivationProvider()
|
||||
model = SimpleNamespace(
|
||||
provider=provider,
|
||||
model_entity=SimpleNamespace(
|
||||
uuid='test-model-uuid',
|
||||
name='test-model',
|
||||
abilities=['func_call'],
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock()),
|
||||
rag_mgr=SimpleNamespace(),
|
||||
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
|
||||
skill_mgr=make_skill_manager(),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = make_query()
|
||||
query.use_funcs = []
|
||||
|
||||
results = [message async for message in runner.run(query)]
|
||||
|
||||
assert [(message.role, message.content) for message in results] == [
|
||||
('assistant', 'I will use the skill.')
|
||||
]
|
||||
assert query.use_funcs == []
|
||||
assert query.variables == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localagent_streaming_activation_does_not_leak_marker():
|
||||
provider = ActivationStreamProvider()
|
||||
model = SimpleNamespace(
|
||||
provider=provider,
|
||||
model_entity=SimpleNamespace(
|
||||
uuid='test-model-uuid',
|
||||
name='test-model',
|
||||
abilities=['func_call'],
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
|
||||
adapter = AsyncMock()
|
||||
adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock()),
|
||||
rag_mgr=SimpleNamespace(),
|
||||
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
|
||||
skill_mgr=make_skill_manager(),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = make_query()
|
||||
query.adapter = adapter
|
||||
query.use_funcs = []
|
||||
|
||||
results = [message async for message in runner.run(query)]
|
||||
|
||||
assert all(isinstance(message, provider_message.MessageChunk) for message in results)
|
||||
assert [message.content for message in results] == ['final answer after activation']
|
||||
|
||||
569
tests/unit_tests/provider/test_skill_tools.py
Normal file
569
tests/unit_tests/provider/test_skill_tools.py
Normal file
@@ -0,0 +1,569 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_ap(logger=None):
|
||||
ap = SimpleNamespace()
|
||||
ap.logger = logger or Mock()
|
||||
ap.persistence_mgr = Mock()
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))
|
||||
ap.persistence_mgr.serialize_model = Mock(side_effect=lambda cls, row: row)
|
||||
return ap
|
||||
|
||||
|
||||
def _make_skill_data(
|
||||
name='test-skill',
|
||||
instructions='Do something',
|
||||
package_root='',
|
||||
entry_file='SKILL.md',
|
||||
auto_activate=True,
|
||||
**kwargs,
|
||||
):
|
||||
return {
|
||||
'name': name,
|
||||
'display_name': kwargs.pop('display_name', name),
|
||||
'description': kwargs.pop('description', f'Description of {name}'),
|
||||
'instructions': instructions,
|
||||
'package_root': package_root,
|
||||
'entry_file': entry_file,
|
||||
'auto_activate': auto_activate,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
|
||||
class TestSkillManagerPackageLoading:
|
||||
def test_load_skill_file_success(self):
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_md = os.path.join(tmpdir, 'SKILL.md')
|
||||
with open(skill_md, 'w', encoding='utf-8') as f:
|
||||
f.write('---\ndescription: Test skill\n---\n\n# Test Skill\nDo things.')
|
||||
|
||||
skill_data = _make_skill_data(package_root=tmpdir)
|
||||
result = mgr._load_skill_file(skill_data)
|
||||
|
||||
assert result is True
|
||||
assert skill_data['instructions'] == '# Test Skill\nDo things.'
|
||||
assert skill_data['description'] == 'Test skill'
|
||||
|
||||
def test_refresh_skill_from_disk_updates_cached_dict_in_place(self):
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_md = os.path.join(tmpdir, 'SKILL.md')
|
||||
with open(skill_md, 'w', encoding='utf-8') as f:
|
||||
f.write('---\ndescription: First\n---\n\nOriginal instructions')
|
||||
|
||||
skill_data = _make_skill_data(name='test-skill', package_root=tmpdir)
|
||||
assert mgr._load_skill_file(skill_data) is True
|
||||
|
||||
mgr.skills['test-skill'] = skill_data
|
||||
|
||||
with open(skill_md, 'w', encoding='utf-8') as f:
|
||||
f.write('---\ndescription: Second\n---\n\nUpdated instructions')
|
||||
|
||||
assert mgr.refresh_skill_from_disk('test-skill') is True
|
||||
assert mgr.skills['test-skill'] is skill_data
|
||||
assert skill_data['instructions'] == 'Updated instructions'
|
||||
assert skill_data['description'] == 'Second'
|
||||
|
||||
|
||||
class TestSkillManagerActivation:
|
||||
def test_detect_skill_activations_returns_unique_ordered_skills(self):
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
mgr.skills = {
|
||||
'alpha': _make_skill_data(name='alpha'),
|
||||
'beta': _make_skill_data(name='beta'),
|
||||
}
|
||||
|
||||
response = (
|
||||
'[ACTIVATE_SKILL: alpha]\n'
|
||||
'[ACTIVATE_SKILL: beta]\n'
|
||||
'[ACTIVATE_SKILL: alpha]\n'
|
||||
'Let me handle this.'
|
||||
)
|
||||
|
||||
assert mgr.detect_skill_activations(response) == ['alpha', 'beta']
|
||||
assert mgr.detect_skill_activation(response) == 'alpha'
|
||||
|
||||
def test_build_activation_prompt_for_skills_includes_runtime_guidance(self):
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
mgr.skills = {
|
||||
'primary': _make_skill_data(name='primary', instructions='Primary instructions'),
|
||||
'aux': _make_skill_data(name='aux', instructions='Aux instructions'),
|
||||
}
|
||||
|
||||
prompt = mgr.build_activation_prompt_for_skills(['primary', 'aux'])
|
||||
|
||||
assert 'Activated skills: primary, aux' in prompt
|
||||
assert 'role="primary"' in prompt
|
||||
assert 'role="auxiliary"' in prompt
|
||||
assert '/workspace/.skills/<skill-name>' in prompt
|
||||
|
||||
def test_remove_activation_marker_removes_multiple_markers(self):
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
|
||||
response = '[ACTIVATE_SKILL: alpha]\n[ACTIVATE_SKILL: beta]\nFinal answer'
|
||||
assert mgr.remove_activation_marker(response) == 'Final answer'
|
||||
|
||||
|
||||
class TestSkillActivationHelper:
|
||||
def test_prepare_skill_activation_registers_only_explicit_activated_skills(self):
|
||||
from langbot.pkg.skill.activation import prepare_skill_activation
|
||||
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
mgr.skills = {
|
||||
'primary': _make_skill_data(name='primary', instructions='Primary instructions'),
|
||||
'aux': _make_skill_data(name='aux', instructions='Aux instructions'),
|
||||
}
|
||||
ap.skill_mgr = mgr
|
||||
|
||||
query = SimpleNamespace(variables={}, use_funcs=[])
|
||||
activation = prepare_skill_activation(
|
||||
ap,
|
||||
query,
|
||||
'[ACTIVATE_SKILL: primary]\n[ACTIVATE_SKILL: aux]\nWorking on it.',
|
||||
)
|
||||
|
||||
assert activation is not None
|
||||
assert activation.activated_skill_names == ['primary', 'aux']
|
||||
assert activation.cleaned_content == 'Working on it.'
|
||||
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'primary', 'aux'}
|
||||
|
||||
|
||||
class TestSkillPathHelpers:
|
||||
def test_get_visible_skills_filters_by_bound_names(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, get_visible_skills
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(
|
||||
skills={
|
||||
'visible': _make_skill_data(name='visible'),
|
||||
'hidden': _make_skill_data(name='hidden'),
|
||||
}
|
||||
)
|
||||
query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
|
||||
|
||||
result = get_visible_skills(ap, query)
|
||||
|
||||
assert list(result.keys()) == ['visible']
|
||||
|
||||
def test_resolve_virtual_skill_path_allows_visible_skill_reads(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import (
|
||||
PIPELINE_BOUND_SKILLS_KEY,
|
||||
resolve_virtual_skill_path,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
|
||||
query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||
|
||||
skill, rewritten = resolve_virtual_skill_path(
|
||||
ap,
|
||||
query,
|
||||
'/workspace/.skills/demo/SKILL.md',
|
||||
include_visible=True,
|
||||
include_activated=False,
|
||||
)
|
||||
|
||||
assert skill['name'] == 'demo'
|
||||
assert rewritten == '/workspace/SKILL.md'
|
||||
|
||||
def test_build_skill_session_id_uses_name_based_identifier(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import build_skill_session_id
|
||||
|
||||
with_launcher = build_skill_session_id(
|
||||
{'name': 'writer'},
|
||||
SimpleNamespace(query_id=42, launcher_type='person', launcher_id='123'),
|
||||
)
|
||||
fallback = build_skill_session_id({'name': 'writer'}, SimpleNamespace(query_id=99))
|
||||
|
||||
assert with_launcher == 'skill-person_123-writer'
|
||||
assert fallback == 'skill-99-writer'
|
||||
|
||||
def test_should_prepare_skill_python_env_detects_manifests_and_venv(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import should_prepare_skill_python_env
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
assert should_prepare_skill_python_env(tmpdir) is False
|
||||
|
||||
with open(os.path.join(tmpdir, 'requirements.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('requests==2.32.0\n')
|
||||
assert should_prepare_skill_python_env(tmpdir) is True
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.makedirs(os.path.join(tmpdir, '.venv'))
|
||||
assert should_prepare_skill_python_env(tmpdir) is True
|
||||
|
||||
def test_wrap_skill_command_with_python_env_bootstraps_then_runs_command(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import wrap_skill_command_with_python_env
|
||||
|
||||
command = wrap_skill_command_with_python_env('python scripts/run.py')
|
||||
|
||||
assert 'python -m venv "$_LB_VENV_DIR"' in command
|
||||
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
|
||||
assert command.rstrip().endswith('python scripts/run.py')
|
||||
|
||||
|
||||
class TestSkillAuthoringToolLoader:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_creates_managed_prompt_only_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
CREATE_SKILL_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
create_skill=AsyncMock(return_value=_make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill')),
|
||||
reload_skills=AsyncMock(),
|
||||
list_skills=AsyncMock(return_value=[]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
CREATE_SKILL_TOOL_NAME,
|
||||
{
|
||||
'name': 'prompt-skill',
|
||||
'display_name': 'Prompt Skill',
|
||||
'description': 'Prompt only skill',
|
||||
'instructions': 'Follow these steps carefully.',
|
||||
'auto_activate': False,
|
||||
},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
ap.skill_service.create_skill.assert_awaited_once_with(
|
||||
{
|
||||
'name': 'prompt-skill',
|
||||
'display_name': 'Prompt Skill',
|
||||
'description': 'Prompt only skill',
|
||||
'instructions': 'Follow these steps carefully.',
|
||||
'auto_activate': False,
|
||||
}
|
||||
)
|
||||
assert result == {
|
||||
'created': True,
|
||||
'skill': _make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill'),
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_returns_managed_skills(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
LIST_SKILLS_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
list_skills=AsyncMock(return_value=[_make_skill_data(name='alpha'), _make_skill_data(name='beta')]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(LIST_SKILLS_TOOL_NAME, {}, SimpleNamespace())
|
||||
|
||||
assert result == {
|
||||
'skills': [_make_skill_data(name='alpha'), _make_skill_data(name='beta')],
|
||||
'skill_names': ['alpha', 'beta'],
|
||||
'count': 2,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_skill_returns_one_managed_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
GET_SKILL_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
get_skill=AsyncMock(return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now')),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(GET_SKILL_TOOL_NAME, {'name': 'time-now'}, SimpleNamespace())
|
||||
|
||||
ap.skill_service.get_skill.assert_awaited_once_with('time-now')
|
||||
assert result == {
|
||||
'skill': _make_skill_data(name='time-now', package_root='/data/skills/time-now'),
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_updates_managed_prompt_only_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
UPDATE_SKILL_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
create_skill=AsyncMock(),
|
||||
update_skill=AsyncMock(return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now')),
|
||||
reload_skills=AsyncMock(),
|
||||
list_skills=AsyncMock(return_value=[]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
UPDATE_SKILL_TOOL_NAME,
|
||||
{
|
||||
'name': 'time-now',
|
||||
'description': 'Fixed to Beijing time',
|
||||
'instructions': 'Always use Asia/Shanghai and never offer other timezones.',
|
||||
'auto_activate': True,
|
||||
},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
ap.skill_service.update_skill.assert_awaited_once_with(
|
||||
'time-now',
|
||||
{
|
||||
'name': 'time-now',
|
||||
'description': 'Fixed to Beijing time',
|
||||
'instructions': 'Always use Asia/Shanghai and never offer other timezones.',
|
||||
'auto_activate': True,
|
||||
},
|
||||
)
|
||||
assert result == {
|
||||
'updated': True,
|
||||
'skill': _make_skill_data(name='time-now', package_root='/data/skills/time-now'),
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_skill_deletes_managed_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
DELETE_SKILL_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
delete_skill=AsyncMock(return_value=True),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(DELETE_SKILL_TOOL_NAME, {'name': 'time-now'}, SimpleNamespace())
|
||||
|
||||
ap.skill_service.delete_skill.assert_awaited_once_with('time-now')
|
||||
assert result == {
|
||||
'deleted': True,
|
||||
'skill_name': 'time-now',
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_skill_from_directory_uses_workspace_path_and_service_import(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace')
|
||||
ap.skill_service = SimpleNamespace(
|
||||
scan_directory=Mock(
|
||||
return_value={
|
||||
'name': 'cloned-skill',
|
||||
'display_name': 'Cloned Skill',
|
||||
'description': 'Imported from clone',
|
||||
'instructions': 'Do work',
|
||||
'auto_activate': True,
|
||||
}
|
||||
),
|
||||
create_skill=AsyncMock(return_value=_make_skill_data(name='cloned-skill', package_root='/repo/root')),
|
||||
reload_skills=AsyncMock(),
|
||||
list_skills=AsyncMock(return_value=[]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap.box_service.default_host_workspace = tmpdir
|
||||
repo_dir = os.path.join(tmpdir, 'repos', 'cloned-skill')
|
||||
os.makedirs(repo_dir)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
{'path': '/workspace/repos/cloned-skill'},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
ap.skill_service.scan_directory.assert_called_once_with(os.path.realpath(repo_dir))
|
||||
ap.skill_service.create_skill.assert_awaited_once_with(
|
||||
{
|
||||
'name': 'cloned-skill',
|
||||
'display_name': 'Cloned Skill',
|
||||
'description': 'Imported from clone',
|
||||
'instructions': 'Do work',
|
||||
'package_root': os.path.realpath(repo_dir),
|
||||
'auto_activate': True,
|
||||
}
|
||||
)
|
||||
assert result['imported'] is True
|
||||
assert result['source_path'] == '/workspace/repos/cloned-skill'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_skill_from_directory_rejects_workspace_escape(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace')
|
||||
ap.skill_service = SimpleNamespace(
|
||||
scan_directory=Mock(),
|
||||
create_skill=AsyncMock(),
|
||||
reload_skills=AsyncMock(),
|
||||
list_skills=AsyncMock(return_value=[]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
with pytest.raises(ValueError, match='escapes the workspace boundary'):
|
||||
await loader.invoke_tool(
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
{'path': '/workspace/../../etc'},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_skills_rescans_filesystem_and_returns_current_names(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
RELOAD_SKILLS_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
reload_skills=AsyncMock(),
|
||||
list_skills=AsyncMock(return_value=[_make_skill_data(name='alpha'), _make_skill_data(name='beta')]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(RELOAD_SKILLS_TOOL_NAME, {}, SimpleNamespace())
|
||||
|
||||
assert result == {
|
||||
'reloaded': True,
|
||||
'skill_names': ['alpha', 'beta'],
|
||||
'count': 2,
|
||||
}
|
||||
ap.skill_service.reload_skills.assert_awaited_once_with()
|
||||
|
||||
|
||||
class TestNativeToolLoaderSkillPaths:
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_visible_skill_file(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_md = os.path.join(tmpdir, 'SKILL.md')
|
||||
with open(skill_md, 'w', encoding='utf-8') as f:
|
||||
f.write('demo instructions')
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir)
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||
loader = NativeToolLoader(ap)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{'path': '/workspace/.skills/demo/SKILL.md'},
|
||||
SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}),
|
||||
)
|
||||
|
||||
assert result == {'ok': True, 'content': 'demo instructions'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
default_host_workspace=tmpdir,
|
||||
execute_spec_payload=AsyncMock(return_value={'ok': True}),
|
||||
)
|
||||
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
|
||||
loader = NativeToolLoader(ap)
|
||||
|
||||
query = SimpleNamespace(query_id='q1', launcher_type='person', launcher_id='123', variables={})
|
||||
register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir))
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'exec',
|
||||
{
|
||||
'command': 'python /workspace/.skills/demo/scripts/run.py',
|
||||
'workdir': '/workspace/.skills/demo',
|
||||
},
|
||||
query,
|
||||
)
|
||||
|
||||
assert result == {'ok': True}
|
||||
spec_payload = ap.box_service.execute_spec_payload.await_args.args[0]
|
||||
assert spec_payload['cmd'] == 'python /workspace/scripts/run.py'
|
||||
assert spec_payload['workdir'] == '/workspace'
|
||||
assert spec_payload['host_path'] == tmpdir
|
||||
assert spec_payload['session_id'] == 'skill-person_123-demo'
|
||||
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with('demo')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_requires_skill_activation(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir)
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||
loader = NativeToolLoader(ap)
|
||||
|
||||
query = SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||
|
||||
with pytest.raises(ValueError, match='Skill "demo" is not available at this path'):
|
||||
await loader.invoke_tool(
|
||||
'write',
|
||||
{'path': '/workspace/.skills/demo/notes.txt', 'content': 'hi'},
|
||||
query,
|
||||
)
|
||||
@@ -42,9 +42,10 @@ def make_tool(name: str) -> resource_tool.LLMTool:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_lists_native_tools_first():
|
||||
async def test_tool_manager_omits_skill_authoring_tools_by_default():
|
||||
manager = ToolManager(SimpleNamespace())
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
||||
manager.skill_authoring_tool_loader = StubLoader([make_tool('reload_skills')])
|
||||
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
@@ -53,11 +54,25 @@ async def test_tool_manager_lists_native_tools_first():
|
||||
assert [tool.name for tool in tools] == ['exec', 'plugin_tool', 'mcp_tool']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_includes_skill_authoring_tools_when_requested():
|
||||
manager = ToolManager(SimpleNamespace())
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
||||
manager.skill_authoring_tool_loader = StubLoader([make_tool('reload_skills')])
|
||||
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
tools = await manager.get_all_tools(include_skill_authoring=True)
|
||||
|
||||
assert [tool.name for tool in tools] == ['exec', 'reload_skills', 'plugin_tool', 'mcp_tool']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_routes_native_tool_calls():
|
||||
app = SimpleNamespace()
|
||||
manager = ToolManager(app)
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')], invoke_result={'backend': 'fake'})
|
||||
manager.skill_authoring_tool_loader = StubLoader([make_tool('reload_skills')])
|
||||
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
|
||||
23
tests/unit_tests/test_paths.py
Normal file
23
tests/unit_tests/test_paths.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.langbot.pkg.utils import paths
|
||||
|
||||
|
||||
def test_get_data_root_uses_source_root_in_repo_checkout():
|
||||
data_root = Path(paths.get_data_root())
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
|
||||
assert data_root == repo_root / 'data'
|
||||
|
||||
|
||||
def test_get_data_path_joins_under_data_root():
|
||||
data_path = Path(paths.get_data_path('skills', 'demo-skill'))
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
|
||||
assert data_path == repo_root / 'data' / 'skills' / 'demo-skill'
|
||||
|
||||
|
||||
def test_get_data_root_honors_env_override(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'custom-data'))
|
||||
|
||||
assert Path(paths.get_data_root()) == (tmp_path / 'custom-data').resolve()
|
||||
134
tests/unit_tests/test_preproc.py
Normal file
134
tests/unit_tests/test_preproc.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.api.entities.builtin.pipeline.query import Query
|
||||
from langbot_plugin.api.entities.builtin.platform.entities import Friend
|
||||
from langbot_plugin.api.entities.builtin.platform.events import FriendMessage
|
||||
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
from langbot_plugin.api.entities.builtin.provider.prompt import Prompt
|
||||
from langbot_plugin.api.entities.builtin.provider.session import Conversation, LauncherTypes, Session
|
||||
|
||||
|
||||
def _make_query() -> Query:
|
||||
message_chain = MessageChain([Plain(text='create a skill')])
|
||||
return Query(
|
||||
query_id=1,
|
||||
launcher_type=LauncherTypes.PERSON,
|
||||
launcher_id='launcher-1',
|
||||
sender_id='sender-1',
|
||||
message_event=FriendMessage(
|
||||
message_chain=message_chain,
|
||||
time=0,
|
||||
sender=Friend(id='sender-1', nickname='Tester', remark='Tester'),
|
||||
),
|
||||
message_chain=message_chain,
|
||||
bot_uuid='bot-1',
|
||||
pipeline_uuid='pipe-1',
|
||||
pipeline_config={
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {
|
||||
'model': {'primary': 'model-1', 'fallbacks': []},
|
||||
'prompt': 'default',
|
||||
'knowledge-bases': [],
|
||||
},
|
||||
},
|
||||
'trigger': {'misc': {}},
|
||||
},
|
||||
variables={},
|
||||
)
|
||||
|
||||
|
||||
def _make_conversation() -> Conversation:
|
||||
return Conversation(
|
||||
prompt=Prompt(name='default', messages=[Message(role='system', content='system prompt')]),
|
||||
messages=[],
|
||||
pipeline_uuid='pipe-1',
|
||||
bot_uuid='bot-1',
|
||||
uuid='conv-1',
|
||||
)
|
||||
|
||||
|
||||
def _make_app(*, skill_service) -> SimpleNamespace:
|
||||
session = Session(launcher_type=LauncherTypes.PERSON, launcher_id='launcher-1', sender_id='sender-1')
|
||||
conversation = _make_conversation()
|
||||
model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'}))
|
||||
tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[]))
|
||||
|
||||
return SimpleNamespace(
|
||||
sess_mgr=SimpleNamespace(
|
||||
get_session=AsyncMock(return_value=session),
|
||||
get_conversation=AsyncMock(return_value=conversation),
|
||||
),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=tool_mgr,
|
||||
plugin_connector=SimpleNamespace(
|
||||
emit_event=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
event=SimpleNamespace(
|
||||
default_prompt=conversation.prompt.messages.copy(),
|
||||
prompt=conversation.messages.copy(),
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
pipeline_service=SimpleNamespace(
|
||||
get_pipeline=AsyncMock(return_value={'extensions_preferences': {'enable_all_skills': True}})
|
||||
),
|
||||
skill_mgr=SimpleNamespace(
|
||||
build_skill_aware_prompt_addition=Mock(return_value=''),
|
||||
skills={},
|
||||
),
|
||||
skill_service=skill_service,
|
||||
logger=Mock(),
|
||||
)
|
||||
|
||||
|
||||
def _import_preproc_modules():
|
||||
fake_app_module = types.ModuleType('langbot.pkg.core.app')
|
||||
fake_app_module.Application = object
|
||||
sys.modules['langbot.pkg.core.app'] = fake_app_module
|
||||
|
||||
for module_name in (
|
||||
'langbot.pkg.pipeline.preproc.preproc',
|
||||
'langbot.pkg.pipeline.stage',
|
||||
):
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
preproc_module = importlib.import_module('langbot.pkg.pipeline.preproc.preproc')
|
||||
entities_module = importlib.import_module('langbot.pkg.pipeline.entities')
|
||||
return preproc_module, entities_module
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_enables_skill_authoring_tools_when_skill_service_available():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
stage = preproc_module.PreProcessor(app)
|
||||
|
||||
result = await stage.process(_make_query(), 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=None)
|
||||
stage = preproc_module.PreProcessor(app)
|
||||
|
||||
result = await stage.process(_make_query(), 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=False)
|
||||
408
tests/unit_tests/test_skill_service.py
Normal file
408
tests/unit_tests/test_skill_service.py
Normal file
@@ -0,0 +1,408 @@
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.langbot.pkg.api.http.service.skill import SkillService
|
||||
|
||||
|
||||
def _create_skill_file(
|
||||
path,
|
||||
*,
|
||||
name: str = 'imported-skill',
|
||||
display_name: str = '',
|
||||
description: str = 'Imported from local directory',
|
||||
auto_activate: bool = True,
|
||||
body: str = 'Skill instructions',
|
||||
) -> None:
|
||||
frontmatter = ['name: ' + name, 'description: ' + description]
|
||||
if display_name:
|
||||
frontmatter.insert(1, 'display_name: ' + display_name)
|
||||
if not auto_activate:
|
||||
frontmatter.append('auto_activate: false')
|
||||
|
||||
path.write_text(
|
||||
'---\n' + '\n'.join(frontmatter) + f'\n---\n\n{body}\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skill_service():
|
||||
app = SimpleNamespace(
|
||||
skill_mgr=SimpleNamespace(
|
||||
refresh_skill_from_disk=lambda *_args, **_kwargs: True,
|
||||
reload_skills=AsyncMock(),
|
||||
)
|
||||
)
|
||||
return SkillService(app)
|
||||
|
||||
|
||||
def test_scan_directory_supports_nested_skill_within_two_levels(skill_service, tmp_path):
|
||||
nested_dir = tmp_path / 'downloaded' / 'self-improving-agent'
|
||||
nested_dir.mkdir(parents=True)
|
||||
_create_skill_file(nested_dir / 'SKILL.md')
|
||||
|
||||
result = skill_service.scan_directory(str(tmp_path))
|
||||
|
||||
assert result['package_root'] == str(nested_dir.resolve())
|
||||
assert result['entry_file'] == 'SKILL.md'
|
||||
assert result['name'] == 'imported-skill'
|
||||
assert result['instructions'] == 'Skill instructions'
|
||||
|
||||
|
||||
def test_scan_directory_rejects_ambiguous_nested_skill_directories(skill_service, tmp_path):
|
||||
first_dir = tmp_path / 'skills' / 'alpha'
|
||||
second_dir = tmp_path / 'skills' / 'beta'
|
||||
first_dir.mkdir(parents=True)
|
||||
second_dir.mkdir(parents=True)
|
||||
_create_skill_file(first_dir / 'SKILL.md', body='alpha instructions')
|
||||
_create_skill_file(second_dir / 'SKILL.md', body='beta instructions')
|
||||
|
||||
with pytest.raises(ValueError, match='Multiple skill directories found'):
|
||||
skill_service.scan_directory(str(tmp_path))
|
||||
|
||||
|
||||
def test_scan_directory_errors_when_skill_is_deeper_than_two_levels(skill_service, tmp_path):
|
||||
deep_dir = tmp_path / 'a' / 'b' / 'c'
|
||||
deep_dir.mkdir(parents=True)
|
||||
_create_skill_file(deep_dir / 'SKILL.md')
|
||||
|
||||
with pytest.raises(ValueError, match='max depth: 2'):
|
||||
skill_service.scan_directory(str(tmp_path))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_import_preserves_existing_skill_content_when_form_fields_blank(tmp_path, monkeypatch):
|
||||
source_dir = tmp_path / 'external-skills' / 'manual-skill'
|
||||
source_dir.mkdir(parents=True)
|
||||
_create_skill_file(
|
||||
source_dir / 'SKILL.md',
|
||||
display_name='Imported Skill',
|
||||
description='Imported description',
|
||||
auto_activate=False,
|
||||
body='Original instructions',
|
||||
)
|
||||
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
service.get_skill_by_name = AsyncMock(return_value=None)
|
||||
managed_root = tmp_path / 'data' / 'skills' / 'imported-skill'
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'imported-skill',
|
||||
'package_root': str(managed_root.resolve()),
|
||||
'description': 'Imported description',
|
||||
'instructions': 'Original instructions',
|
||||
'auto_activate': False,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
|
||||
await service.create_skill(
|
||||
{
|
||||
'name': 'imported-skill',
|
||||
'package_root': str(source_dir),
|
||||
'display_name': '',
|
||||
'description': '',
|
||||
'instructions': '',
|
||||
}
|
||||
)
|
||||
|
||||
content = (managed_root / 'SKILL.md').read_text(encoding='utf-8')
|
||||
assert 'display_name: Imported Skill' in content
|
||||
assert 'description: Imported description' in content
|
||||
assert 'auto_activate: false' in content
|
||||
assert content.endswith('Original instructions')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_reuses_existing_managed_directory_without_copying(tmp_path, monkeypatch):
|
||||
managed_root = tmp_path / 'data' / 'skills' / 'demo-repo' / 'skills' / 'nested-skill'
|
||||
managed_root.mkdir(parents=True)
|
||||
_create_skill_file(
|
||||
managed_root / 'SKILL.md',
|
||||
name='nested-skill',
|
||||
display_name='Nested Skill',
|
||||
description='Already managed',
|
||||
body='Managed instructions',
|
||||
)
|
||||
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
service.get_skill_by_name = AsyncMock(return_value=None)
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'nested-skill',
|
||||
'package_root': str(managed_root.resolve()),
|
||||
'description': 'Already managed',
|
||||
'instructions': 'Managed instructions',
|
||||
'auto_activate': True,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
|
||||
await service.create_skill(
|
||||
{
|
||||
'name': 'nested-skill',
|
||||
'package_root': str(managed_root),
|
||||
'display_name': '',
|
||||
'description': '',
|
||||
'instructions': '',
|
||||
}
|
||||
)
|
||||
|
||||
copied_root = tmp_path / 'data' / 'skills' / 'nested-skill'
|
||||
assert not copied_root.exists()
|
||||
content = (managed_root / 'SKILL.md').read_text(encoding='utf-8')
|
||||
assert 'display_name: Nested Skill' in content
|
||||
assert content.endswith('Managed instructions')
|
||||
|
||||
|
||||
def _build_skill_archive() -> bytes:
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w') as archive:
|
||||
archive.writestr(
|
||||
'demo-repo-main/skills/nested-skill/SKILL.md',
|
||||
'---\n'
|
||||
'name: imported-skill\n'
|
||||
'description: Imported from GitHub archive\n'
|
||||
'---\n\n'
|
||||
'Skill instructions\n',
|
||||
)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_github_supports_nested_skill_archive(skill_service, tmp_path, monkeypatch):
|
||||
archive_bytes = _build_skill_archive()
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self.content = content
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def get(self, url: str) -> _FakeResponse:
|
||||
return _FakeResponse(archive_bytes)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
monkeypatch.setattr('src.langbot.pkg.api.http.service.skill.httpx.AsyncClient', _FakeAsyncClient)
|
||||
skill_service.get_skill = AsyncMock(return_value=None)
|
||||
|
||||
result = await skill_service.install_from_github(
|
||||
{
|
||||
'asset_url': 'https://api.github.com/repos/example/demo-repo/zipball/main',
|
||||
'owner': 'example',
|
||||
'repo': 'demo-repo',
|
||||
'release_tag': 'main',
|
||||
}
|
||||
)
|
||||
|
||||
expected_root = tmp_path / 'data' / 'skills' / 'demo-repo-nested-skill-main'
|
||||
assert result[0]['package_root'] == str(expected_root.resolve())
|
||||
assert (expected_root / 'SKILL.md').read_text(encoding='utf-8').endswith('Skill instructions\n')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_github_rejects_asset_url_outside_requested_repo(skill_service, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
|
||||
with pytest.raises(ValueError, match='owner/repo'):
|
||||
await skill_service.install_from_github(
|
||||
{
|
||||
'asset_url': 'https://api.github.com/repos/example/other-repo/zipball/main',
|
||||
'owner': 'example',
|
||||
'repo': 'demo-repo',
|
||||
'release_tag': 'main',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_github_rejects_zip_with_path_traversal(skill_service, tmp_path, monkeypatch):
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w') as archive:
|
||||
archive.writestr('../escape.txt', 'boom')
|
||||
archive_bytes = stream.getvalue()
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self.content = content
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def get(self, url: str) -> _FakeResponse:
|
||||
return _FakeResponse(archive_bytes)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
monkeypatch.setattr('src.langbot.pkg.api.http.service.skill.httpx.AsyncClient', _FakeAsyncClient)
|
||||
|
||||
with pytest.raises(ValueError, match='unsafe path'):
|
||||
await skill_service.install_from_github(
|
||||
{
|
||||
'asset_url': 'https://api.github.com/repos/example/demo-repo/zipball/main',
|
||||
'owner': 'example',
|
||||
'repo': 'demo-repo',
|
||||
'release_tag': 'main',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_file_operations_stay_within_package_root(skill_service, tmp_path):
|
||||
skill_dir = tmp_path / 'mood-logger'
|
||||
skill_dir.mkdir()
|
||||
_create_skill_file(skill_dir / 'SKILL.md')
|
||||
(skill_dir / 'resources').mkdir()
|
||||
(skill_dir / 'resources' / 'keywords_zh.json').write_text('{"hello": 1}\n', encoding='utf-8')
|
||||
|
||||
skill_record = {
|
||||
'name': 'mood-logger',
|
||||
'package_root': str(skill_dir),
|
||||
'entry_file': 'SKILL.md',
|
||||
}
|
||||
skill_service.get_skill = AsyncMock(return_value=skill_record)
|
||||
|
||||
listed = await skill_service.list_skill_files('mood-logger', path='resources')
|
||||
assert listed['entries'] == [
|
||||
{
|
||||
'path': 'resources/keywords_zh.json',
|
||||
'name': 'keywords_zh.json',
|
||||
'is_dir': False,
|
||||
'size': os.path.getsize(skill_dir / 'resources' / 'keywords_zh.json'),
|
||||
}
|
||||
]
|
||||
|
||||
read_back = await skill_service.read_skill_file('mood-logger', 'resources/keywords_zh.json')
|
||||
assert read_back['content'] == '{"hello": 1}\n'
|
||||
|
||||
written = await skill_service.write_skill_file('mood-logger', 'resources/affinity.py', 'print("ok")\n')
|
||||
assert written['path'] == 'resources/affinity.py'
|
||||
assert (skill_dir / 'resources' / 'affinity.py').read_text(encoding='utf-8') == 'print("ok")\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_file_operations_reject_path_traversal(skill_service, tmp_path):
|
||||
skill_dir = tmp_path / 'mood-logger'
|
||||
skill_dir.mkdir()
|
||||
_create_skill_file(skill_dir / 'SKILL.md')
|
||||
|
||||
skill_service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'mood-logger',
|
||||
'package_root': str(skill_dir),
|
||||
'entry_file': 'SKILL.md',
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='path must stay within the skill package root'):
|
||||
await skill_service.read_skill_file('mood-logger', '../outside.txt')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_rejects_package_root_change(tmp_path):
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
skill_root = tmp_path / 'data' / 'skills' / 'writer'
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'writer',
|
||||
'package_root': str(skill_root.resolve()),
|
||||
'display_name': 'Writer',
|
||||
'description': 'Writes things',
|
||||
'instructions': 'Do work',
|
||||
'auto_activate': True,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='Updating package_root is not supported'):
|
||||
await service.update_skill('writer', {'package_root': str(tmp_path / 'other-root')})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_skill_removes_managed_skill_directory(tmp_path, monkeypatch):
|
||||
managed_root = tmp_path / 'data' / 'skills' / 'self-improving-agent'
|
||||
managed_root.mkdir(parents=True)
|
||||
_create_skill_file(managed_root / 'SKILL.md')
|
||||
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'self-improving-agent',
|
||||
'package_root': str(managed_root.resolve()),
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
|
||||
result = await service.delete_skill('self-improving-agent')
|
||||
|
||||
assert result is True
|
||||
assert not managed_root.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_skill_removes_managed_install_root_for_nested_package(tmp_path, monkeypatch):
|
||||
install_root = tmp_path / 'data' / 'skills' / 'demo-repo'
|
||||
package_root = install_root / 'skills' / 'nested-skill'
|
||||
package_root.mkdir(parents=True)
|
||||
_create_skill_file(package_root / 'SKILL.md')
|
||||
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'nested-skill',
|
||||
'package_root': str(package_root.resolve()),
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
|
||||
await service.delete_skill('nested-skill')
|
||||
|
||||
assert not install_root.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_skill_rejects_external_package_directory(tmp_path, monkeypatch):
|
||||
external_root = tmp_path / 'external-skills' / 'manual-skill'
|
||||
external_root.mkdir(parents=True)
|
||||
_create_skill_file(external_root / 'SKILL.md')
|
||||
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'manual-skill',
|
||||
'package_root': str(external_root.resolve()),
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match='Only managed skills under data/skills'):
|
||||
await service.delete_skill('manual-skill')
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Store,
|
||||
Github,
|
||||
Zap,
|
||||
FilePlus2,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
@@ -119,6 +120,7 @@ const ENTITY_CATEGORY_IDS = [
|
||||
'knowledge',
|
||||
'plugins',
|
||||
'mcp',
|
||||
'skills',
|
||||
] as const;
|
||||
type EntityCategoryId = (typeof ENTITY_CATEGORY_IDS)[number];
|
||||
|
||||
@@ -129,6 +131,7 @@ const DETAIL_PAGE_CATEGORIES: EntityCategoryId[] = [
|
||||
'knowledge',
|
||||
'plugins',
|
||||
'mcp',
|
||||
'skills',
|
||||
];
|
||||
|
||||
// Categories that support creating new entities from the sidebar
|
||||
@@ -138,6 +141,7 @@ const CREATABLE_CATEGORIES: EntityCategoryId[] = [
|
||||
'knowledge',
|
||||
'mcp',
|
||||
'plugins',
|
||||
'skills',
|
||||
];
|
||||
|
||||
// Categories where clicking the parent only toggles collapse (no list page)
|
||||
@@ -146,6 +150,7 @@ const COLLAPSIBLE_ONLY_CATEGORIES: EntityCategoryId[] = [
|
||||
'pipelines',
|
||||
'knowledge',
|
||||
'mcp',
|
||||
'skills',
|
||||
];
|
||||
|
||||
function isEntityCategory(id: string): id is EntityCategoryId {
|
||||
@@ -155,13 +160,14 @@ function isEntityCategory(id: string): id is EntityCategoryId {
|
||||
// Map sidebar config IDs to SidebarDataContext keys
|
||||
const ENTITY_KEY_MAP: Record<
|
||||
EntityCategoryId,
|
||||
'bots' | 'pipelines' | 'knowledgeBases' | 'plugins' | 'mcpServers'
|
||||
'bots' | 'pipelines' | 'knowledgeBases' | 'plugins' | 'mcpServers' | 'skills'
|
||||
> = {
|
||||
bots: 'bots',
|
||||
pipelines: 'pipelines',
|
||||
knowledge: 'knowledgeBases',
|
||||
plugins: 'plugins',
|
||||
mcp: 'mcpServers',
|
||||
skills: 'skills',
|
||||
};
|
||||
|
||||
// Route prefix map for entity detail pages
|
||||
@@ -171,6 +177,7 @@ const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
|
||||
knowledge: '/home/knowledge',
|
||||
plugins: '/home/plugins',
|
||||
mcp: '/home/mcp',
|
||||
skills: '/home/skills',
|
||||
};
|
||||
|
||||
// localStorage key for collapsible section open/closed state
|
||||
@@ -247,7 +254,8 @@ function NavItems({
|
||||
const pathname = location.pathname;
|
||||
const [searchParams] = useSearchParams();
|
||||
const sidebarData = useSidebarData();
|
||||
const { setPendingPluginInstallAction } = sidebarData;
|
||||
const { setPendingPluginInstallAction, setPendingSkillInstallAction } =
|
||||
sidebarData;
|
||||
const { state: sidebarState, isMobile } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
// Track which entity categories have their full list expanded
|
||||
@@ -323,6 +331,22 @@ function NavItems({
|
||||
|
||||
const sectionItems = sidebarConfigList.filter((c) => c.section === section);
|
||||
|
||||
// Persist open state for sections that become active through navigation,
|
||||
// so they remain expanded when the user switches to a different section.
|
||||
const sectionOpenRef = useRef(sectionOpenState);
|
||||
sectionOpenRef.current = sectionOpenState;
|
||||
useEffect(() => {
|
||||
sectionItems.forEach((config) => {
|
||||
if (!isEntityCategory(config.id)) return;
|
||||
const routePrefix = ENTITY_ROUTE_MAP[config.id];
|
||||
const active =
|
||||
pathname === routePrefix || pathname.startsWith(routePrefix + '/');
|
||||
if (active && sectionOpenRef.current[config.id] === undefined) {
|
||||
onSectionToggle(config.id, true);
|
||||
}
|
||||
});
|
||||
}, [pathname, sectionItems, onSectionToggle]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sectionItems.map((config) => {
|
||||
@@ -350,6 +374,7 @@ function NavItems({
|
||||
const canCreate = CREATABLE_CATEGORIES.includes(config.id);
|
||||
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(config.id);
|
||||
const isPlugin = config.id === 'plugins';
|
||||
const isSkill = config.id === 'skills';
|
||||
const isBot = config.id === 'bots';
|
||||
const isMCP = config.id === 'mcp';
|
||||
const isActive =
|
||||
@@ -663,6 +688,61 @@ function NavItems({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('create');
|
||||
navigate('/home/skills');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('upload');
|
||||
navigate('/home/skills');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('github');
|
||||
navigate('/home/skills');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -759,6 +839,50 @@ function NavItems({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('create');
|
||||
navigate('/home/skills');
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('upload');
|
||||
navigate('/home/skills');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('github');
|
||||
navigate('/home/skills');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface SidebarEntityItem {
|
||||
|
||||
// Install action types that can be triggered from sidebar
|
||||
export type PluginInstallAction = 'local' | 'github' | null;
|
||||
export type SkillInstallAction = 'create' | 'github' | 'upload' | null;
|
||||
|
||||
// Entity lists and refresh functions exposed via context
|
||||
export interface SidebarDataContextValue {
|
||||
@@ -38,11 +39,13 @@ export interface SidebarDataContextValue {
|
||||
knowledgeBases: SidebarEntityItem[];
|
||||
plugins: SidebarEntityItem[];
|
||||
mcpServers: SidebarEntityItem[];
|
||||
skills: SidebarEntityItem[];
|
||||
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;
|
||||
@@ -50,6 +53,9 @@ export interface SidebarDataContextValue {
|
||||
// Pending plugin install action triggered from sidebar
|
||||
pendingPluginInstallAction: PluginInstallAction;
|
||||
setPendingPluginInstallAction: (action: PluginInstallAction) => void;
|
||||
// Pending skill install action triggered from sidebar
|
||||
pendingSkillInstallAction: SkillInstallAction;
|
||||
setPendingSkillInstallAction: (action: SkillInstallAction) => void;
|
||||
}
|
||||
|
||||
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
|
||||
@@ -64,9 +70,12 @@ export function SidebarDataProvider({
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
|
||||
useState<PluginInstallAction>(null);
|
||||
const [pendingSkillInstallAction, setPendingSkillInstallAction] =
|
||||
useState<SkillInstallAction>(null);
|
||||
|
||||
const refreshBots = useCallback(async () => {
|
||||
try {
|
||||
@@ -185,6 +194,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(),
|
||||
@@ -192,6 +217,7 @@ export function SidebarDataProvider({
|
||||
refreshKnowledgeBases(),
|
||||
refreshPlugins(),
|
||||
refreshMCPServers(),
|
||||
refreshSkills(),
|
||||
]);
|
||||
}, [
|
||||
refreshBots,
|
||||
@@ -199,6 +225,7 @@ export function SidebarDataProvider({
|
||||
refreshKnowledgeBases,
|
||||
refreshPlugins,
|
||||
refreshMCPServers,
|
||||
refreshSkills,
|
||||
]);
|
||||
|
||||
// Fetch all entity lists on mount
|
||||
@@ -214,16 +241,20 @@ export function SidebarDataProvider({
|
||||
knowledgeBases,
|
||||
plugins,
|
||||
mcpServers,
|
||||
skills,
|
||||
refreshBots,
|
||||
refreshPipelines,
|
||||
refreshKnowledgeBases,
|
||||
refreshPlugins,
|
||||
refreshMCPServers,
|
||||
refreshSkills,
|
||||
refreshAll,
|
||||
detailEntityName,
|
||||
setDetailEntityName,
|
||||
pendingPluginInstallAction,
|
||||
setPendingPluginInstallAction,
|
||||
pendingSkillInstallAction,
|
||||
setPendingSkillInstallAction,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -117,7 +117,6 @@ export const sidebarConfigList = [
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
|
||||
// ── Extensions section ──
|
||||
new SidebarChildVO({
|
||||
id: 'plugins',
|
||||
@@ -184,4 +183,26 @@ export const sidebarConfigList = [
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'skills',
|
||||
name: t('skills.title'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M15 5.25L16.7249 2.75L19.2249 4.47494L17.4999 6.97494L15 5.25ZM7.5 11.25L2.0001 4L8.00006 4.00002L10.0001 7.00001L7.5 11.25ZM19.0001 13.0001L22.0001 20.0001H2.00006L9.97488 11.5248L11.4999 14L15 14.0001L19.0001 13.0001ZM4.50012 18.0001H18.0001L15.753 12.6844L12.0219 13.3488L9.69693 9.41455L4.50012 18.0001Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/home/skills',
|
||||
description: t('skills.description'),
|
||||
helpLink: {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
ja_JP: '',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -44,7 +44,12 @@ import {
|
||||
} from '@/app/home/plugins/components/plugin-install-task';
|
||||
|
||||
// Routes that belong to the "Extensions" section
|
||||
const EXTENSIONS_ROUTES = ['/home/plugins', '/home/market', '/home/mcp'];
|
||||
const EXTENSIONS_ROUTES = [
|
||||
'/home/plugins',
|
||||
'/home/market',
|
||||
'/home/mcp',
|
||||
'/home/skills',
|
||||
];
|
||||
|
||||
function isExtensionsRoute(pathname: string): boolean {
|
||||
return EXTENSIONS_ROUTES.some(
|
||||
|
||||
@@ -12,12 +12,12 @@ 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';
|
||||
|
||||
export default function PipelineExtension({
|
||||
@@ -29,16 +29,23 @@ export default function PipelineExtension({
|
||||
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 +64,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 +85,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 +105,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 +120,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 +128,8 @@ export default function PipelineExtension({
|
||||
boundMCPServerIds,
|
||||
newEnableAllPlugins ?? enableAllPlugins,
|
||||
newEnableAllMCPServers ?? enableAllMCPServers,
|
||||
boundSkillIds,
|
||||
newEnableAllSkills ?? enableAllSkills,
|
||||
);
|
||||
toast.success(t('pipelines.extensions.saveSuccess'));
|
||||
} catch (error) {
|
||||
@@ -123,13 +145,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 +170,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 +191,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 +238,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 +255,10 @@ export default function PipelineExtension({
|
||||
await saveToBackend(
|
||||
selectedPlugins,
|
||||
selectedMCPServers,
|
||||
selectedSkills,
|
||||
checked,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -211,6 +267,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 +502,83 @@ 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleOpenSkillDialog}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={enableAllSkills}
|
||||
>
|
||||
<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 +767,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ChevronDownIcon,
|
||||
UploadIcon,
|
||||
StoreIcon,
|
||||
Download,
|
||||
Power,
|
||||
Github,
|
||||
ChevronLeft,
|
||||
@@ -24,13 +23,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -41,6 +33,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
@@ -72,6 +65,8 @@ interface GithubRelease {
|
||||
published_at: string;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
source_type?: 'release' | 'tag' | 'branch';
|
||||
archive_url?: string;
|
||||
}
|
||||
|
||||
interface GithubAsset {
|
||||
@@ -108,7 +103,7 @@ function PluginListView() {
|
||||
registerOnTaskComplete,
|
||||
unregisterOnTaskComplete,
|
||||
} = usePluginInstallTasks();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [showGithubInstall, setShowGithubInstall] = useState(false);
|
||||
const [installSource, setInstallSource] = useState<string>('local');
|
||||
const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||
@@ -256,6 +251,9 @@ function PluginListView() {
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
release.id,
|
||||
release.tag_name,
|
||||
release.source_type,
|
||||
release.archive_url,
|
||||
);
|
||||
setGithubAssets(result.assets);
|
||||
|
||||
@@ -319,7 +317,7 @@ function PluginListView() {
|
||||
});
|
||||
setSelectedTaskId(taskKey);
|
||||
resetGithubState();
|
||||
setModalOpen(false);
|
||||
setShowGithubInstall(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setInstallError(err.msg);
|
||||
@@ -340,11 +338,11 @@ function PluginListView() {
|
||||
fileSize: fileSize,
|
||||
});
|
||||
setSelectedTaskId(taskKey);
|
||||
setModalOpen(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setInstallError(err.msg);
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
toast.error(t('plugins.installFailed') + (err.msg || ''));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -369,7 +367,6 @@ function PluginListView() {
|
||||
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
|
||||
setModalOpen(true);
|
||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
setInstallError(null);
|
||||
installPlugin('local', { file });
|
||||
@@ -449,7 +446,7 @@ function PluginListView() {
|
||||
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
|
||||
setInstallError(null);
|
||||
resetGithubState();
|
||||
setModalOpen(true);
|
||||
setShowGithubInstall(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pendingPluginInstallAction, statusLoading, isPluginSystemReady]);
|
||||
@@ -689,7 +686,7 @@ function PluginListView() {
|
||||
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
|
||||
setInstallError(null);
|
||||
resetGithubState();
|
||||
setModalOpen(true);
|
||||
setShowGithubInstall(true);
|
||||
}}
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
@@ -699,258 +696,252 @@ function PluginListView() {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Inline GitHub install flow */}
|
||||
{showGithubInstall && (
|
||||
<div className="px-[0.8rem] pb-4 flex-shrink-0">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Github className="size-5" />
|
||||
<span>{t('plugins.installPlugin')}</span>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowGithubInstall(false);
|
||||
resetGithubState();
|
||||
setInstallError(null);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Step 1: Enter repo URL */}
|
||||
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm">{t('plugins.enterRepoUrl')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('plugins.repoUrlPlaceholder')}
|
||||
value={githubURL}
|
||||
onChange={(e) => setGithubURL(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={fetchGithubReleases}
|
||||
disabled={!githubURL.trim() || fetchingReleases}
|
||||
>
|
||||
{fetchingReleases
|
||||
? t('plugins.loading')
|
||||
: t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Select release */}
|
||||
{pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="font-medium text-sm">
|
||||
{t('plugins.selectRelease')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
|
||||
setGithubReleases([]);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{t('plugins.backToRepoUrl')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
|
||||
{githubReleases.map((release) => (
|
||||
<Card
|
||||
key={release.id}
|
||||
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-4"
|
||||
onClick={() => handleReleaseSelect(release)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-start justify-between px-3 space-y-0">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm">
|
||||
{release.name || release.tag_name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{t('plugins.releaseTag', {
|
||||
tag: release.tag_name,
|
||||
})}{' '}
|
||||
•{' '}
|
||||
{t('plugins.publishedAt', {
|
||||
date: new Date(
|
||||
release.published_at,
|
||||
).toLocaleDateString(),
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{release.prerelease && (
|
||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded ml-2 shrink-0">
|
||||
{t('plugins.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{fetchingAssets && (
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
{t('plugins.loading')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Select asset */}
|
||||
{pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="font-medium text-sm">
|
||||
{t('plugins.selectAsset')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPluginInstallStatus(
|
||||
PluginInstallStatus.SELECT_RELEASE,
|
||||
);
|
||||
setGithubAssets([]);
|
||||
setSelectedAsset(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{t('plugins.backToReleases')}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedRelease && (
|
||||
<div className="mb-3 p-2 bg-muted rounded">
|
||||
<div className="text-sm font-medium">
|
||||
{selectedRelease.name || selectedRelease.tag_name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedRelease.tag_name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
|
||||
{githubAssets.map((asset) => (
|
||||
<Card
|
||||
key={asset.id}
|
||||
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-3"
|
||||
onClick={() => handleAssetSelect(asset)}
|
||||
>
|
||||
<CardHeader className="px-3">
|
||||
<CardTitle className="text-sm">
|
||||
{asset.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{t('plugins.assetSize', {
|
||||
size: formatFileSize(asset.size),
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Confirm install */}
|
||||
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="font-medium text-sm">
|
||||
{t('plugins.confirmInstall')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPluginInstallStatus(
|
||||
PluginInstallStatus.SELECT_ASSET,
|
||||
);
|
||||
setSelectedAsset(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{t('plugins.backToAssets')}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedRelease && selectedAsset && (
|
||||
<div className="p-3 bg-muted rounded space-y-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
Repository:{' '}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
{githubOwner}/{githubRepo}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Release: </span>
|
||||
<span className="text-sm">
|
||||
{selectedRelease.tag_name}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">File: </span>
|
||||
<span className="text-sm">{selectedAsset.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button onClick={() => handleModalConfirm()}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installing state */}
|
||||
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
|
||||
<div>
|
||||
<p className="text-sm">{t('plugins.installing')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||
<div>
|
||||
<p className="text-sm mb-1">{t('plugins.installFailed')}</p>
|
||||
<p className="text-sm text-destructive">{installError}</p>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setShowGithubInstall(false);
|
||||
resetGithubState();
|
||||
setInstallError(null);
|
||||
}}
|
||||
>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installed plugins grid */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PluginInstalledComponent ref={pluginInstalledRef} />
|
||||
</div>
|
||||
|
||||
{/* Install plugin dialog (GitHub flow) */}
|
||||
<Dialog
|
||||
open={modalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setModalOpen(open);
|
||||
if (!open) {
|
||||
resetGithubState();
|
||||
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">
|
||||
{installSource === 'github' ? (
|
||||
<Github className="size-6" />
|
||||
) : (
|
||||
<Download className="size-6" />
|
||||
)}
|
||||
<span>{t('plugins.installPlugin')}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* GitHub Install Flow */}
|
||||
{installSource === 'github' &&
|
||||
pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.enterRepoUrl')}</p>
|
||||
<Input
|
||||
placeholder={t('plugins.repoUrlPlaceholder')}
|
||||
value={githubURL}
|
||||
onChange={(e) => setGithubURL(e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
{fetchingReleases && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('plugins.fetchingReleases')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installSource === 'github' &&
|
||||
pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="font-medium">{t('plugins.selectRelease')}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
|
||||
setGithubReleases([]);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{t('plugins.backToRepoUrl')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
|
||||
{githubReleases.map((release) => (
|
||||
<Card
|
||||
key={release.id}
|
||||
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-4"
|
||||
onClick={() => handleReleaseSelect(release)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-start justify-between px-3 space-y-0">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm">
|
||||
{release.name || release.tag_name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{t('plugins.releaseTag', { tag: release.tag_name })}{' '}
|
||||
•{' '}
|
||||
{t('plugins.publishedAt', {
|
||||
date: new Date(
|
||||
release.published_at,
|
||||
).toLocaleDateString(),
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{release.prerelease && (
|
||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded ml-2 shrink-0">
|
||||
{t('plugins.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{fetchingAssets && (
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
{t('plugins.loading')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installSource === 'github' &&
|
||||
pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="font-medium">{t('plugins.selectAsset')}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPluginInstallStatus(
|
||||
PluginInstallStatus.SELECT_RELEASE,
|
||||
);
|
||||
setGithubAssets([]);
|
||||
setSelectedAsset(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{t('plugins.backToReleases')}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedRelease && (
|
||||
<div className="mb-4 p-2 bg-gray-50 dark:bg-gray-900 rounded">
|
||||
<div className="text-sm font-medium">
|
||||
{selectedRelease.name || selectedRelease.tag_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{selectedRelease.tag_name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
|
||||
{githubAssets.map((asset) => (
|
||||
<Card
|
||||
key={asset.id}
|
||||
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-3"
|
||||
onClick={() => handleAssetSelect(asset)}
|
||||
>
|
||||
<CardHeader className="px-3">
|
||||
<CardTitle className="text-sm">{asset.name}</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{t('plugins.assetSize', {
|
||||
size: formatFileSize(asset.size),
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GitHub Install Confirm */}
|
||||
{installSource === 'github' &&
|
||||
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="font-medium">{t('plugins.confirmInstall')}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET);
|
||||
setSelectedAsset(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{t('plugins.backToAssets')}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedRelease && selectedAsset && (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded space-y-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium">Repository: </span>
|
||||
<span className="text-sm">
|
||||
{githubOwner}/{githubRepo}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Release: </span>
|
||||
<span className="text-sm">
|
||||
{selectedRelease.tag_name}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">File: </span>
|
||||
<span className="text-sm">{selectedAsset.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installing State */}
|
||||
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.installing')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{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.WAIT_INPUT &&
|
||||
installSource === 'github' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setModalOpen(false);
|
||||
resetGithubState();
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={fetchGithubReleases}
|
||||
disabled={!githubURL.trim() || fetchingReleases}
|
||||
>
|
||||
{fetchingReleases
|
||||
? t('plugins.loading')
|
||||
: t('common.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{isDragOver && (
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-50 flex items-center justify-center z-50 pointer-events-none">
|
||||
<div className="bg-white rounded-lg p-8 shadow-lg border-2 border-dashed border-gray-500">
|
||||
|
||||
166
web/src/app/home/skills/SkillDetailContent.tsx
Normal file
166
web/src/app/home/skills/SkillDetailContent.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
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 {
|
||||
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';
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
setDetailEntityName(t('skills.createSkill'));
|
||||
} else {
|
||||
const skill = skills.find((item) => item.id === id);
|
||||
setDetailEntityName(skill?.name ?? id);
|
||||
}
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, setDetailEntityName, skills, 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 items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('skills.createSkill')}</h1>
|
||||
<Button type="submit" form="skill-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
initSkillName={undefined}
|
||||
onNewSkillCreated={(skillName) =>
|
||||
handleImportedSkills([skillName])
|
||||
}
|
||||
onSkillUpdated={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('skills.editSkill')}</h1>
|
||||
<Button type="submit" form="skill-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<SkillForm
|
||||
key={id}
|
||||
initSkillName={id}
|
||||
onNewSkillCreated={(skillName) =>
|
||||
handleImportedSkills([skillName])
|
||||
}
|
||||
onSkillUpdated={handleSkillUpdated}
|
||||
/>
|
||||
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('skills.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('skills.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t('common.delete')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('skills.deleteConfirmation')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
645
web/src/app/home/skills/components/SkillGithubImportPanel.tsx
Normal file
645
web/src/app/home/skills/components/SkillGithubImportPanel.tsx
Normal file
@@ -0,0 +1,645 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { ChevronLeft, Github, Upload } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import type { Skill } from '@/app/infra/entities/api';
|
||||
|
||||
interface GithubRelease {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
published_at: string;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
source_type?: 'release' | 'tag' | 'branch';
|
||||
archive_url?: string;
|
||||
}
|
||||
|
||||
interface GithubAsset {
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
download_url: string;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
interface PreviewSkill extends Skill {
|
||||
source_path?: string;
|
||||
entry_file?: string;
|
||||
}
|
||||
|
||||
interface SkillGithubImportPanelProps {
|
||||
onImported: (skillNames: string[]) => void;
|
||||
/** Which section to display. Defaults to 'all' (both GitHub and upload). */
|
||||
mode?: 'all' | 'github' | 'upload';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function previewPath(skill: PreviewSkill): string {
|
||||
return skill.source_path || '';
|
||||
}
|
||||
|
||||
export default function SkillGithubImportPanel({
|
||||
onImported,
|
||||
mode = 'all',
|
||||
}: SkillGithubImportPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [githubURL, setGithubURL] = useState('');
|
||||
const [githubOwner, setGithubOwner] = useState('');
|
||||
const [githubRepo, setGithubRepo] = useState('');
|
||||
const [githubSourceSubdir, setGithubSourceSubdir] = useState('');
|
||||
const [githubReleases, setGithubReleases] = useState<GithubRelease[]>([]);
|
||||
const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(
|
||||
null,
|
||||
);
|
||||
const [githubAssets, setGithubAssets] = useState<GithubAsset[]>([]);
|
||||
const [selectedAsset, setSelectedAsset] = useState<GithubAsset | null>(null);
|
||||
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
|
||||
const [selectedPreviewPaths, setSelectedPreviewPaths] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [activePreviewPath, setActivePreviewPath] = useState('');
|
||||
const [fetchingReleases, setFetchingReleases] = useState(false);
|
||||
const [fetchingAssets, setFetchingAssets] = useState(false);
|
||||
const [previewingGithub, setPreviewingGithub] = useState(false);
|
||||
const [installingGithub, setInstallingGithub] = useState(false);
|
||||
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [uploadPreviewSkills, setUploadPreviewSkills] = useState<
|
||||
PreviewSkill[]
|
||||
>([]);
|
||||
const [selectedUploadPreviewPaths, setSelectedUploadPreviewPaths] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [activeUploadPreviewPath, setActiveUploadPreviewPath] = useState('');
|
||||
const [previewingUpload, setPreviewingUpload] = useState(false);
|
||||
const [installingUpload, setInstallingUpload] = useState(false);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const activePreviewSkill =
|
||||
previewSkills.find((skill) => previewPath(skill) === activePreviewPath) ||
|
||||
null;
|
||||
const activeUploadPreviewSkill =
|
||||
uploadPreviewSkills.find(
|
||||
(skill) => previewPath(skill) === activeUploadPreviewPath,
|
||||
) || null;
|
||||
|
||||
function initializeSelection(
|
||||
skills: PreviewSkill[],
|
||||
setSelectedPaths: (paths: string[]) => void,
|
||||
setActivePath: (path: string) => void,
|
||||
) {
|
||||
const paths = skills.map(previewPath);
|
||||
setSelectedPaths(paths);
|
||||
setActivePath(paths[0] || '');
|
||||
}
|
||||
|
||||
function toggleSelection(
|
||||
targetPath: string,
|
||||
selectedPaths: string[],
|
||||
setSelectedPaths: (paths: string[]) => void,
|
||||
setActivePath: (path: string) => void,
|
||||
) {
|
||||
if (selectedPaths.includes(targetPath)) {
|
||||
const nextPaths = selectedPaths.filter((path) => path !== targetPath);
|
||||
setSelectedPaths(nextPaths);
|
||||
if (!nextPaths.includes(targetPath)) {
|
||||
setActivePath(nextPaths[0] || targetPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPaths([...selectedPaths, targetPath]);
|
||||
setActivePath(targetPath);
|
||||
}
|
||||
|
||||
function buildSourceArchiveAsset(release: GithubRelease): GithubAsset | null {
|
||||
if (!release.archive_url) return null;
|
||||
|
||||
return {
|
||||
id: 0,
|
||||
name: t('skills.sourceArchive'),
|
||||
size: 0,
|
||||
download_url: release.archive_url,
|
||||
content_type: 'application/zip',
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchReleases() {
|
||||
if (!githubURL.trim()) return;
|
||||
setFetchingReleases(true);
|
||||
setErrorMessage(null);
|
||||
setPreviewSkills([]);
|
||||
setSelectedPreviewPaths([]);
|
||||
setActivePreviewPath('');
|
||||
|
||||
try {
|
||||
const result = await httpClient.getGithubReleases(githubURL);
|
||||
setGithubReleases(result.releases);
|
||||
setGithubOwner(result.owner);
|
||||
setGithubRepo(result.repo);
|
||||
setGithubSourceSubdir(result.source_subdir || '');
|
||||
|
||||
if (result.releases.length === 0) {
|
||||
toast.warning(t('skills.noReleasesFound'));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message || t('skills.fetchReleasesError'));
|
||||
} finally {
|
||||
setFetchingReleases(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReleaseSelect(release: GithubRelease) {
|
||||
setSelectedRelease(release);
|
||||
setSelectedAsset(null);
|
||||
setPreviewSkills([]);
|
||||
setSelectedPreviewPaths([]);
|
||||
setActivePreviewPath('');
|
||||
setErrorMessage(null);
|
||||
setFetchingAssets(true);
|
||||
|
||||
try {
|
||||
if (release.source_type && release.source_type !== 'release') {
|
||||
const archiveAsset = buildSourceArchiveAsset(release);
|
||||
setGithubAssets(archiveAsset ? [archiveAsset] : []);
|
||||
if (!archiveAsset) {
|
||||
toast.warning(t('skills.noAssetsFound'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await httpClient.getGithubReleaseAssets(
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
release.id,
|
||||
release.tag_name,
|
||||
release.source_type,
|
||||
release.archive_url,
|
||||
);
|
||||
let assets = result.assets;
|
||||
if (assets.length === 0) {
|
||||
const archiveAsset = buildSourceArchiveAsset(release);
|
||||
if (archiveAsset) {
|
||||
assets = [archiveAsset];
|
||||
}
|
||||
}
|
||||
setGithubAssets(assets);
|
||||
if (assets.length === 0) {
|
||||
toast.warning(t('skills.noAssetsFound'));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message || t('skills.fetchAssetsError'));
|
||||
} finally {
|
||||
setFetchingAssets(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGithubPreview(asset: GithubAsset) {
|
||||
if (!selectedRelease) return;
|
||||
|
||||
setSelectedAsset(asset);
|
||||
setPreviewSkills([]);
|
||||
setSelectedPreviewPaths([]);
|
||||
setActivePreviewPath('');
|
||||
setErrorMessage(null);
|
||||
setPreviewingGithub(true);
|
||||
|
||||
try {
|
||||
const resp = await httpClient.previewSkillInstallFromGithub(
|
||||
asset.download_url,
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
selectedRelease.tag_name,
|
||||
githubSourceSubdir,
|
||||
);
|
||||
const skills = resp.skills as PreviewSkill[];
|
||||
setPreviewSkills(skills);
|
||||
initializeSelection(
|
||||
skills,
|
||||
setSelectedPreviewPaths,
|
||||
setActivePreviewPath,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message || t('skills.installError'));
|
||||
} finally {
|
||||
setPreviewingGithub(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGithubImport() {
|
||||
if (!selectedAsset || !selectedRelease || selectedPreviewPaths.length === 0)
|
||||
return;
|
||||
|
||||
setInstallingGithub(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const resp = await httpClient.installSkillFromGithub(
|
||||
selectedAsset.download_url,
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
selectedRelease.tag_name,
|
||||
selectedPreviewPaths,
|
||||
githubSourceSubdir,
|
||||
);
|
||||
toast.success(t('skills.installSuccess'));
|
||||
onImported(resp.skills.map((skill) => skill.name));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message || t('skills.installError'));
|
||||
} finally {
|
||||
setInstallingGithub(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadPreview() {
|
||||
if (!uploadFile) return;
|
||||
if (!uploadFile.name.toLowerCase().endsWith('.zip')) {
|
||||
setErrorMessage(t('skills.uploadZipOnly'));
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewingUpload(true);
|
||||
setUploadPreviewSkills([]);
|
||||
setSelectedUploadPreviewPaths([]);
|
||||
setActiveUploadPreviewPath('');
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const resp = await httpClient.previewSkillInstallFromUpload(uploadFile);
|
||||
const skills = resp.skills as PreviewSkill[];
|
||||
setUploadPreviewSkills(skills);
|
||||
initializeSelection(
|
||||
skills,
|
||||
setSelectedUploadPreviewPaths,
|
||||
setActiveUploadPreviewPath,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message || t('skills.installError'));
|
||||
} finally {
|
||||
setPreviewingUpload(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadImport() {
|
||||
if (!uploadFile || selectedUploadPreviewPaths.length === 0) return;
|
||||
|
||||
setInstallingUpload(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const resp = await httpClient.installSkillFromUpload(
|
||||
uploadFile,
|
||||
selectedUploadPreviewPaths,
|
||||
);
|
||||
toast.success(t('skills.installSuccess'));
|
||||
onImported(resp.skills.map((skill) => skill.name));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message || t('skills.installError'));
|
||||
} finally {
|
||||
setInstallingUpload(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCandidateSelector(
|
||||
skills: PreviewSkill[],
|
||||
selectedPaths: string[],
|
||||
activePath: string,
|
||||
setSelectedPaths: (paths: string[]) => void,
|
||||
setActivePath: (path: string) => void,
|
||||
) {
|
||||
if (skills.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{skills.map((skill) => {
|
||||
const path = previewPath(skill);
|
||||
const selected = selectedPaths.includes(path);
|
||||
const active = path === activePath;
|
||||
return (
|
||||
<div
|
||||
key={path || skill.name}
|
||||
className={`w-full rounded-lg border p-3 transition-colors ${
|
||||
active ? 'border-primary bg-accent/50' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={() =>
|
||||
toggleSelection(
|
||||
path,
|
||||
selectedPaths,
|
||||
setSelectedPaths,
|
||||
setActivePath,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left"
|
||||
onClick={() => setActivePath(path)}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{skill.display_name || skill.name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(skill.source_path || '.') + ' · ' + skill.name}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPreviewDetail(skill: PreviewSkill | null) {
|
||||
if (!skill) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{t('skills.displayName')}:</span>{' '}
|
||||
{skill.display_name || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{t('skills.skillSlug')}:</span>{' '}
|
||||
{skill.name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{t('skills.skillDescription')}:</span>{' '}
|
||||
{skill.description}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{t('skills.packageRoot')}:</span>{' '}
|
||||
{skill.package_root}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{t('skills.skillInstructions')}
|
||||
</div>
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs">
|
||||
{skill.instructions || ''}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{(mode === 'all' || mode === 'github') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Github className="size-5" />
|
||||
<span>{t('skills.importFromGithub')}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{githubReleases.length === 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('skills.repoUrlPlaceholder')}
|
||||
value={githubURL}
|
||||
onChange={(e) => setGithubURL(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={fetchReleases}
|
||||
disabled={!githubURL.trim() || fetchingReleases}
|
||||
>
|
||||
{fetchingReleases ? t('skills.loading') : t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{githubReleases.length > 0 && !selectedRelease && (
|
||||
<div className="space-y-2">
|
||||
{githubReleases.map((release) => (
|
||||
<button
|
||||
key={release.id}
|
||||
type="button"
|
||||
className="w-full rounded-lg border p-3 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => handleReleaseSelect(release)}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{release.name || release.tag_name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('skills.releaseTag', { tag: release.tag_name })}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRelease && previewSkills.length === 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{selectedRelease.name || selectedRelease.tag_name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('skills.releaseTag', {
|
||||
tag: selectedRelease.tag_name,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedRelease(null);
|
||||
setGithubAssets([]);
|
||||
setSelectedAsset(null);
|
||||
setPreviewSkills([]);
|
||||
setSelectedPreviewPaths([]);
|
||||
setActivePreviewPath('');
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="size-4 mr-1" />
|
||||
{t('skills.backToReleases')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fetchingAssets && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('skills.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fetchingAssets && githubAssets.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{githubAssets.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
className="w-full rounded-lg border p-3 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => handleGithubPreview(asset)}
|
||||
disabled={previewingGithub}
|
||||
>
|
||||
<div className="font-medium">{asset.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('skills.assetSize', {
|
||||
size: formatFileSize(asset.size),
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewSkills.length > 0 && selectedRelease && selectedAsset && (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">{t('skills.preview')}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPreviewSkills([]);
|
||||
setSelectedPreviewPaths([]);
|
||||
setActivePreviewPath('');
|
||||
setSelectedAsset(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="size-4 mr-1" />
|
||||
{t('skills.backToAssets')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{renderCandidateSelector(
|
||||
previewSkills,
|
||||
selectedPreviewPaths,
|
||||
activePreviewPath,
|
||||
setSelectedPreviewPaths,
|
||||
setActivePreviewPath,
|
||||
)}
|
||||
{renderPreviewDetail(activePreviewSkill)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGithubImport}
|
||||
disabled={
|
||||
installingGithub || selectedPreviewPaths.length === 0
|
||||
}
|
||||
>
|
||||
{installingGithub
|
||||
? t('skills.installing')
|
||||
: t('skills.confirmInstall')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(mode === 'all' || mode === 'upload') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="size-5" />
|
||||
<span>{t('skills.uploadZip')}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setUploadFile(file);
|
||||
setUploadPreviewSkills([]);
|
||||
setSelectedUploadPreviewPaths([]);
|
||||
setActiveUploadPreviewPath('');
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
/>
|
||||
{uploadFile && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{uploadFile.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUploadPreview}
|
||||
disabled={!uploadFile || previewingUpload}
|
||||
>
|
||||
{previewingUpload ? t('skills.loading') : t('skills.preview')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{uploadPreviewSkills.length > 0 && uploadFile && (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="font-medium">{t('skills.preview')}</div>
|
||||
|
||||
{renderCandidateSelector(
|
||||
uploadPreviewSkills,
|
||||
selectedUploadPreviewPaths,
|
||||
activeUploadPreviewPath,
|
||||
setSelectedUploadPreviewPaths,
|
||||
setActiveUploadPreviewPath,
|
||||
)}
|
||||
{renderPreviewDetail(activeUploadPreviewSkill)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUploadImport}
|
||||
disabled={
|
||||
installingUpload ||
|
||||
selectedUploadPreviewPaths.length === 0
|
||||
}
|
||||
>
|
||||
{installingUpload
|
||||
? t('skills.installing')
|
||||
: t('skills.confirmInstall')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="text-sm text-destructive">{errorMessage}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
web/src/app/home/skills/components/skill-form/SkillForm.tsx
Normal file
249
web/src/app/home/skills/components/skill-form/SkillForm.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FolderSearch, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Skill } from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface SkillFormProps {
|
||||
initSkillName?: string;
|
||||
onNewSkillCreated: (skillName: string) => void;
|
||||
onSkillUpdated: (skillName: string) => void;
|
||||
}
|
||||
|
||||
export default function SkillForm({
|
||||
initSkillName,
|
||||
onNewSkillCreated,
|
||||
onSkillUpdated,
|
||||
}: SkillFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [skill, setSkill] = useState<Partial<Skill>>({
|
||||
name: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
package_root: '',
|
||||
auto_activate: true,
|
||||
});
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initSkillName) {
|
||||
loadSkill(initSkillName);
|
||||
return;
|
||||
}
|
||||
setSkill({
|
||||
name: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
package_root: '',
|
||||
auto_activate: true,
|
||||
});
|
||||
setShowAdvanced(false);
|
||||
}, [initSkillName]);
|
||||
|
||||
async function loadSkill(skillName: string) {
|
||||
try {
|
||||
const resp = await httpClient.getSkill(skillName);
|
||||
setSkill(resp.skill);
|
||||
} catch (error) {
|
||||
console.error('Failed to load skill:', error);
|
||||
toast.error(t('skills.getSkillListError') + String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function scanDirectory() {
|
||||
const path = skill.package_root?.trim();
|
||||
if (!path) {
|
||||
toast.error(t('skills.packageRootRequired'));
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await httpClient.scanSkillDirectory(path);
|
||||
setSkill((prev) => ({
|
||||
...prev,
|
||||
name: prev.name || result.name,
|
||||
display_name: prev.display_name || result.display_name || '',
|
||||
description: prev.description || result.description,
|
||||
package_root: result.package_root,
|
||||
instructions: result.instructions,
|
||||
auto_activate: result.auto_activate ?? true,
|
||||
}));
|
||||
toast.success(t('skills.scanSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Failed to scan directory:', error);
|
||||
toast.error(t('skills.scanError') + String(error));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!skill.name?.trim()) {
|
||||
toast.error(t('skills.skillNameRequired'));
|
||||
return;
|
||||
}
|
||||
if (!skill.description?.trim()) {
|
||||
toast.error(t('skills.skillDescriptionRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const baseSkillData = {
|
||||
name: skill.name,
|
||||
display_name: skill.display_name || '',
|
||||
description: skill.description || '',
|
||||
instructions: skill.instructions || '',
|
||||
auto_activate: skill.auto_activate ?? true,
|
||||
};
|
||||
|
||||
try {
|
||||
if (initSkillName) {
|
||||
const resp = await httpClient.updateSkill(initSkillName, baseSkillData);
|
||||
toast.success(t('skills.saveSuccess'));
|
||||
onSkillUpdated(resp.skill.name);
|
||||
} else {
|
||||
const skillData: Omit<Skill, 'name'> & { name: string } = {
|
||||
...baseSkillData,
|
||||
package_root: skill.package_root || '',
|
||||
};
|
||||
const resp = await httpClient.createSkill(skillData);
|
||||
toast.success(t('skills.createSuccess'));
|
||||
onNewSkillCreated(resp.skill.name);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
(initSkillName ? t('skills.saveError') : t('skills.createError')) +
|
||||
String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form id="skill-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="display_name">{t('skills.displayName')}</Label>
|
||||
<Input
|
||||
id="display_name"
|
||||
value={skill.display_name || ''}
|
||||
onChange={(e) => setSkill({ ...skill, display_name: e.target.value })}
|
||||
placeholder={t('skills.displayNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{t('skills.skillSlug')} *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={skill.name || ''}
|
||||
onChange={(e) =>
|
||||
setSkill({
|
||||
...skill,
|
||||
name: e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''),
|
||||
})
|
||||
}
|
||||
placeholder={t('skills.skillSlugPlaceholder')}
|
||||
className="font-mono"
|
||||
disabled={Boolean(initSkillName)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('skills.skillSlugHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">{t('skills.skillDescription')} *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={skill.description || ''}
|
||||
onChange={(e) => setSkill({ ...skill, description: e.target.value })}
|
||||
placeholder={t('skills.descriptionPlaceholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instructions">{t('skills.skillInstructions')}</Label>
|
||||
<Textarea
|
||||
id="instructions"
|
||||
value={skill.instructions || ''}
|
||||
onChange={(e) => setSkill({ ...skill, instructions: e.target.value })}
|
||||
placeholder={t('skills.instructionsPlaceholder')}
|
||||
rows={16}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="auto_activate">{t('skills.autoActivate')}</Label>
|
||||
<Switch
|
||||
id="auto_activate"
|
||||
checked={skill.auto_activate ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkill({ ...skill, auto_activate: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full p-3 text-sm font-medium text-left"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
{t('skills.advancedSettings')}
|
||||
{showAdvanced ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="p-3 pt-0 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('skills.packageRoot')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={skill.package_root || ''}
|
||||
onChange={(e) =>
|
||||
setSkill({ ...skill, package_root: e.target.value })
|
||||
}
|
||||
placeholder={`data/skills/${skill.name || '<skill-name>'}/`}
|
||||
className="flex-1"
|
||||
disabled={Boolean(initSkillName)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={scanDirectory}
|
||||
disabled={
|
||||
Boolean(initSkillName) ||
|
||||
scanning ||
|
||||
!skill.package_root?.trim()
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
<FolderSearch className="h-4 w-4 mr-1" />
|
||||
{scanning ? t('common.loading') : t('skills.scan')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('skills.packageRootHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
135
web/src/app/home/skills/page.tsx
Normal file
135
web/src/app/home/skills/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } 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 SkillGithubImportPanel from '@/app/home/skills/components/SkillGithubImportPanel';
|
||||
import {
|
||||
useSidebarData,
|
||||
type SkillInstallAction,
|
||||
} from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
const {
|
||||
refreshSkills,
|
||||
pendingSkillInstallAction,
|
||||
setPendingSkillInstallAction,
|
||||
} = useSidebarData();
|
||||
|
||||
// Local active view: consumed from context on mount/change
|
||||
const [activeView, setActiveView] = useState<SkillInstallAction>(null);
|
||||
|
||||
// Consume pending action from sidebar context
|
||||
useEffect(() => {
|
||||
if (!pendingSkillInstallAction) return;
|
||||
const action = pendingSkillInstallAction;
|
||||
setPendingSkillInstallAction(null);
|
||||
setActiveView(action);
|
||||
}, [pendingSkillInstallAction, setPendingSkillInstallAction]);
|
||||
|
||||
// If a detail id is present, show detail content (edit existing / old create mode)
|
||||
if (detailId) {
|
||||
return <SkillDetailContent id={detailId} />;
|
||||
}
|
||||
|
||||
// Handle callback after skills are imported/created
|
||||
function handleImportedSkills(skillNames: string[]) {
|
||||
void refreshSkills();
|
||||
setActiveView(null);
|
||||
const primarySkill = skillNames[0];
|
||||
if (primarySkill) {
|
||||
navigate(`/home/skills?id=${encodeURIComponent(primarySkill)}`);
|
||||
return;
|
||||
}
|
||||
navigate('/home/skills');
|
||||
}
|
||||
|
||||
// Inline create manually view
|
||||
if (activeView === 'create') {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('skills.createSkill')}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setActiveView(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form="skill-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
initSkillName={undefined}
|
||||
onNewSkillCreated={(skillName) =>
|
||||
handleImportedSkills([skillName])
|
||||
}
|
||||
onSkillUpdated={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline GitHub import view
|
||||
if (activeView === 'github') {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('skills.importFromGithub')}
|
||||
</h1>
|
||||
<Button variant="outline" onClick={() => setActiveView(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<SkillGithubImportPanel
|
||||
mode="github"
|
||||
onImported={handleImportedSkills}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline upload ZIP view
|
||||
if (activeView === 'upload') {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('skills.uploadZip')}</h1>
|
||||
<Button variant="outline" onClick={() => setActiveView(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<SkillGithubImportPanel
|
||||
mode="upload"
|
||||
onImported={handleImportedSkills}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: no selection
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('skills.selectFromSidebar')}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -545,3 +545,24 @@ export interface ApiRespTools {
|
||||
export interface ApiRespToolDetail {
|
||||
tool: PluginTool;
|
||||
}
|
||||
|
||||
// Skills
|
||||
export interface Skill {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
description: string;
|
||||
instructions?: string;
|
||||
package_root?: string;
|
||||
auto_activate?: boolean;
|
||||
is_builtin?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ApiRespSkills {
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
export interface ApiRespSkill {
|
||||
skill: Skill;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ import {
|
||||
RagMigrationStatusResp,
|
||||
ApiRespTools,
|
||||
ApiRespToolDetail,
|
||||
Skill,
|
||||
ApiRespSkills,
|
||||
ApiRespSkill,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
||||
@@ -260,10 +263,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 +280,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -629,9 +639,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 });
|
||||
}
|
||||
@@ -640,6 +653,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;
|
||||
@@ -653,6 +669,9 @@ export class BackendClient extends BaseHttpClient {
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
release_tag: releaseTag,
|
||||
source_type: sourceType,
|
||||
archive_url: archiveUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -662,6 +681,61 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.postFile('/api/v1/plugins/install/local', 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,
|
||||
@@ -1109,6 +1183,53 @@ 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;
|
||||
auto_activate?: boolean;
|
||||
}> {
|
||||
return this.get('/api/v1/skills/scan', { path });
|
||||
}
|
||||
}
|
||||
|
||||
export interface SurveyQuestion {
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -802,8 +802,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',
|
||||
@@ -1241,6 +1248,80 @@ const enUS = {
|
||||
maxExtensionsReached:
|
||||
'Maximum number of extensions ({{max}}) reached. Please remove an existing MCP server or plugin before adding a new one.',
|
||||
},
|
||||
skills: {
|
||||
title: 'Skills',
|
||||
description:
|
||||
'Create and manage skills that can be activated during conversations',
|
||||
createSkill: 'Create Skill',
|
||||
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',
|
||||
autoActivate: 'Auto Activate',
|
||||
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?',
|
||||
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: 'Preview Instructions',
|
||||
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.',
|
||||
advancedSettings: 'Advanced Settings',
|
||||
searchSkills: 'Search skills...',
|
||||
selectSkills: 'Select Skills',
|
||||
builtin: 'Built-in',
|
||||
importFromGithub: 'Import 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',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'Create a bot with guided steps',
|
||||
loading: 'Loading wizard...',
|
||||
@@ -1305,6 +1386,16 @@ const enUS = {
|
||||
backToWorkbench: 'Back to Workbench',
|
||||
},
|
||||
},
|
||||
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.',
|
||||
goBack: 'Go Back',
|
||||
backToHome: 'Back to Home',
|
||||
},
|
||||
};
|
||||
|
||||
export default enUS;
|
||||
|
||||
@@ -1342,6 +1342,15 @@ 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.',
|
||||
goBack: 'Volver',
|
||||
backToHome: 'Ir al inicio',
|
||||
},
|
||||
};
|
||||
|
||||
export default esES;
|
||||
|
||||
@@ -1312,6 +1312,16 @@ const jaJP = {
|
||||
backToWorkbench: 'ワークベンチに戻る',
|
||||
},
|
||||
},
|
||||
errorPage: {
|
||||
unexpectedError: 'エラーが発生しました',
|
||||
unexpectedErrorDescription:
|
||||
'予期しないエラーが発生しました。しばらくしてからもう一度お試しください。',
|
||||
notFound: 'ページが見つかりません',
|
||||
notFoundDescription:
|
||||
'お探しのページは存在しないか、移動された可能性があります。',
|
||||
goBack: '戻る',
|
||||
backToHome: 'ホームに戻る',
|
||||
},
|
||||
};
|
||||
|
||||
export default jaJP;
|
||||
|
||||
@@ -1284,6 +1284,15 @@ const thTH = {
|
||||
backToWorkbench: 'กลับไปหน้าทำงาน',
|
||||
},
|
||||
},
|
||||
errorPage: {
|
||||
unexpectedError: 'เกิดข้อผิดพลาด',
|
||||
unexpectedErrorDescription:
|
||||
'เกิดข้อผิดพลาดที่ไม่คาดคิด กรุณาลองใหม่อีกครั้งในภายหลัง',
|
||||
notFound: 'ไม่พบหน้า',
|
||||
notFoundDescription: 'หน้าที่คุณกำลังมองหาไม่มีอยู่หรือถูกย้ายแล้ว',
|
||||
goBack: 'ย้อนกลับ',
|
||||
backToHome: 'กลับหน้าหลัก',
|
||||
},
|
||||
};
|
||||
|
||||
export default thTH;
|
||||
|
||||
@@ -1305,6 +1305,16 @@ 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.',
|
||||
goBack: 'Quay lại',
|
||||
backToHome: 'Về trang chủ',
|
||||
},
|
||||
};
|
||||
|
||||
export default viVN;
|
||||
|
||||
@@ -768,8 +768,15 @@ const zhHans = {
|
||||
selectAll: '全选',
|
||||
enableAllPlugins: '启用所有插件',
|
||||
enableAllMCPServers: '启用所有 MCP 服务器',
|
||||
enableAllSkills: '启用所有技能',
|
||||
allPluginsEnabled: '已启用所有插件',
|
||||
allMCPServersEnabled: '已启用所有 MCP 服务器',
|
||||
allSkillsEnabled: '已启用所有技能',
|
||||
skillsTitle: '技能',
|
||||
noSkillsSelected: '未选择任何技能',
|
||||
addSkill: '添加技能',
|
||||
selectSkills: '选择技能',
|
||||
noSkillsAvailable: '暂无可用技能',
|
||||
},
|
||||
debugDialog: {
|
||||
title: '流水线对话',
|
||||
@@ -1187,6 +1194,77 @@ const zhHans = {
|
||||
maxExtensionsReached:
|
||||
'已达到扩展数量上限({{max}}个)。请先删除已有的 MCP 服务器或插件后再添加新的。',
|
||||
},
|
||||
skills: {
|
||||
title: '技能',
|
||||
description: '创建和管理可在对话中激活的技能',
|
||||
createSkill: '创建技能',
|
||||
editSkill: '编辑技能',
|
||||
getSkillListError: '获取技能列表失败:',
|
||||
skillName: '技能名称',
|
||||
displayName: '技能名称',
|
||||
displayNamePlaceholder: '显示名称(支持中文)',
|
||||
skillSlug: '目录名称',
|
||||
skillSlugPlaceholder: 'english-name-only',
|
||||
skillSlugHelp: '用作技能目录名,仅支持英文字母、数字、连字符和下划线。',
|
||||
skillDescription: '技能描述',
|
||||
skillInstructions: '指令内容',
|
||||
autoActivate: '自动激活',
|
||||
saveSuccess: '保存成功',
|
||||
saveError: '保存失败:',
|
||||
createSuccess: '创建成功',
|
||||
createError: '创建失败:',
|
||||
deleteSuccess: '删除成功',
|
||||
deleteError: '删除失败:',
|
||||
deleteConfirmation: '你确定要删除这个技能吗?',
|
||||
skillNameRequired: '技能名称不能为空',
|
||||
skillDescriptionRequired: '技能描述不能为空',
|
||||
packageRootRequired: '技能目录不能为空',
|
||||
scan: '扫描',
|
||||
scanSuccess: '目录扫描成功',
|
||||
scanError: '扫描目录失败:',
|
||||
noSkills: '暂未配置任何技能',
|
||||
preview: '预览',
|
||||
previewInstructions: '预览指令',
|
||||
instructionsPlaceholder: '使用 Markdown 格式输入技能指令...',
|
||||
descriptionPlaceholder: '简短描述此技能的功能(会展示给 LLM)',
|
||||
packageRoot: '技能目录',
|
||||
packageRootHelp:
|
||||
'非必填。仅在导入已有技能目录时需要填写,新建技能可留空。扫描会检查当前目录及两级子目录。',
|
||||
advancedSettings: '高级设置',
|
||||
searchSkills: '搜索技能...',
|
||||
selectSkills: '选择技能',
|
||||
builtin: '内置',
|
||||
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: '不可逆的操作',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: '通过引导步骤创建机器人',
|
||||
loading: '正在加载向导...',
|
||||
@@ -1247,6 +1325,14 @@ const zhHans = {
|
||||
backToWorkbench: '返回工作台',
|
||||
},
|
||||
},
|
||||
errorPage: {
|
||||
unexpectedError: '出错了',
|
||||
unexpectedErrorDescription: '发生了意外错误,请稍后重试。',
|
||||
notFound: '页面未找到',
|
||||
notFoundDescription: '你访问的页面不存在或已被移动。',
|
||||
goBack: '返回上页',
|
||||
backToHome: '返回首页',
|
||||
},
|
||||
};
|
||||
|
||||
export default zhHans;
|
||||
|
||||
@@ -1247,6 +1247,14 @@ const zhHant = {
|
||||
backToWorkbench: '返回工作台',
|
||||
},
|
||||
},
|
||||
errorPage: {
|
||||
unexpectedError: '出錯了',
|
||||
unexpectedErrorDescription: '發生了意外錯誤,請稍後重試。',
|
||||
notFound: '頁面未找到',
|
||||
notFoundDescription: '你訪問的頁面不存在或已被移動。',
|
||||
goBack: '返回上頁',
|
||||
backToHome: '返回首頁',
|
||||
},
|
||||
};
|
||||
|
||||
export default zhHant;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom';
|
||||
|
||||
// Layouts
|
||||
import LoginLayout from '@/app/login/layout';
|
||||
@@ -21,124 +21,141 @@ import PluginsPage from '@/app/home/plugins/page';
|
||||
import MarketPage from '@/app/home/market/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';
|
||||
|
||||
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>
|
||||
),
|
||||
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: '/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/skills',
|
||||
element: (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<HomeLayout>
|
||||
<SkillsPage />
|
||||
</HomeLayout>
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user