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