feat(skills): support batch skill bootstrap via content_ids; clear parent dir once before per-skill write

This commit is contained in:
Titan 2026-04-03 23:02:12 +08:00
parent 2b0581db71
commit 45b95c4538
3 changed files with 104 additions and 70 deletions

View File

@ -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:

View File

@ -57,15 +57,23 @@ export default function ChatPage() {
}, []);
const { showNotification } = useNotification();
const skillBootstrappedKeyRef = useRef<string | null>(null);
const isBootstrappingRef = useRef(false);
const skillBootstrappedKeysRef = useRef<Set<string>>(new Set());
const skillBootstrappingKeysRef = useRef<Set<string>>(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(

View File

@ -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;