mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-09 07:16:04 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user