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 5de7a2ab46
commit b412b5193b
3 changed files with 104 additions and 70 deletions

View File

@ -1,5 +1,6 @@
import json import json
import logging import logging
import shutil
from pathlib import Path from pathlib import Path
import httpx import httpx
@ -59,11 +60,15 @@ class RemoteSkillBootstrapRequest(BaseModel):
"""Request model for bootstrapping skill files from remote content API.""" """Request model for bootstrapping skill files from remote content API."""
thread_id: str = Field(..., description="Thread ID used for user-data path binding") 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") language_type: int = Field(default=0, description="Language type for remote API request body")
target_dir: str = Field( target_dir: str = Field(
default="/mnt/user-data/uploads/skill", 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( clear_target: bool = Field(
default=True, default=True,
@ -75,7 +80,8 @@ class RemoteSkillBootstrapResponse(BaseModel):
"""Response model for remote bootstrap endpoint.""" """Response model for remote bootstrap endpoint."""
success: bool = Field(..., description="Whether bootstrap succeeded") 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_directories: int = Field(..., description="Number of created directories")
created_files: int = Field(..., description="Number of created files") 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)") 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, response_model=RemoteSkillBootstrapResponse,
summary="Bootstrap Skill Files From Remote API", summary="Bootstrap Skill Files From Remote API",
description=( description=(
"Fetch YAML text from configured remote API by content_id/language_type and " "Fetch YAML text from configured remote API by content_ids/language_type and "
"materialize files into /mnt/user-data/uploads/skill before first thread submit." "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: async def bootstrap_skill_from_remote(request: RemoteSkillBootstrapRequest) -> RemoteSkillBootstrapResponse:
@ -217,59 +224,84 @@ async def bootstrap_skill_from_remote(request: RemoteSkillBootstrapRequest) -> R
try: try:
cfg = get_gateway_config() cfg = get_gateway_config()
api_url = cfg.skill_content_api_url api_url = cfg.skill_content_api_url
payload = { created_directories_total = 0
"contentId": request.content_id, created_files_total = 0
"languageType": request.language_type,
} 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: 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: response = await client.post(api_url, json=payload)
raise HTTPException( if response.status_code >= 400:
status_code=502, raise HTTPException(
detail=f"Remote skill content API failed with HTTP {response.status_code}", status_code=502,
) detail=(
"Remote skill content API failed with HTTP "
f"{response.status_code} for content_id={content_id}"
),
)
try: try:
response_json = response.json() response_json = response.json()
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=502, detail=f"Remote API did not return valid JSON: {e}") from e raise HTTPException(status_code=502, detail=f"Remote API did not return valid JSON: {e}") from e
status = response_json.get("status") status = response_json.get("status")
if status != 1000: if status != 1000:
raise HTTPException( raise HTTPException(
status_code=502, status_code=502,
detail=f"Remote API returned non-success status: {status}, message: {response_json.get('message')}", detail=(
) "Remote API returned non-success status: "
f"{status}, message: {response_json.get('message')}, content_id={content_id}"
),
)
yaml_text = response_json.get("data") yaml_text = response_json.get("data")
if not isinstance(yaml_text, str) or not yaml_text.strip(): 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") 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) target_dir = f"{request.target_dir.rstrip('/')}/skill-{content_id}"
parsed = parse_skill_yaml_spec(yaml_text) target_path = resolve_thread_virtual_path(request.thread_id, target_dir)
materialize_skill_tree(parsed, target_path, clear_target=request.clear_target) parsed = parse_skill_yaml_spec(yaml_text)
materialize_skill_tree(parsed, target_path, clear_target=False)
logger.info( created_directories_total += len(parsed.directories)
"Bootstrapped remote skill YAML for thread %s (content_id=%s, language_type=%s) to %s: dirs=%d files=%d", created_files_total += len(parsed.files)
request.thread_id,
request.content_id, logger.info(
request.language_type, "Bootstrapped remote skill YAML for thread %s (content_id=%s, language_type=%s) to %s: dirs=%d files=%d",
request.target_dir, request.thread_id,
len(parsed.directories), content_id,
len(parsed.files), request.language_type,
) target_dir,
len(parsed.directories),
len(parsed.files),
)
return RemoteSkillBootstrapResponse( return RemoteSkillBootstrapResponse(
success=True, success=True,
target_dir=request.target_dir, target_dir=request.target_dir,
created_directories=len(parsed.directories), content_ids=request.content_ids,
created_files=len(parsed.files), created_directories=created_directories_total,
created_files=created_files_total,
sandbox_id=None, sandbox_id=None,
message=( message=(
f"Bootstrapped {len(parsed.files)} files and {len(parsed.directories)} directories " f"Bootstrapped {created_files_total} files and {created_directories_total} directories "
f"under '{request.target_dir}'" f"for {len(request.content_ids)} skills under '{request.target_dir}'"
), ),
) )
except HTTPException: except HTTPException:

View File

@ -57,15 +57,23 @@ export default function ChatPage() {
}, []); }, []);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const skillBootstrappedKeyRef = useRef<string | null>(null); const skillBootstrappedKeysRef = useRef<Set<string>>(new Set());
const isBootstrappingRef = useRef(false); const skillBootstrappingKeysRef = useRef<Set<string>>(new Set());
const skillBootstrap = useMemo(() => { const skillBootstrap = useMemo(() => {
const skillIdRaw = searchParams.get("skill_id")?.trim(); const skillIdRaw = searchParams.get("skill_id")?.trim();
if (!skillIdRaw) return undefined; if (!skillIdRaw) return undefined;
const contentId = Number(skillIdRaw); const contentIds = skillIdRaw
if (!Number.isFinite(contentId)) return undefined; .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 = const languageTypeRaw =
searchParams.get("languageType")?.trim() ?? searchParams.get("languageType")?.trim() ??
@ -73,7 +81,7 @@ export default function ChatPage() {
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0; const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
return { return {
contentId, contentIds: uniqueContentIds,
languageType: Number.isFinite(languageType) ? languageType : 0, languageType: Number.isFinite(languageType) ? languageType : 0,
}; };
}, [searchParams]); }, [searchParams]);
@ -106,49 +114,42 @@ export default function ChatPage() {
}); });
useEffect(() => { useEffect(() => {
if (!threadId || !skillBootstrap?.contentId) { if (!threadId || !skillBootstrap?.contentIds?.length) {
isBootstrappingRef.current = false;
return; return;
} }
const languageType = skillBootstrap.languageType ?? 0; const languageType = skillBootstrap.languageType ?? 0;
const initKey = `${threadId}:${skillBootstrap.contentId}:${languageType}`; const initKey = `${threadId}:${skillBootstrap.contentIds.join(",")}:${languageType}`;
if (skillBootstrappedKeyRef.current === initKey || isBootstrappingRef.current) { if (
skillBootstrappedKeysRef.current.has(initKey) ||
skillBootstrappingKeysRef.current.has(initKey)
) {
return; return;
} }
let cancelled = false; skillBootstrappingKeysRef.current.add(initKey);
const runBootstrap = async () => { const runBootstrap = async () => {
isBootstrappingRef.current = true;
try { try {
await bootstrapRemoteSkill({ await bootstrapRemoteSkill({
thread_id: threadId, thread_id: threadId,
content_id: skillBootstrap.contentId, content_ids: skillBootstrap.contentIds,
language_type: languageType, language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill", target_dir: "/mnt/user-data/uploads/skill",
clear_target: true, clear_target: true,
}); });
if (!cancelled) { skillBootstrappedKeysRef.current.add(initKey);
skillBootstrappedKeyRef.current = initKey;
}
} catch (error) { } catch (error) {
if (!cancelled) { const message =
const message = error instanceof Error ? error.message : "Skill initialization failed";
error instanceof Error ? error.message : "Skill initialization failed"; showNotification("Skill initialization failed", { body: message });
showNotification("Skill initialization failed", { body: message });
}
} finally { } finally {
isBootstrappingRef.current = false; skillBootstrappingKeysRef.current.delete(initKey);
} }
}; };
void runBootstrap(); void runBootstrap();
return () => {
cancelled = true;
};
}, [threadId, skillBootstrap, showNotification]); }, [threadId, skillBootstrap, showNotification]);
const handleSubmit = useCallback( const handleSubmit = useCallback(

View File

@ -37,7 +37,7 @@ export interface InstallSkillResponse {
export interface BootstrapRemoteSkillRequest { export interface BootstrapRemoteSkillRequest {
thread_id: string; thread_id: string;
content_id: number; content_ids: number[];
language_type?: number; language_type?: number;
target_dir?: string; target_dir?: string;
clear_target?: boolean; clear_target?: boolean;
@ -46,6 +46,7 @@ export interface BootstrapRemoteSkillRequest {
export interface BootstrapRemoteSkillResponse { export interface BootstrapRemoteSkillResponse {
success: boolean; success: boolean;
target_dir: string; target_dir: string;
content_ids: number[];
created_directories: number; created_directories: number;
created_files: number; created_files: number;
sandbox_id: string | null; sandbox_id: string | null;