diff --git a/backend/app/gateway/routers/skills.py b/backend/app/gateway/routers/skills.py index 09e9a5a5..9a504b95 100644 --- a/backend/app/gateway/routers/skills.py +++ b/backend/app/gateway/routers/skills.py @@ -1,5 +1,6 @@ import json import logging +import shutil from pathlib import Path import httpx @@ -59,11 +60,15 @@ class RemoteSkillBootstrapRequest(BaseModel): """Request model for bootstrapping skill files from remote content API.""" thread_id: str = Field(..., description="Thread ID used for user-data path binding") - content_id: int = Field(..., description="Remote content ID (maps from frontend query param skill_id)") + content_ids: list[int] = Field( + ..., + min_length=1, + description="Remote content ID sequence (maps from frontend query param skill_id)", + ) language_type: int = Field(default=0, description="Language type for remote API request body") target_dir: str = Field( default="/mnt/user-data/uploads/skill", - description="Virtual target directory where parsed files/directories are created", + description="Virtual base directory where each skill-{id} subdirectory is created", ) clear_target: bool = Field( default=True, @@ -75,7 +80,8 @@ class RemoteSkillBootstrapResponse(BaseModel): """Response model for remote bootstrap endpoint.""" success: bool = Field(..., description="Whether bootstrap succeeded") - target_dir: str = Field(..., description="Virtual target directory") + target_dir: str = Field(..., description="Virtual base target directory") + content_ids: list[int] = Field(..., description="Bootstrapped content IDs") created_directories: int = Field(..., description="Number of created directories") created_files: int = Field(..., description="Number of created files") sandbox_id: str | None = Field(default=None, description="Acquired sandbox ID (null when sandbox is not acquired)") @@ -208,8 +214,9 @@ async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse: response_model=RemoteSkillBootstrapResponse, summary="Bootstrap Skill Files From Remote API", description=( - "Fetch YAML text from configured remote API by content_id/language_type and " - "materialize files into /mnt/user-data/uploads/skill before first thread submit." + "Fetch YAML text from configured remote API by content_ids/language_type and " + "materialize files into skill-{id} directories under /mnt/user-data/uploads/skill " + "before first thread submit." ), ) async def bootstrap_skill_from_remote(request: RemoteSkillBootstrapRequest) -> RemoteSkillBootstrapResponse: @@ -217,59 +224,84 @@ async def bootstrap_skill_from_remote(request: RemoteSkillBootstrapRequest) -> R try: cfg = get_gateway_config() api_url = cfg.skill_content_api_url - payload = { - "contentId": request.content_id, - "languageType": request.language_type, - } + created_directories_total = 0 + created_files_total = 0 + + base_target_path = resolve_thread_virtual_path(request.thread_id, request.target_dir) + if request.clear_target and base_target_path.exists(): + if base_target_path.is_dir(): + shutil.rmtree(base_target_path) + else: + base_target_path.unlink() + base_target_path.mkdir(parents=True, exist_ok=True) async with httpx.AsyncClient(timeout=20.0) as client: - response = await client.post(api_url, json=payload) + for content_id in request.content_ids: + payload = { + "contentId": content_id, + "languageType": request.language_type, + } - if response.status_code >= 400: - raise HTTPException( - status_code=502, - detail=f"Remote skill content API failed with HTTP {response.status_code}", - ) + response = await client.post(api_url, json=payload) + if response.status_code >= 400: + raise HTTPException( + status_code=502, + detail=( + "Remote skill content API failed with HTTP " + f"{response.status_code} for content_id={content_id}" + ), + ) - try: - response_json = response.json() - except ValueError as e: - raise HTTPException(status_code=502, detail=f"Remote API did not return valid JSON: {e}") from e + try: + response_json = response.json() + except ValueError as e: + raise HTTPException(status_code=502, detail=f"Remote API did not return valid JSON: {e}") from e - status = response_json.get("status") - if status != 1000: - raise HTTPException( - status_code=502, - detail=f"Remote API returned non-success status: {status}, message: {response_json.get('message')}", - ) + status = response_json.get("status") + if status != 1000: + raise HTTPException( + status_code=502, + detail=( + "Remote API returned non-success status: " + f"{status}, message: {response_json.get('message')}, content_id={content_id}" + ), + ) - yaml_text = response_json.get("data") - if not isinstance(yaml_text, str) or not yaml_text.strip(): - raise HTTPException(status_code=502, detail="Remote API returned empty or invalid YAML content") + yaml_text = response_json.get("data") + if not isinstance(yaml_text, str) or not yaml_text.strip(): + raise HTTPException( + status_code=502, + detail=f"Remote API returned empty or invalid YAML content for content_id={content_id}", + ) - target_path = resolve_thread_virtual_path(request.thread_id, request.target_dir) - parsed = parse_skill_yaml_spec(yaml_text) - materialize_skill_tree(parsed, target_path, clear_target=request.clear_target) + target_dir = f"{request.target_dir.rstrip('/')}/skill-{content_id}" + target_path = resolve_thread_virtual_path(request.thread_id, target_dir) + parsed = parse_skill_yaml_spec(yaml_text) + materialize_skill_tree(parsed, target_path, clear_target=False) - logger.info( - "Bootstrapped remote skill YAML for thread %s (content_id=%s, language_type=%s) to %s: dirs=%d files=%d", - request.thread_id, - request.content_id, - request.language_type, - request.target_dir, - len(parsed.directories), - len(parsed.files), - ) + created_directories_total += len(parsed.directories) + created_files_total += len(parsed.files) + + logger.info( + "Bootstrapped remote skill YAML for thread %s (content_id=%s, language_type=%s) to %s: dirs=%d files=%d", + request.thread_id, + content_id, + request.language_type, + target_dir, + len(parsed.directories), + len(parsed.files), + ) return RemoteSkillBootstrapResponse( success=True, target_dir=request.target_dir, - created_directories=len(parsed.directories), - created_files=len(parsed.files), + content_ids=request.content_ids, + created_directories=created_directories_total, + created_files=created_files_total, sandbox_id=None, message=( - f"Bootstrapped {len(parsed.files)} files and {len(parsed.directories)} directories " - f"under '{request.target_dir}'" + f"Bootstrapped {created_files_total} files and {created_directories_total} directories " + f"for {len(request.content_ids)} skills under '{request.target_dir}'" ), ) except HTTPException: diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index c9f1b3f6..89ea9467 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -57,15 +57,23 @@ export default function ChatPage() { }, []); const { showNotification } = useNotification(); - const skillBootstrappedKeyRef = useRef(null); - const isBootstrappingRef = useRef(false); + const skillBootstrappedKeysRef = useRef>(new Set()); + const skillBootstrappingKeysRef = useRef>(new Set()); const skillBootstrap = useMemo(() => { const skillIdRaw = searchParams.get("skill_id")?.trim(); if (!skillIdRaw) return undefined; - const contentId = Number(skillIdRaw); - if (!Number.isFinite(contentId)) return undefined; + const contentIds = skillIdRaw + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)); + + // Deduplicate while preserving incoming order. + const uniqueContentIds = Array.from(new Set(contentIds)); + if (uniqueContentIds.length === 0) return undefined; const languageTypeRaw = searchParams.get("languageType")?.trim() ?? @@ -73,7 +81,7 @@ export default function ChatPage() { const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0; return { - contentId, + contentIds: uniqueContentIds, languageType: Number.isFinite(languageType) ? languageType : 0, }; }, [searchParams]); @@ -106,49 +114,42 @@ export default function ChatPage() { }); useEffect(() => { - if (!threadId || !skillBootstrap?.contentId) { - isBootstrappingRef.current = false; + if (!threadId || !skillBootstrap?.contentIds?.length) { return; } const languageType = skillBootstrap.languageType ?? 0; - const initKey = `${threadId}:${skillBootstrap.contentId}:${languageType}`; - if (skillBootstrappedKeyRef.current === initKey || isBootstrappingRef.current) { + const initKey = `${threadId}:${skillBootstrap.contentIds.join(",")}:${languageType}`; + if ( + skillBootstrappedKeysRef.current.has(initKey) || + skillBootstrappingKeysRef.current.has(initKey) + ) { return; } - let cancelled = false; + skillBootstrappingKeysRef.current.add(initKey); const runBootstrap = async () => { - isBootstrappingRef.current = true; try { await bootstrapRemoteSkill({ thread_id: threadId, - content_id: skillBootstrap.contentId, + content_ids: skillBootstrap.contentIds, language_type: languageType, target_dir: "/mnt/user-data/uploads/skill", clear_target: true, }); - if (!cancelled) { - skillBootstrappedKeyRef.current = initKey; - } + skillBootstrappedKeysRef.current.add(initKey); } catch (error) { - if (!cancelled) { - const message = - error instanceof Error ? error.message : "Skill initialization failed"; - showNotification("Skill initialization failed", { body: message }); - } + const message = + error instanceof Error ? error.message : "Skill initialization failed"; + showNotification("Skill initialization failed", { body: message }); } finally { - isBootstrappingRef.current = false; + skillBootstrappingKeysRef.current.delete(initKey); } }; void runBootstrap(); - - return () => { - cancelled = true; - }; }, [threadId, skillBootstrap, showNotification]); const handleSubmit = useCallback( diff --git a/frontend/src/core/skills/api.ts b/frontend/src/core/skills/api.ts index 40888ec9..1204310c 100644 --- a/frontend/src/core/skills/api.ts +++ b/frontend/src/core/skills/api.ts @@ -37,7 +37,7 @@ export interface InstallSkillResponse { export interface BootstrapRemoteSkillRequest { thread_id: string; - content_id: number; + content_ids: number[]; language_type?: number; target_dir?: string; clear_target?: boolean; @@ -46,6 +46,7 @@ export interface BootstrapRemoteSkillRequest { export interface BootstrapRemoteSkillResponse { success: boolean; target_dir: string; + content_ids: number[]; created_directories: number; created_files: number; sandbox_id: string | null;