feat(skills): support batch skill bootstrap via content_ids; clear parent dir once before per-skill write
This commit is contained in:
parent
2b0581db71
commit
45b95c4538
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue