393 lines
15 KiB
Python
393 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""NianCode's fixed, keyless adapter for the Meowa game-assets Skill.
|
||
|
||
The Meowa credential stays in Electron Main. This CLI talks only to the local
|
||
NianCode Host API and writes generated output plus provenance metadata into the
|
||
active project.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import base64
|
||
from datetime import datetime, timezone
|
||
import json
|
||
import mimetypes
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import sys
|
||
import time
|
||
from typing import Any
|
||
from urllib.error import HTTPError, URLError
|
||
from urllib.parse import urlencode
|
||
from urllib.request import Request, urlopen
|
||
|
||
|
||
SKILL_VERSION = "2026.06.19.1"
|
||
DEFAULT_HOST_BASE = "http://127.0.0.1:13210"
|
||
DEFAULT_OUTPUT_DIR = "assets/generated/meowa"
|
||
TERMINAL_STATUSES = {"success", "completed", "failure", "failed", "cancelled", "canceled"}
|
||
|
||
|
||
class AdapterError(RuntimeError):
|
||
pass
|
||
|
||
|
||
def host_base() -> str:
|
||
value = os.environ.get("NIANCODE_HOST_API_BASE_URL", DEFAULT_HOST_BASE).strip()
|
||
return value.rstrip("/")
|
||
|
||
|
||
def host_token() -> str:
|
||
token = os.environ.get("NIANCODE_HOST_API_TOKEN", "").strip()
|
||
if not token:
|
||
raise AdapterError("NianCode Host API token is unavailable; run this command from a NianCode Agent")
|
||
return token
|
||
|
||
|
||
def parse_payload(raw: bytes) -> Any:
|
||
if not raw:
|
||
return None
|
||
try:
|
||
return json.loads(raw.decode("utf-8"))
|
||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||
return raw.decode("utf-8", errors="replace")[:500]
|
||
|
||
|
||
def payload_message(payload: Any, fallback: str) -> str:
|
||
if isinstance(payload, dict):
|
||
for key in ("error", "message", "detail", "msg"):
|
||
value = payload.get(key)
|
||
if isinstance(value, str) and value.strip():
|
||
return value.strip()[:500]
|
||
if isinstance(payload, str) and payload.strip():
|
||
return payload.strip()[:500]
|
||
return fallback
|
||
|
||
|
||
def request_json(path: str, method: str = "GET", payload: dict[str, Any] | None = None, timeout: int = 240) -> Any:
|
||
url = path if path.startswith("http://") or path.startswith("https://") else f"{host_base()}{path}"
|
||
body = None
|
||
headers = {
|
||
"Accept": "application/json",
|
||
"Authorization": f"Bearer {host_token()}",
|
||
}
|
||
if payload is not None:
|
||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
headers["Content-Type"] = "application/json"
|
||
request = Request(url, data=body, headers=headers, method=method)
|
||
try:
|
||
with urlopen(request, timeout=timeout) as response:
|
||
return parse_payload(response.read())
|
||
except HTTPError as error:
|
||
payload_value = parse_payload(error.read())
|
||
raise AdapterError(payload_message(payload_value, f"Host API returned HTTP {error.code}")) from error
|
||
except URLError as error:
|
||
raise AdapterError(f"无法连接 NianCode Host API:{error.reason}") from error
|
||
|
||
|
||
def json_dump(path: Path, value: Any) -> None:
|
||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
|
||
|
||
def print_json(value: Any) -> None:
|
||
print(json.dumps(value, ensure_ascii=False, indent=2))
|
||
|
||
|
||
def cli_path() -> Path:
|
||
return Path(__file__).resolve()
|
||
|
||
|
||
def command_skill_doc(args: argparse.Namespace) -> int:
|
||
query = urlencode({key: value for key, value in {"task": args.task, "topic": args.topic}.items() if value})
|
||
path = "/api/meowa/game-assets/skill-doc"
|
||
if query:
|
||
path = f"{path}?{query}"
|
||
try:
|
||
payload = request_json(path)
|
||
content = payload.get("content") if isinstance(payload, dict) else None
|
||
if isinstance(content, str) and content.strip():
|
||
print(content)
|
||
return 0
|
||
raise AdapterError("Skill 文档响应没有 content")
|
||
except AdapterError as error:
|
||
fallback = cli_path().with_name("meowart_api.md")
|
||
if fallback.is_file():
|
||
print(f"[WARN] online Meowa guide unavailable; using bundled guide: {error}", file=sys.stderr)
|
||
print(fallback.read_text(encoding="utf-8"))
|
||
return 0
|
||
raise
|
||
|
||
|
||
def command_skill_doc_status(_args: argparse.Namespace) -> int:
|
||
guide = cli_path().with_name("meowart_api.md")
|
||
print_json({
|
||
"skillVersion": SKILL_VERSION,
|
||
"bundledGuide": str(guide),
|
||
"bundledGuideExists": guide.is_file(),
|
||
"remoteGuide": "通过 NianCode Main 代理读取",
|
||
})
|
||
return 0
|
||
|
||
|
||
def command_config_status(_args: argparse.Namespace) -> int:
|
||
print_json(request_json("/api/meowa/game-assets/config"))
|
||
return 0
|
||
|
||
|
||
def command_template_info(args: argparse.Namespace) -> int:
|
||
print_json(request_json(f"/api/meowa/game-assets/template-info?kind={args.kind}"))
|
||
return 0
|
||
|
||
|
||
def command_credits(_args: argparse.Namespace) -> int:
|
||
print_json(request_json("/api/meowa/game-assets/credits"))
|
||
return 0
|
||
|
||
|
||
def slug(value: str) -> str:
|
||
value = re.sub(r"[^A-Za-z0-9_-]+", "-", value.strip()).strip("-_")
|
||
return value[:72] or "asset"
|
||
|
||
|
||
def now_iso() -> str:
|
||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||
|
||
|
||
def read_reference_files(paths: list[str]) -> list[dict[str, str]]:
|
||
references: list[dict[str, str]] = []
|
||
total = 0
|
||
for raw_path in paths:
|
||
path = Path(raw_path).expanduser()
|
||
if not path.is_file():
|
||
raise AdapterError(f"参考文件不存在:{path}")
|
||
data = path.read_bytes()
|
||
if len(data) > 8 * 1024 * 1024:
|
||
raise AdapterError(f"参考文件过大:{path}")
|
||
total += len(data)
|
||
if total > 16 * 1024 * 1024:
|
||
raise AdapterError("参考文件总大小超过限制")
|
||
references.append({
|
||
"name": path.name,
|
||
"mimeType": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
|
||
"dataBase64": base64.b64encode(data).decode("ascii"),
|
||
})
|
||
return references
|
||
|
||
|
||
def safe_request_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||
result = dict(payload)
|
||
references = result.get("referenceFiles")
|
||
if isinstance(references, list):
|
||
result["referenceFiles"] = [
|
||
{"name": item.get("name"), "mimeType": item.get("mimeType")}
|
||
for item in references
|
||
if isinstance(item, dict)
|
||
]
|
||
return result
|
||
|
||
|
||
def extension_for(mime_type: str, file_name: str) -> str:
|
||
suffix = Path(file_name).suffix.lower()
|
||
if suffix and len(suffix) <= 10:
|
||
return suffix
|
||
return {
|
||
"image/png": ".png",
|
||
"image/jpeg": ".jpg",
|
||
"image/webp": ".webp",
|
||
"application/zip": ".zip",
|
||
"audio/mpeg": ".mp3",
|
||
"audio/wav": ".wav",
|
||
}.get(mime_type.lower(), ".bin")
|
||
|
||
|
||
def download_job(kind: str, job_id: str, output_index: int | None = None) -> dict[str, Any]:
|
||
params: dict[str, str] = {"kind": kind, "id": job_id}
|
||
if output_index is not None:
|
||
params["outputIndex"] = str(output_index)
|
||
payload = request_json(f"/api/meowa/game-assets/download?{urlencode(params)}")
|
||
if not isinstance(payload, dict) or not payload.get("success"):
|
||
raise AdapterError(payload_message(payload, "Meowa 下载响应无效"))
|
||
return payload
|
||
|
||
|
||
def generation_payload(args: argparse.Namespace, kind: str) -> dict[str, Any]:
|
||
template_config: Any = {}
|
||
if args.template_config:
|
||
try:
|
||
template_config = json.loads(args.template_config)
|
||
except json.JSONDecodeError as error:
|
||
raise AdapterError(f"template-config 不是有效 JSON:{error}") from error
|
||
if not isinstance(template_config, dict):
|
||
raise AdapterError("template-config 必须是 JSON 对象")
|
||
|
||
payload: dict[str, Any] = {
|
||
"kind": kind,
|
||
"templateName": args.template_name,
|
||
"templateConfig": template_config,
|
||
"requirement": args.requirement,
|
||
"jobName": args.job_name,
|
||
"modelName": args.model_name,
|
||
"resolution": args.resolution,
|
||
"aspectRatio": args.aspect_ratio,
|
||
"temperature": args.temperature,
|
||
"includeBase64": args.include_base64,
|
||
"referenceFiles": read_reference_files(args.reference_file),
|
||
}
|
||
if kind == "hd":
|
||
payload.update({
|
||
"hdRemoveBgMode": args.remove_bg_mode,
|
||
"projectId": args.project_id,
|
||
"threadId": args.thread_id,
|
||
})
|
||
return payload
|
||
|
||
|
||
def poll_job(kind: str, job_id: str, max_wait: int, poll_interval: float) -> dict[str, Any]:
|
||
deadline = time.time() + max(max_wait, 1)
|
||
last: dict[str, Any] = {}
|
||
while time.time() <= deadline:
|
||
payload = request_json(f"/api/meowa/game-assets/jobs?{urlencode({'kind': kind, 'id': job_id})}")
|
||
if not isinstance(payload, dict):
|
||
raise AdapterError("Meowa 任务响应不是 JSON 对象")
|
||
last = payload
|
||
status = str(payload.get("status") or "").strip().lower()
|
||
print(f"[INFO] meowa job={job_id} status={status or 'unknown'}", file=sys.stderr)
|
||
if status in TERMINAL_STATUSES:
|
||
return payload
|
||
time.sleep(max(poll_interval, 0.2))
|
||
raise AdapterError(f"Meowa 任务等待超时:{job_id},最后状态 {last.get('status', 'unknown')}")
|
||
|
||
|
||
def command_generate(args: argparse.Namespace, kind: str) -> int:
|
||
payload = generation_payload(args, kind)
|
||
output_root = Path(args.output_dir).expanduser()
|
||
run_dir = output_root / f"{slug(args.template_name)}-{int(time.time())}"
|
||
run_dir.mkdir(parents=True, exist_ok=True)
|
||
started_at = now_iso()
|
||
|
||
json_dump(run_dir / "request.meta.json", {
|
||
"provider": "meowa",
|
||
"skillVersion": SKILL_VERSION,
|
||
"kind": kind,
|
||
"startedAt": started_at,
|
||
"request": safe_request_payload(payload),
|
||
})
|
||
|
||
try:
|
||
submit = request_json("/api/meowa/game-assets/generate", method="POST", payload=payload)
|
||
json_dump(run_dir / "submit_response.json", submit)
|
||
if not isinstance(submit, dict):
|
||
raise AdapterError("Meowa 提交响应不是 JSON 对象")
|
||
job_id = str(submit.get("api_job_id") or submit.get("job_id") or "").strip()
|
||
if not job_id:
|
||
raise AdapterError("Meowa 提交响应缺少任务 ID")
|
||
print(f"[INFO] submitted meowa job={job_id}", file=sys.stderr)
|
||
|
||
final = poll_job(kind, job_id, args.max_wait, args.poll_interval)
|
||
json_dump(run_dir / "final_response.json", final)
|
||
status = str(final.get("status") or "").strip().lower()
|
||
if status not in {"success", "completed"}:
|
||
raise AdapterError(f"Meowa 任务未成功结束:{status or 'unknown'}")
|
||
|
||
download = download_job(kind, job_id)
|
||
file_name = str(download.get("fileName") or f"{job_id}.bin")
|
||
mime_type = str(download.get("mimeType") or "application/octet-stream")
|
||
encoded = download.get("dataBase64")
|
||
if not isinstance(encoded, str) or not encoded:
|
||
raise AdapterError("Meowa 下载响应缺少文件内容")
|
||
file_path = run_dir / f"asset{extension_for(mime_type, file_name)}"
|
||
file_path.write_bytes(base64.b64decode(encoded))
|
||
|
||
metadata = {
|
||
"provider": "meowa",
|
||
"integration": "niancode-meowa-game-assets",
|
||
"skillVersion": SKILL_VERSION,
|
||
"kind": kind,
|
||
"templateName": args.template_name,
|
||
"requirement": args.requirement,
|
||
"jobId": job_id,
|
||
"generatedAt": now_iso(),
|
||
"localPath": str(file_path.as_posix()),
|
||
"mimeType": mime_type,
|
||
"sourceType": "ai-generated",
|
||
"licenseStatus": "verify-meowa-plan-before-shipping",
|
||
"commercialUse": "依 Meowa 当前账户方案和条款确认;不得默认视为 CC0。",
|
||
"termsUrl": "https://meowa.ai/tos",
|
||
"upstreamSkill": "https://github.com/Meowa-AI/meowa-skills/tree/main/skills/game-assets",
|
||
}
|
||
json_dump(run_dir / "generation.meta.json", metadata)
|
||
print(f"[INFO] saved_dir={run_dir}", file=sys.stderr)
|
||
print_json({"success": True, "jobId": job_id, "file": str(file_path.as_posix()), "metadata": str((run_dir / 'generation.meta.json').as_posix())})
|
||
return 0
|
||
except Exception as error:
|
||
json_dump(run_dir / "error.json", {"error": str(error), "failedAt": now_iso()})
|
||
raise
|
||
|
||
|
||
def add_generation_args(parser: argparse.ArgumentParser, kind: str) -> None:
|
||
parser.add_argument("--template-name", required=True)
|
||
parser.add_argument("--requirement", required=True)
|
||
parser.add_argument("--template-config", default="")
|
||
parser.add_argument("--job-name", default="")
|
||
parser.add_argument("--model-name", default="gemini-3.1-flash-image-preview" if kind == "hd" else "")
|
||
parser.add_argument("--resolution", default="")
|
||
parser.add_argument("--aspect-ratio", default="1:1")
|
||
parser.add_argument("--temperature", type=float, default=0.0)
|
||
parser.add_argument("--include-base64", action="store_true")
|
||
parser.add_argument("--reference-file", action="append", default=[])
|
||
parser.add_argument("--output-dir", default=DEFAULT_OUTPUT_DIR)
|
||
parser.add_argument("--max-wait", type=int, default=900)
|
||
parser.add_argument("--poll-interval", type=float, default=3.0)
|
||
if kind == "hd":
|
||
parser.add_argument("--remove-bg-mode", default="")
|
||
parser.add_argument("--project-id", default=None)
|
||
parser.add_argument("--thread-id", default=None)
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(description="NianCode keyless adapter for Meowa game assets")
|
||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||
|
||
skill_doc = subparsers.add_parser("skill-doc")
|
||
skill_doc.add_argument("--task", default="")
|
||
skill_doc.add_argument("--topic", default="")
|
||
skill_doc.set_defaults(handler=command_skill_doc)
|
||
|
||
status = subparsers.add_parser("skill-doc-status")
|
||
status.set_defaults(handler=command_skill_doc_status)
|
||
|
||
config = subparsers.add_parser("config-status")
|
||
config.set_defaults(handler=command_config_status)
|
||
|
||
template = subparsers.add_parser("template-info")
|
||
template.add_argument("--kind", choices=("pixel", "hd"), required=True)
|
||
template.set_defaults(handler=command_template_info)
|
||
|
||
credits = subparsers.add_parser("credits")
|
||
credits.set_defaults(handler=command_credits)
|
||
|
||
for kind in ("pixel", "hd"):
|
||
generator = subparsers.add_parser(f"{kind}-gen")
|
||
add_generation_args(generator, kind)
|
||
generator.set_defaults(handler=lambda args, selected_kind=kind: command_generate(args, selected_kind))
|
||
|
||
return parser
|
||
|
||
|
||
def main() -> int:
|
||
args = build_parser().parse_args()
|
||
try:
|
||
return int(args.handler(args))
|
||
except AdapterError as error:
|
||
print(f"[ERROR] {error}", file=sys.stderr)
|
||
return 1
|
||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||
print(f"[ERROR] {error}", file=sys.stderr)
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|