Merge remote-tracking branch 'origin/master' into codex/game-agent-resource-section-pages

This commit is contained in:
2026-08-24 15:35:59 +08:00
36 changed files with 4229 additions and 321 deletions
@@ -27,7 +27,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
## Essential Invariants
- Authenticate MCP and business API calls with `Authorization: Bearer <tnr_sk_...>`. Never ask the user to paste a key into chat or place one in repository files.
- All eight generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
- All nine generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again.
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
@@ -106,6 +106,21 @@ client.generate_image(
)
```
For background removal, pass a stable owner-scoped object key, project resource ID, or asset ID; the helper keeps the same asynchronous submission and polling contract:
```python
session = client.prepare_canvas_session("去背景画布")
client.remove_background(
"editor-upload/object.png",
source_width=720,
source_height=1280,
canvasSession=session,
assetLabel="去背景结果",
)
```
Background removal preserves the source pixel size. For normal canvas placement with `canvasSession`, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Passing `targetLayerId` selects in-place replacement: the helper retains the session's project/library context but does not inject `canvasCompletion`, and it rejects an explicit `canvasCompletion` combined with `targetLayerId`. The target layer must point to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation.
Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`.
For character animation, pass the canvas session and asset label to `animate_character`. The helper submits asynchronously and returns the completed compact result containing the authoritative formal `resource` and `asset`; do not synthesize a library asset from the first frame.
@@ -51,6 +51,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| --- | --- | --- | --- |
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceLayout`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
@@ -58,7 +59,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
Poll all eight through:
Poll all nine through:
```text
GET /api/external/v1/generations/{operationId}
@@ -72,6 +73,7 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM
- Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported.
- UI extraction uses `assetFolderId` and `spritesheetLabel`.
- Character animation accepts `assetFolderId` and `assetLabel`. Its completed compact result directly returns the final `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`; never create a duplicate first-frame resource or asset.
- Background removal derives the final static-image `assetKind` from the authoritative source record. A conflicting request kind or any video, audio, animation, or image-sequence kind returns `400` before queueing. Without `canvasCompletion`, `targetLayerId` must point to the same authoritative object as `sourceImageSrc` (prefer `assetObjectId`, otherwise canonical bucket/object key).
- If a caller must manually create a `character-animation` resource or asset, put the authoritative frames and total sequence duration in `imageSequenceFrames` and `imageSequenceDurationMs`. Keep `generationInputs` replayable: it must not contain legacy runtime fields such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, or `durationSeconds`.
- Reload project/library state after completion when full current state is required.
@@ -47,6 +47,7 @@ Infer what is already clear and ask only for missing fields that block the selec
| --- | --- |
| Generate a background, character, spec, UI mockup, or publication image | Image generation |
| Redraw, retouch, or replace an existing image | Image edit |
| Remove the background from an existing image | Background removal |
| Generate from a local reference | Upload and confirm the local file, then image generation or edit |
| Build a reusable transparent icon/game atlas from a visual spec | Icon spritesheet generation |
| Extract marked assets from an existing UI design | UI design asset extraction |
@@ -15,7 +15,7 @@ Use this reference to build generation payloads, carry canvas/library context, p
## Asynchronous Submission
All eight generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
All nine generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
```json
{
@@ -71,6 +71,8 @@ status = client.get_generation(operation_id)
completed = client.wait_for_generation(operation_id)
```
Background removal uses the same submission and polling state machine. `sourceImageSrc` must be a stable owner-scoped object key, project resource ID, or asset ID; never pass a Data URL, Blob URL, or expiring signed URL. An explicit resource ID or asset ID is resolved before any object-key fallback. If a raw object key has multiple registrations with conflicting authoritative metadata, pass `sourceResourceId` to disambiguate or the server returns `400`. Use `projectId + canvasCompletion` for normal canvas placement. When `canvasCompletion` is absent, `projectId + targetLayerId` replaces an existing resource-backed layer and is rejected before queueing if the target is invalid; for a raw object key, the target resource becomes the durable source binding rechecked by the Worker. If both placement fields are absent, the server does not add the result to the canvas. The completed compact result contains the stable output object key, dimensions, and persisted resource/asset references when requested.
## Canvas and Asset-Library Completion
For endpoints that support these fields, include:
@@ -98,6 +100,8 @@ A minimal `canvasCompletion` is:
`dialogId` is optional. The placeholder supplies canvas placement and completion coordinates; it is not a final media pixel-size constraint. For successful pixel-art snapping, the result layer uses the final logical-grid PNG dimensions even when they differ from the placeholder. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed.
Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame.
For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence.
@@ -561,6 +561,58 @@ class GenarrativeExternalClient:
idempotency_key=idempotency_key,
)
def remove_background(
self,
source_image_src: str,
source_width: int | None = None,
source_height: int | None = None,
**fields: Any,
) -> Any:
source_image_src = normalize_optional_text(source_image_src)
if not source_image_src:
raise GenarrativeApiError(
"source_image_src must be an owner-scoped object key, resource ID, or asset ID"
)
if (source_width is None) != (source_height is None):
raise GenarrativeApiError("source_width and source_height must be provided together")
if source_width is not None and (
source_width <= 0 or source_height is None or source_height <= 0
):
raise GenarrativeApiError("source_width and source_height must be positive integers")
session = fields.get("canvasSession")
if session is None:
session = fields.get("canvas_session")
target_layer_id = normalize_optional_text(fields.get("targetLayerId"))
if target_layer_id and fields.get("canvasCompletion") is not None:
raise GenarrativeApiError(
"targetLayerId and canvasCompletion are mutually exclusive for background removal"
)
canvas_width = fields.get("canvasWidth")
canvas_height = fields.get("canvasHeight")
if (canvas_width is None) != (canvas_height is None):
raise GenarrativeApiError("canvasWidth and canvasHeight must be provided together")
if session is not None and canvas_width is None and not target_layer_id:
if source_width is None or source_height is None:
raise GenarrativeApiError(
"remove_background requires source_width and source_height when canvasSession is used without canvasWidth/canvasHeight"
)
fields["canvasWidth"] = source_width
fields["canvasHeight"] = source_height
self._apply_canvas_session_fields(
fields,
fields.get("assetLabel", "去背景结果"),
source_width or 1,
source_height or 1,
)
if target_layer_id:
fields.pop("canvasCompletion", None)
idempotency_key = fields.pop("idempotencyKey", None)
return self.submit_and_wait_generation(
"/api/external/v1/editor/images/background-removals",
{"sourceImageSrc": source_image_src, **fields},
idempotency_key=idempotency_key,
)
def generate_icon_spritesheet(
self,
reference_id: str,
@@ -687,11 +739,14 @@ def _self_test() -> None:
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
b"\x00\x00\x00\x02\x00\x00\x00\x03\x08\x06\x00\x00\x00"
)
with tempfile.NamedTemporaryFile(suffix="Hero Image.png") as fh:
with tempfile.NamedTemporaryFile(suffix="Hero Image.png", delete=False) as fh:
fh.write(png)
fh.flush()
assert image_dimensions(fh.name) == (2, 3)
assert source_layer_id_from_path(fh.name).startswith("external-reference-")
temp_path = fh.name
try:
assert image_dimensions(temp_path) == (2, 3)
assert source_layer_id_from_path(temp_path).startswith("external-reference-")
finally:
Path(temp_path).unlink(missing_ok=True)
assert unwrap_envelope({"ok": True, "data": {"upload": 1}}) == {"upload": 1}
client = GenarrativeExternalClient(api_key="test")
session = {"projectId": "proj-demo", "assetFolderId": "editor-asset-folder-demo"}
@@ -773,6 +828,54 @@ def _self_test() -> None:
assert len(result["asset"]["imageSequenceFrames"]) == 2
assert result["asset"]["imageSequenceDurationMs"] == 4000
calls.clear()
background_result = client.remove_background(
"uploads/source.png",
720,
1280,
canvasSession=session,
assetLabel="去背景结果",
)
assert background_result["taskId"] == "task-demo"
assert calls[0]["path"] == "/api/external/v1/editor/images/background-removals"
assert calls[0]["body"]["sourceImageSrc"] == "uploads/source.png"
assert calls[0]["body"]["projectId"] == "proj-demo"
assert calls[0]["body"]["assetFolderId"] == "editor-asset-folder-demo"
assert calls[0]["body"]["assetLabel"] == "去背景结果"
assert calls[0]["body"]["canvasCompletion"]["title"] == "去背景结果"
assert calls[0]["body"]["canvasCompletion"]["placeholder"]["width"] == 720
assert calls[0]["body"]["canvasCompletion"]["placeholder"]["height"] == 1280
calls.clear()
client.remove_background(
"uploads/source.png",
canvasSession=session,
targetLayerId="layer-1",
assetLabel="原位去背景结果",
)
assert calls[0]["body"]["projectId"] == "proj-demo"
assert calls[0]["body"]["assetFolderId"] == "editor-asset-folder-demo"
assert calls[0]["body"]["assetLabel"] == "原位去背景结果"
assert calls[0]["body"]["targetLayerId"] == "layer-1"
assert "canvasCompletion" not in calls[0]["body"]
calls.clear()
try:
client.remove_background(
"uploads/source.png",
canvasSession=session,
targetLayerId="layer-1",
canvasCompletion={"title": "冲突完成指令"},
)
except GenarrativeApiError as error:
assert "targetLayerId and canvasCompletion are mutually exclusive" in str(error)
else:
raise AssertionError("background removal must reject conflicting canvas placement modes")
assert calls == []
try:
client.remove_background("uploads/source.png", canvasSession=session)
except GenarrativeApiError as error:
assert "source_width and source_height" in str(error)
else:
raise AssertionError("canvas background removal must not guess source dimensions")
assert calls == []
client.generate_icon_spritesheet(
"editor-resource-spec",
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.mjs",
@@ -1733,12 +1733,12 @@ if (
}
if (
tauriConfig.version !== '0.1.0' ||
packageConfig.version !== '0.1.0' ||
cargoPackageVersion !== '0.1.0'
tauriConfig.version !== '0.1.1' ||
packageConfig.version !== '0.1.1' ||
cargoPackageVersion !== '0.1.1'
) {
throw new Error(
'AI game creator standard release must remain version 0.1.0 while game-chat uses its dedicated version',
'AI game creator standard release must remain version 0.1.1 while game-chat uses its dedicated version',
);
}
+1 -1
View File
@@ -1695,7 +1695,7 @@ dependencies = [
[[package]]
name = "genarrative-ai-game-creator-shell"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"agent-runtime-core",
"axum",
@@ -1,6 +1,6 @@
[package]
name = "genarrative-ai-game-creator-shell"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
publish = false
@@ -1,6 +1,6 @@
---
name: agc-client-projection
description: Preserve AGC client resource and version projection semantics after real project changes. Use when adding, replacing, or removing game files or registered art, when reasoning about project revisions and versions, or when client-visible resources appear out of sync.
description: Preserve AGC client resource and version projection semantics after real project changes. Use when querying, creating, deriving, adding, replacing, or removing registered resources, when reasoning about project revisions and versions, or when client-visible resources appear out of sync.
---
# AGC Client Projection
@@ -10,16 +10,20 @@ Let the client derive projections from real disk changes and trusted tool result
## Workflow
1. Write executable source to the current `game/` files and media to the relative paths returned by approved tools.
2. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
3. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
4. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
5. Do not claim a resource or version is visible before the client projects it. If projection is missing, report the changed relative files and let the client re-read durable state.
6. Never move HTML, CSS, or JavaScript into documentation folders. They belong to the game-code projection; prose, design notes, and instructions remain documents.
2. Before using or deriving an existing asset, call `agc_list_registered_assets` and select its `localAssetId`; never infer a source from a filename or submit a local path, platform ID, object key, operation ID, or idempotency key as a generation argument.
3. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image.
4. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
5. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
6. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
7. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
8. Do not claim a resource or version is visible before the client projects it. If projection is missing, report the changed relative files and let the client re-read durable state.
9. Never move HTML, CSS, or JavaScript into documentation folders. They belong to the game-code projection; prose, design notes, and instructions remain documents.
Call `agc_read_skill_resource` with `skillName="agc-client-projection"` and `relativePath="references/projection-contract.md"` when a request touches asset identity, revision behavior, or version history.
## Boundaries
- Platform provenance comes only from the approved art tool.
- Platform provenance comes only from approved client tools.
- The client owns permission checks, project identity, revision, project locks, paid submission, idempotency, operation recovery, download validation, warning projection, and manifest transactions.
- Browser evidence proves runtime behavior, not resource ownership.
- Source changes, resource registration, and version projection are distinct facts; report each accurately.
@@ -3,7 +3,13 @@
The client projects three distinct facts:
1. Game-code resources come from actual source files such as `game/index.html`, `game/style.css`, and `game/game.js`.
2. Art resources come from approved tool registration with durable platform provenance.
2. Art and media resources come from approved tool registration with durable platform provenance.
3. A project version is created only after a real game-source fingerprint change and a monotonic project revision update.
Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance.
`agc_list_registered_assets` is the only Direct read path for manifest resource identity. Its relative path and stable identifiers are evidence; omitted prompt, model, provider route, signed URL, host path, and credentials are intentionally not available to Codex.
`agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity.
`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-23.7",
"version": "2026-08-24.2",
"skills": [
{
"name": "agc-project-structure",
@@ -78,16 +78,23 @@
"purpose": "保持游戏代码、美术资源、revision 与正式版本的客户端投影一致",
"triggers": [
"新增或替换游戏文件",
"查询、创建或派生已登记媒体资源",
"去除已登记图片背景",
"素材或版本未显示",
"推理 revision 与版本关系"
],
"requiredTools": ["agc_tools.agc_read_skill_resource"],
"requiredTools": [
"agc_tools.agc_read_skill_resource",
"agc_tools.agc_list_registered_assets",
"agc_tools.agc_create_or_derive_resource",
"agc_tools.agc_remove_background"
],
"files": [
"SKILL.md",
"agents/openai.yaml",
"references/projection-contract.md"
],
"sha256": "e9da95c2f371620e045a3ab9f4721078c805e108263f479b4e01b1af96d462d3"
"sha256": "07d64f118dc1ecb36c37b92f272cf237649aa7f53fb939197e705d6afa2de315"
}
]
}
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,8 @@ pub(crate) const DIRECT_TOOLS_MCP_MODE_FLAG: &str = "--agc-direct-tools-mcp";
const DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES: usize = 1024 * 1024;
const DIRECT_TOOLS_MCP_MAX_ART_BRIEF_CHARS: usize = 4_000;
const DIRECT_TOOLS_MCP_MAX_SEARCH_QUERY_CHARS: usize = 400;
const DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000;
const DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS: usize = 120;
const DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
pub(crate) const DIRECT_TOOLS_MCP_CONTROLLED_WEB_SEARCH_ENV: &str =
"AGC_CONTROLLED_WEB_SEARCH_ENABLED";
@@ -82,6 +84,102 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
"additionalProperties": false
}
}),
json!({
"name": "agc_list_registered_assets",
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
"inputSchema": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"minLength": 1,
"maxLength": 80,
"description": "可选的 manifest 资源 kind 精确过滤,例如 video、character-animation、sound-effect、background-music 或 art-spritesheet-slice"
},
"assetId": {
"type": "string",
"minLength": 1,
"maxLength": 80,
"description": "可选的本地 manifest asset ID 精确过滤"
},
"includeSequenceFrames": {
"type": "boolean",
"default": false,
"description": "是否返回角色动画各帧的稳定 objectKey/assetObjectId 与尺寸;不返回签名 URL"
},
"offset": { "type": "integer", "minimum": 0, "default": 0 },
"limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }
},
"additionalProperties": false
}
}),
json!({
"name": "agc_create_or_derive_resource",
"description": "按用户当前意图创建或派生视频、角色动画、音效或背景音乐。模型只表达资源语义;客户端掌管项目路径、来源解析、权限、revision、项目锁、幂等键、operation 恢复、付费提交、下载校验和 manifest 事务。相同未完成请求会优先恢复,不能用它绕过账本重发付费请求。",
"inputSchema": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["video", "character-animation", "sound-effect", "background-music"]
},
"mode": { "type": "string", "enum": ["create", "derive"] },
"sourceLocalAssetId": {
"type": "string",
"minLength": 1,
"maxLength": 80,
"description": "derive 时必须使用 agc_list_registered_assets 返回的当前项目 localAssetId;禁止传路径、URL、objectKey 或平台凭据"
},
"prompt": {
"type": "string",
"minLength": 1,
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS
},
"assetName": {
"type": "string",
"minLength": 1,
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
}
},
"required": ["kind", "mode", "prompt", "assetName"],
"oneOf": [
{
"properties": {
"mode": { "const": "create" },
"kind": { "enum": ["video", "sound-effect", "background-music"] }
},
"not": { "required": ["sourceLocalAssetId"] }
},
{
"properties": { "mode": { "const": "derive" } },
"required": ["sourceLocalAssetId"]
}
],
"additionalProperties": false
}
}),
json!({
"name": "agc_remove_background",
"description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。",
"inputSchema": {
"type": "object",
"properties": {
"sourceLocalAssetId": {
"type": "string",
"minLength": 1,
"maxLength": 80,
"description": "必须来自 agc_list_registered_assets 返回的当前项目图片资源 localAssetId"
},
"assetName": {
"type": "string",
"minLength": 1,
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
}
},
"required": ["sourceLocalAssetId", "assetName"],
"additionalProperties": false
}
}),
json!({
"name": "agc_browser_playtest",
"description": "使用当前客户端的受限 Chromium 对当前游戏执行真实 desktop/mobile 双视口运行、截图、控制台、网络、Canvas/WebGL 和有限交互探针。",
@@ -205,6 +303,116 @@ fn bounded_tool_string(arguments: &Value, field: &str, max_chars: usize) -> Resu
Ok(value.to_string())
}
fn validate_tool_object_fields(arguments: &Value, allowed: &[&str]) -> Result<(), String> {
let object = arguments
.as_object()
.ok_or_else(|| "工具参数必须是对象".to_string())?;
if let Some(field) = object
.keys()
.find(|field| !allowed.contains(&field.as_str()))
{
return Err(format!("工具参数包含未审核字段:{field}"));
}
Ok(())
}
fn validate_registered_assets_arguments(arguments: &Value) -> Result<(), String> {
validate_tool_object_fields(
arguments,
&[
"kind",
"assetId",
"includeSequenceFrames",
"offset",
"limit",
],
)?;
for field in ["kind", "assetId"] {
if arguments.get(field).is_some() {
bounded_tool_string(arguments, field, 80)?;
}
}
if arguments
.get("includeSequenceFrames")
.is_some_and(|value| !value.is_boolean())
{
return Err("工具参数 includeSequenceFrames 必须是布尔值".to_string());
}
if arguments
.get("offset")
.is_some_and(|value| value.as_u64().is_none())
{
return Err("工具参数 offset 必须是非负整数".to_string());
}
if let Some(limit) = arguments.get("limit") {
let limit = limit
.as_u64()
.ok_or_else(|| "工具参数 limit 必须是 1 到 100 的整数".to_string())?;
if !(1..=100).contains(&limit) {
return Err("工具参数 limit 必须是 1 到 100 的整数".to_string());
}
}
Ok(())
}
fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), String> {
validate_tool_object_fields(
arguments,
&["kind", "mode", "sourceLocalAssetId", "prompt", "assetName"],
)?;
let kind = bounded_tool_string(arguments, "kind", 80)?;
if ![
"video",
"character-animation",
"sound-effect",
"background-music",
]
.contains(&kind.as_str())
{
return Err("工具参数 kind 不是受支持的媒体资源类型".to_string());
}
let mode = bounded_tool_string(arguments, "mode", 16)?;
if !["create", "derive"].contains(&mode.as_str()) {
return Err("工具参数 mode 必须是 create 或 derive".to_string());
}
let prompt = bounded_tool_string(
arguments,
"prompt",
DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS,
)?;
bounded_tool_string(
arguments,
"assetName",
DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
)?;
let source = arguments.get("sourceLocalAssetId");
if source.is_some() {
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
}
if kind == "background-music" && prompt.chars().count() > 140 {
return Err("背景音乐提示词必须在 1..=140 字符内".to_string());
}
if kind == "character-animation" && mode == "create" {
return Err("角色动画必须基于已登记图片资源派生".to_string());
}
match (mode.as_str(), source.is_some()) {
("create", true) => Err("create 模式不能携带源资源".to_string()),
("derive", false) => Err("derive 模式必须携带 sourceLocalAssetId".to_string()),
_ => Ok(()),
}
}
fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
bounded_tool_string(
arguments,
"assetName",
DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
)?;
Ok(())
}
fn tool_attempt(arguments: &Value) -> Result<usize, String> {
let attempt = arguments
.get("attempt")
@@ -329,6 +537,27 @@ async fn call_taonier_prepare_game_art(arguments: &Value) -> Value {
call_client_tool_bridge("taonier_prepare_game_art", arguments).await
}
async fn call_agc_list_registered_assets(arguments: &Value) -> Value {
if let Err(error) = validate_registered_assets_arguments(arguments) {
return mcp_tool_result(error, Vec::new(), true);
}
call_client_tool_bridge("agc_list_registered_assets", arguments).await
}
async fn call_agc_create_or_derive_resource(arguments: &Value) -> Value {
if let Err(error) = validate_resource_generation_arguments(arguments) {
return mcp_tool_result(error, Vec::new(), true);
}
call_client_tool_bridge("agc_create_or_derive_resource", arguments).await
}
async fn call_agc_remove_background(arguments: &Value) -> Value {
if let Err(error) = validate_remove_background_arguments(arguments) {
return mcp_tool_result(error, Vec::new(), true);
}
call_client_tool_bridge("agc_remove_background", arguments).await
}
async fn call_agc_browser_playtest(arguments: &Value) -> Value {
if let Err(error) = tool_attempt(arguments) {
return mcp_tool_result(error, Vec::new(), true);
@@ -396,6 +625,11 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option
let result = match tool {
"agc_read_skill_resource" => call_agc_read_skill_resource(&arguments),
"taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await,
"agc_list_registered_assets" => call_agc_list_registered_assets(&arguments).await,
"agc_create_or_derive_resource" => {
call_agc_create_or_derive_resource(&arguments).await
}
"agc_remove_background" => call_agc_remove_background(&arguments).await,
"agc_browser_playtest" => call_agc_browser_playtest(&arguments).await,
"agc_web_search" => call_agc_web_search(&arguments).await,
_ => mcp_tool_result("未知或未审核的 AGC 工具".to_string(), Vec::new(), true),
@@ -518,6 +752,9 @@ mod tests {
vec![
"agc_read_skill_resource",
"taonier_prepare_game_art",
"agc_list_registered_assets",
"agc_create_or_derive_resource",
"agc_remove_background",
"agc_browser_playtest"
]
);
@@ -570,6 +807,9 @@ mod tests {
vec![
"agc_read_skill_resource",
"taonier_prepare_game_art",
"agc_list_registered_assets",
"agc_create_or_derive_resource",
"agc_remove_background",
"agc_browser_playtest",
"agc_web_search"
]
@@ -577,6 +817,78 @@ mod tests {
assert!(!specs.to_string().contains("apiKey"));
}
#[test]
fn semantic_resource_tools_reject_unreviewed_or_inconsistent_arguments() {
assert!(validate_registered_assets_arguments(&json!({
"kind": "character-animation",
"includeSequenceFrames": true,
"offset": 0,
"limit": 100
}))
.is_ok());
assert!(validate_registered_assets_arguments(&json!({ "limit": 101 })).is_err());
assert!(
validate_registered_assets_arguments(&json!({ "projectPath": "/private" })).is_err()
);
assert!(validate_resource_generation_arguments(&json!({
"kind": "video",
"mode": "create",
"prompt": "生成森林过场",
"assetName": "森林过场"
}))
.is_ok());
assert!(validate_remove_background_arguments(&json!({
"sourceLocalAssetId": "asset-image-1",
"assetName": "角色透明图"
}))
.is_ok());
assert!(validate_remove_background_arguments(&json!({
"sourceLocalAssetId": "asset-image-1",
"assetName": "角色透明图",
"projectPath": "/private"
}))
.is_err());
assert!(validate_resource_generation_arguments(&json!({
"kind": "character-animation",
"mode": "derive",
"sourceLocalAssetId": "hero",
"prompt": "待机呼吸",
"assetName": "角色待机"
}))
.is_ok());
for malformed in [
json!({
"kind": "character-animation",
"mode": "create",
"prompt": "待机呼吸",
"assetName": "角色待机"
}),
json!({
"kind": "video",
"mode": "derive",
"prompt": "调整节奏",
"assetName": "新视频"
}),
json!({
"kind": "video",
"mode": "create",
"sourceLocalAssetId": "old-video",
"prompt": "生成视频",
"assetName": "新视频"
}),
json!({
"kind": "video",
"mode": "create",
"prompt": "生成视频",
"assetName": "新视频",
"operationId": "model-owned"
}),
] {
assert!(validate_resource_generation_arguments(&malformed).is_err());
}
}
#[test]
fn controlled_search_tool_rejects_malformed_result_bounds() {
assert_eq!(tool_search_max_results(&json!({})).expect("default"), 3);
@@ -362,6 +362,8 @@ mod tests {
let index = render_agc_skill_pack_index().expect("render index");
assert!(index.contains("taonier-art-assets"));
assert!(index.contains("agc_tools.taonier_prepare_game_art"));
assert!(index.contains("agc_tools.agc_list_registered_assets"));
assert!(index.contains("agc_tools.agc_create_or_derive_resource"));
assert!(!index.contains("Use real platform assets only"));
assert!(!index.contains("postprocess-failed-source-preserved"));
}
@@ -215,8 +215,11 @@ pub(crate) struct ListPendingLocalProjectResourceEditsInput {
pub(crate) struct PendingLocalProjectResourceEdit {
pub(crate) operation_id: String,
pub(crate) edit_kind: LocalProjectResourceEditKind,
pub(crate) generation_mode: LocalProjectResourceGenerationMode,
pub(crate) source_resource_id: String,
pub(crate) source_asset_id: Option<String>,
pub(crate) asset_name: String,
pub(crate) prompt_sha256: String,
pub(crate) phase: String,
pub(crate) created_at: u64,
}
@@ -418,6 +421,12 @@ struct ResourceEditLedger {
remote_sequence_frames_json: Option<String>,
#[serde(default)]
remote_sequence_duration_ms: Option<u64>,
#[serde(default)]
remote_canvas_project_id: Option<String>,
#[serde(default)]
remote_warnings: Vec<String>,
#[serde(default)]
remote_slice_warnings: Vec<String>,
remote_asset_object_id: Option<String>,
remote_model: Option<String>,
#[serde(default)]
@@ -2277,9 +2286,8 @@ fn resource_edit_remote_request(
"generationInputs": generation_inputs,
}),
)),
LocalProjectResourceEditKind::CharacterAnimation => Ok((
"/api/external/v1/editor/character-animations/generations",
serde_json::json!({
LocalProjectResourceEditKind::CharacterAnimation => {
let mut body = serde_json::json!({
"sourceLayerId": format!("resource-{}", input.operation_id),
"sourceImageSrc": source_reference.ok_or_else(|| "角色动画缺少稳定源图片引用".to_string())?,
"sourceWidth": source.source_width.ok_or_else(|| "角色动画缺少源图片宽度".to_string())?,
@@ -2292,8 +2300,33 @@ fn resource_edit_remote_request(
"model": "seedance2.0-fast",
"assetLabel": asset_name,
"generationInputs": generation_inputs,
}),
)),
});
if let Some(context) = canvas_context {
let width = source
.source_width
.ok_or_else(|| "角色动画缺少源图片宽度".to_string())?;
let height = source
.source_height
.ok_or_else(|| "角色动画缺少源图片高度".to_string())?;
body["projectId"] = serde_json::json!(context.project_id);
body["assetFolderId"] = serde_json::json!(context.asset_folder_id);
body["canvasCompletion"] = serde_json::json!({
"title": asset_name,
"placeholder": {
"x": 0,
"y": 0,
"width": width,
"height": height,
"originalWidth": width,
"originalHeight": height,
},
});
}
Ok((
"/api/external/v1/editor/character-animations/generations",
body,
))
}
LocalProjectResourceEditKind::Video => {
let mut body = serde_json::json!({
"prompt": prompt,
@@ -2449,7 +2482,7 @@ async fn submit_resource_edit_remote(
.body(body_value.to_string())
.send()
.await
.map_err(|_| "result-unknown: 资源编辑请求已发出但未取得确定响应".to_string())?;
.map_err(|error| format!("result-unknown: 资源编辑请求已发出但未取得确定响应{error}"))?;
let mut post_response_session_error = access.validate_frozen_session().err();
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
@@ -2591,11 +2624,17 @@ async fn wait_for_resource_edit_remote(
let job = platform_generation_status_data(&payload);
match json_string_field(job, "status").as_deref() {
Some("completed") => {
let result = job
let mut result = job
.get("result")
.filter(|value| !value.is_null())
.cloned()
.ok_or_else(|| "result-unknown: 资源编辑任务完成但缺少 result".to_string())?;
if let (Some(result), Some(warning)) = (
result.as_object_mut(),
job.get("warning").filter(|value| !value.is_null()),
) {
result.insert("_queryWarning".to_string(), warning.clone());
}
if !resource_edit_result_has_download(&result) {
return Err("result-unknown: 资源编辑结果缺少可下载媒体".to_string());
}
@@ -2622,10 +2661,33 @@ struct ResourceEditRemoteIdentity {
sequence_frames:
Option<Vec<shared_contracts::game_creation_app::GameCreationAppImageSequenceFrame>>,
sequence_duration_ms: Option<u64>,
canvas_project_id: Option<String>,
warnings: Vec<String>,
slice_warnings: Vec<String>,
asset_object_id: Option<String>,
model: Option<String>,
}
fn resource_edit_warning_text(value: &serde_json::Value) -> Option<String> {
if let Some(value) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(value.to_string());
}
let code = json_string_field(value, "code");
let message = json_string_field(value, "message")
.or_else(|| json_string_field(value, "reason"))
.or_else(|| json_string_field(value, "detail"));
match (code, message) {
(Some(code), Some(message)) => Some(format!("{code}: {message}")),
(Some(code), None) => Some(code),
(None, Some(message)) => Some(message),
(None, None) => None,
}
}
fn extract_resource_edit_remote_identity(
generated: &serde_json::Value,
edit_kind: LocalProjectResourceEditKind,
@@ -2689,6 +2751,18 @@ fn extract_resource_edit_remote_identity(
}
let resource_id =
json_string_field(resource, "resourceId").or_else(|| json_string_field(data, "resourceId"));
let canvas_project_id =
json_string_field(resource, "projectId").or_else(|| json_string_field(data, "projectId"));
let warnings = [generated.get("_queryWarning"), data.get("warning")]
.into_iter()
.flatten()
.filter_map(resource_edit_warning_text)
.collect();
let slice_warnings = data
.get("sliceWarning")
.and_then(resource_edit_warning_text)
.into_iter()
.collect();
let asset_object_id = json_string_field(resource, "assetObjectId")
.or_else(|| json_string_field(asset, "assetObjectId"))
.or_else(|| json_string_field(data, "assetObjectId"));
@@ -2699,6 +2773,9 @@ fn extract_resource_edit_remote_identity(
legacy_public_path,
sequence_frames,
sequence_duration_ms,
canvas_project_id,
warnings,
slice_warnings,
asset_object_id,
model,
})
@@ -3054,9 +3131,15 @@ async fn prepare_remote_resource_edit(
write_resource_edit_ledger(root, ledger)?;
}
if ledger.endpoint.is_none() || ledger.request_body_json.is_none() {
let canvas_context = if input.generation_mode
== LocalProjectResourceGenerationMode::Create
{
let canvas_context = if let Some(prepared) = prepared_source.as_ref() {
Some(prepared.canvas_context.clone())
} else if matches!(
input.edit_kind,
LocalProjectResourceEditKind::CharacterAnimation
| LocalProjectResourceEditKind::Video
| LocalProjectResourceEditKind::SoundEffect
| LocalProjectResourceEditKind::BackgroundMusic
) {
Some(prepare_external_canvas_generation_context(root, &client, access).await?)
} else {
None
@@ -3111,6 +3194,15 @@ async fn prepare_remote_resource_edit(
})
.transpose()?;
ledger.remote_sequence_duration_ms = identity.sequence_duration_ms;
ledger.remote_canvas_project_id = identity.canvas_project_id.or_else(|| {
ledger
.request_body_json
.as_deref()
.and_then(|body| serde_json::from_str::<serde_json::Value>(body).ok())
.and_then(|body| json_string_field(&body, "projectId"))
});
ledger.remote_warnings = identity.warnings;
ledger.remote_slice_warnings = identity.slice_warnings;
ledger.remote_asset_object_id = identity.asset_object_id;
ledger.remote_model = identity.model;
update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteCompleted)?;
@@ -3832,9 +3924,12 @@ fn commit_resource_edit_asset_internal(
} else {
GameCreationAppAssetSourceKind::Generated
},
canvas_project_id: None,
resource_id: Some(format!("local-asset:{asset_id}")),
asset_object_id: None,
canvas_project_id: ledger.remote_canvas_project_id.clone(),
resource_id: ledger
.remote_resource_id
.clone()
.or_else(|| Some(format!("local-asset:{asset_id}"))),
asset_object_id: ledger.remote_asset_object_id.clone(),
task_id: input.producer_task_id.clone(),
prompt: Some(prompt.to_string()),
model: ledger
@@ -4586,8 +4681,11 @@ pub(crate) fn list_pending_local_project_resource_edits_at(
pending.push(PendingLocalProjectResourceEdit {
operation_id: ledger.operation_id,
edit_kind: ledger.edit_kind,
generation_mode: ledger.generation_mode,
source_resource_id: ledger.source_resource_id,
source_asset_id: ledger.source_asset_id,
asset_name: ledger.asset_name,
prompt_sha256: sha256_hex(ledger.prompt.as_bytes()),
phase: ledger.phase.as_str().to_string(),
created_at: ledger.created_at,
});
@@ -4597,6 +4695,17 @@ pub(crate) fn list_pending_local_project_resource_edits_at(
Ok(pending)
}
pub(crate) fn local_project_resource_edit_warnings_at(
root: &Path,
operation_id: &str,
) -> Result<(Vec<String>, Vec<String>), String> {
validate_project_root(root)?;
validate_resource_edit_uuid(operation_id, "operationId")?;
let ledger = read_resource_edit_ledger(root, operation_id)?
.ok_or_else(|| "资源编辑告警对应的 operation 不存在".to_string())?;
Ok((ledger.remote_warnings, ledger.remote_slice_warnings))
}
fn resource_edit_pending_is_visible_to_current_principal(
ledger: &ResourceEditLedger,
current_platform_session: Option<&PlatformSessionSnapshot>,
@@ -5032,6 +5141,9 @@ pub(crate) async fn derive_local_project_resource_at(
remote_legacy_public_path: None,
remote_sequence_frames_json: None,
remote_sequence_duration_ms: None,
remote_canvas_project_id: None,
remote_warnings: Vec::new(),
remote_slice_warnings: Vec::new(),
remote_asset_object_id: None,
remote_model: None,
terminal_failure_code: None,
@@ -5563,6 +5675,9 @@ mod tests {
remote_legacy_public_path: None,
remote_sequence_frames_json: None,
remote_sequence_duration_ms: None,
remote_canvas_project_id: None,
remote_warnings: Vec::new(),
remote_slice_warnings: Vec::new(),
remote_asset_object_id: None,
remote_model: None,
terminal_failure_code: None,
@@ -7542,7 +7657,7 @@ mod tests {
}
#[tokio::test]
async fn submission_transport_failure_requires_reconciliation_before_resume() {
async fn canvas_context_transport_failure_stays_prepared_before_resume() {
let directory = tempfile::tempdir().expect("create transport failure fixture");
let root = directory.path();
init_local_game_project_at(root, PROJECT_ID, "远端提交传输失败测试")
@@ -7583,14 +7698,12 @@ mod tests {
})
.await
.expect_err("transport failure must fail closed");
assert!(error.contains("result-unknown"), "{error}");
assert_eq!(
read_resource_edit_ledger(root, &request.operation_id)
.expect("read transport failure ledger")
.expect("persisted transport failure ledger")
.phase,
ResourceEditLedgerPhase::ReconciliationRequired
);
assert!(error.contains("读取外部画布项目"), "{error}");
let persisted = read_resource_edit_ledger(root, &request.operation_id)
.expect("read transport failure ledger")
.expect("persisted transport failure ledger");
assert_eq!(persisted.phase, ResourceEditLedgerPhase::Prepared);
assert_eq!(persisted.remote_operation_id, None);
let resume_error =
resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput {
@@ -7601,7 +7714,7 @@ mod tests {
.await
.expect_err("transport failure must stop resume before network access");
assert!(
resume_error.contains("reconciliation-required"),
resume_error.contains("authentication-required"),
"{resume_error}"
);
}
@@ -8153,7 +8266,7 @@ mod tests {
"让角色自然呼吸",
"角色动画",
Some("stable-image-object-key"),
None,
Some(&canvas_context),
)
.expect("build character animation request");
assert_eq!(
@@ -8166,6 +8279,23 @@ mod tests {
);
assert_eq!(animation_body["sourceWidth"], serde_json::json!(720));
assert_eq!(animation_body["sourceHeight"], serde_json::json!(1280));
assert_eq!(animation_body["projectId"], serde_json::json!("project-1"));
assert_eq!(
animation_body["assetFolderId"],
serde_json::json!("folder-1")
);
assert_eq!(
animation_body["canvasCompletion"]["title"],
serde_json::json!("角色动画")
);
assert_eq!(
animation_body["canvasCompletion"]["placeholder"]["width"],
serde_json::json!(720)
);
assert_eq!(
animation_body["canvasCompletion"]["placeholder"]["height"],
serde_json::json!(1280)
);
let generated = serde_json::json!({
"data": {
@@ -8183,7 +8313,16 @@ mod tests {
],
"resource": {
"resourceId": "editor-resource-animation",
"projectId": "project-1",
"objectKey": "generated-character-drafts/layer/animation/frame-01.png"
},
"warning": {
"code": "animation-normalized",
"message": "序列帧尺寸已归一化"
},
"sliceWarning": {
"code": "partial-preview",
"reason": "预览仅展示首段"
}
}
});
@@ -8201,6 +8340,15 @@ mod tests {
Some("generated-character-drafts/layer/animation/frame-01.png")
);
assert_eq!(identity.sequence_duration_ms, Some(4_000));
assert_eq!(identity.canvas_project_id.as_deref(), Some("project-1"));
assert_eq!(
identity.warnings,
vec!["animation-normalized: 序列帧尺寸已归一化"]
);
assert_eq!(
identity.slice_warnings,
vec!["partial-preview: 预览仅展示首段"]
);
assert_eq!(identity.sequence_frames.as_ref().map(Vec::len), Some(1));
assert_eq!(
identity
@@ -8328,6 +8476,7 @@ mod tests {
assert!(request_lower.contains("idempotency-key:"));
assert!(request.contains(&server_source_key));
assert!(request.contains("external-video-project"));
assert!(request.contains("canvasCompletion"));
write_json(
&mut stream,
"202 Accepted",
@@ -8349,12 +8498,14 @@ mod tests {
"operationId": "external-video-operation",
"status": "completed",
"result": {
"resource": {
"resource": {
"resourceId": "external-video-resource",
"objectKey": "generated/result-video.mp4",
"assetObjectId": "generated-video-object"
},
"model": "seedance2.0-fast"
},
"warning": "视频已登记到画布",
"sliceWarning": "视频切片将在后台完成",
"model": "seedance2.0-fast"
}
}}),
);
@@ -8401,6 +8552,20 @@ mod tests {
&& !request.starts_with("GET /api/runtime/external-generation/")
}));
let derivative = result.asset.expect("derived video asset");
let (warnings, slice_warnings) =
local_project_resource_edit_warnings_at(root, &request.operation_id)
.expect("read persisted canvas warnings");
assert!(warnings.iter().any(|warning| warning == "视频已登记到画布"));
assert!(slice_warnings
.iter()
.any(|warning| warning == "视频切片将在后台完成"));
let persisted = read_resource_edit_ledger(root, &request.operation_id)
.expect("read completed video ledger")
.expect("completed video ledger");
assert_eq!(
persisted.remote_canvas_project_id.as_deref(),
Some("external-video-project")
);
assert_ne!(derivative.id, source_asset.id);
assert_eq!(
fs::read(root.join(&source_asset.local_path)).expect("read preserved source video"),
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Genarrative AI Game Creator",
"version": "0.1.0",
"version": "0.1.1",
"identifier": "world.genarrative.ai-game-creator",
"build": {
"beforeDevCommand": "npm --prefix ../.. run agc:serve",
@@ -8,6 +8,7 @@ import {
} from 'react';
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import {
clearStoredAuthAccessToken,
getClientAuthErrorMessage,
@@ -72,9 +73,12 @@ export class ClientRuntimeErrorBoundary extends Component<
return this.props.children;
}
return (
<main className="client-auth-shell" aria-label="客户端页面加载失败">
<main
className="client-auth-shell platform-theme platform-theme--light"
aria-label="客户端页面加载失败"
>
<section className="client-auth-panel">
<span className="client-auth-logo">tn</span>
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
<div>
<h1></h1>
<p className="client-auth-status">{this.state.errorMessage}</p>
@@ -389,9 +393,12 @@ export function AuthenticatedClient({
if (authStatus === 'checking') {
return (
<main className="client-auth-shell" aria-label="登录状态检查">
<main
className="client-auth-shell platform-theme platform-theme--light"
aria-label="登录状态检查"
>
<section className="client-auth-panel">
<span className="client-auth-logo">tn</span>
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
<h1></h1>
</section>
</main>
@@ -400,9 +407,12 @@ export function AuthenticatedClient({
if (!authUser) {
return (
<main className="client-auth-shell" aria-label="登录">
<main
className="client-auth-shell platform-theme platform-theme--light"
aria-label="登录"
>
<form className="client-auth-panel" onSubmit={handleLoginSubmit}>
<span className="client-auth-logo">tn</span>
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
<div>
<h1> GameAgent</h1>
<p></p>
+118 -55
View File
@@ -123,50 +123,73 @@ textarea {
display: grid;
min-height: 100vh;
padding: 24px;
background: #f8fafc;
color: #111827;
overflow-x: hidden;
overflow-y: auto;
background: var(--platform-body-fill);
color: var(--platform-text-strong);
place-items: center;
}
.client-auth-panel {
position: relative;
display: grid;
width: min(360px, 100%);
gap: 16px;
padding: 28px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
box-shadow: 0 18px 52px rgb(15 23 42 / 12%);
overflow: hidden;
border: 1px solid var(--platform-surface-border);
border-radius: 24px;
background: var(--platform-panel-fill);
box-shadow: var(--platform-panel-shadow);
}
.client-auth-panel::before {
position: absolute;
top: -94px;
right: -76px;
width: 204px;
height: 204px;
border-radius: 50%;
background: radial-gradient(
circle,
var(--platform-hero-glow-b),
transparent 68%
);
content: '';
pointer-events: none;
}
.client-auth-logo {
display: grid;
width: 34px;
height: 34px;
border-radius: 50%;
background: #101010;
color: #fff;
font-size: 12px;
font-weight: 800;
place-items: center;
position: relative;
z-index: 1;
width: 56px;
height: 56px;
object-fit: contain;
filter: drop-shadow(0 9px 15px rgb(182 98 63 / 18%));
}
.client-auth-panel h1 {
position: relative;
z-index: 1;
margin: 0;
font-size: 24px;
letter-spacing: 0;
}
.client-auth-panel p {
position: relative;
z-index: 1;
margin: 6px 0 0;
color: #6b7280;
color: var(--platform-text-soft);
font-size: 13px;
}
.client-auth-panel label {
position: relative;
z-index: 1;
display: grid;
gap: 7px;
color: #374151;
color: var(--platform-text-base);
font-size: 13px;
font-weight: 700;
}
@@ -175,42 +198,68 @@ textarea {
height: 38px;
min-width: 0;
padding: 0 11px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: #111827;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
background: var(--platform-input-fill);
color: var(--platform-text-strong);
font: inherit;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
background 160ms ease;
}
.client-auth-panel select {
height: 38px;
min-width: 0;
padding: 0 11px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: #111827;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
background: var(--platform-input-fill);
color: var(--platform-text-strong);
font: inherit;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
background 160ms ease;
}
.client-auth-panel select:focus {
border-color: #111827;
outline: 2px solid rgb(17 24 39 / 10%);
border-color: var(--platform-surface-hover-border);
outline: 2px solid var(--platform-input-focus-ring);
background: var(--platform-input-fill-focus);
}
.client-auth-panel input:focus {
border-color: #111827;
outline: 2px solid rgb(17 24 39 / 10%);
border-color: var(--platform-surface-hover-border);
outline: 2px solid var(--platform-input-focus-ring);
background: var(--platform-input-fill-focus);
}
.client-auth-panel button {
position: relative;
z-index: 1;
height: 38px;
border: 0;
border-radius: 8px;
background: #111827;
color: #fff;
border: 1px solid var(--platform-button-primary-border);
border-radius: 12px;
background: var(--platform-button-primary-fill);
box-shadow: var(--platform-profile-action-shadow);
color: var(--platform-button-primary-text);
font-weight: 700;
cursor: pointer;
transition:
transform 160ms ease,
box-shadow 160ms ease,
filter 160ms ease;
}
.client-auth-panel button:not(:disabled):hover {
filter: brightness(1.04);
transform: translateY(-1px);
}
.client-auth-panel button:not(:disabled):active {
transform: translateY(0);
}
.client-auth-panel button:disabled {
@@ -219,23 +268,29 @@ textarea {
}
.client-auth-tabs {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
padding: 4px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
border: 1px solid var(--platform-subpanel-border);
border-radius: 14px;
background: var(--platform-subpanel-fill);
}
.client-auth-tabs button {
border-color: transparent;
background: transparent;
color: #4b5563;
box-shadow: none;
color: var(--platform-text-soft);
}
.client-auth-tabs button.is-active {
background: #111827;
color: #fff;
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
box-shadow: 0 8px 18px rgb(182 98 63 / 18%);
color: var(--platform-button-primary-text);
}
.client-auth-code-row {
@@ -255,6 +310,17 @@ textarea {
overflow-wrap: anywhere;
}
@media (max-width: 480px) {
.client-auth-shell {
padding: 16px;
}
.client-auth-panel {
padding: 24px;
border-radius: 20px;
}
}
.launcher-shell {
--launcher-sidebar-width: 64px;
display: grid;
@@ -1604,8 +1670,7 @@ textarea {
.launcher-development-canvas {
display: grid;
min-height: 300px;
background:
linear-gradient(#f3f4f6 1px, transparent 1px),
background: linear-gradient(#f3f4f6 1px, transparent 1px),
linear-gradient(90deg, #f3f4f6 1px, transparent 1px), #fff;
background-size: 24px 24px;
place-items: center;
@@ -2456,8 +2521,7 @@ textarea {
min-width: 0;
min-height: 0;
overflow: hidden;
background:
linear-gradient(45deg, #101827 25%, transparent 25%),
background: linear-gradient(45deg, #101827 25%, transparent 25%),
linear-gradient(-45deg, #101827 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #101827 75%),
linear-gradient(-45deg, transparent 75%, #101827 75%), #0b1220;
@@ -3032,28 +3096,24 @@ textarea {
}
.launcher-card-collage span:nth-child(1) {
background:
radial-gradient(circle at 45% 38%, #6b5c43 0 18%, transparent 19%),
background: radial-gradient(circle at 45% 38%, #6b5c43 0 18%, transparent 19%),
radial-gradient(circle at 62% 54%, #2f2a23 0 25%, transparent 26%),
linear-gradient(135deg, #91a17e, #2c3327);
}
.launcher-card-collage span:nth-child(2) {
background:
radial-gradient(circle at 58% 45%, #b28b5a 0 16%, transparent 17%),
background: radial-gradient(circle at 58% 45%, #b28b5a 0 16%, transparent 17%),
radial-gradient(circle at 48% 55%, #302114 0 24%, transparent 25%),
linear-gradient(135deg, #d9e8ef, #9fb0a0);
}
.launcher-card-collage span:nth-child(3) {
background:
radial-gradient(circle at 50% 54%, #eac9c0 0 22%, transparent 23%),
background: radial-gradient(circle at 50% 54%, #eac9c0 0 22%, transparent 23%),
linear-gradient(135deg, #f7d9d5, #dab6aa);
}
.launcher-card-collage span:nth-child(4) {
background:
radial-gradient(circle at 56% 50%, #32271b 0 22%, transparent 23%),
background: radial-gradient(circle at 56% 50%, #32271b 0 22%, transparent 23%),
linear-gradient(135deg, #8e6c4e, #2b211b);
}
@@ -3077,8 +3137,12 @@ textarea {
.launcher-card-pattern > div {
display: grid;
grid-template-columns: repeat(2, 1fr);
background:
linear-gradient(45deg, transparent 46%, #fff 46% 54%, transparent 54%),
background: linear-gradient(
45deg,
transparent 46%,
#fff 46% 54%,
transparent 54%
),
linear-gradient(135deg, #55405a, #e4b2a7);
}
@@ -5815,8 +5879,7 @@ iframe.preview-frame {
.game-resource-card-visual,
.game-resource-card[data-preview-kind='media-image'] .game-resource-card-visual,
.game-resource-card[data-preview-kind='video'] .game-resource-card-visual {
background:
linear-gradient(45deg, #f1ebe7 25%, transparent 25%),
background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%),
linear-gradient(-45deg, #f1ebe7 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f1ebe7 75%),
linear-gradient(-45deg, transparent 75%, #f1ebe7 75%), #faf7f5;
@@ -1899,7 +1899,8 @@ export default function ProjectDevelopmentView({
}
const viewport = resolveViewport(event);
const category = viewport?.dataset.resourceSectionScroll as
ResourceCategory | undefined;
| ResourceCategory
| undefined;
if (!viewport || !category) {
return;
}
@@ -1918,7 +1919,8 @@ export default function ProjectDevelopmentView({
const event = rawEvent as WebKitGestureEvent;
const viewport = resolveViewport(event);
const category = viewport?.dataset.resourceSectionScroll as
ResourceCategory | undefined;
| ResourceCategory
| undefined;
if (!viewport || !category) {
return;
}
@@ -2061,7 +2063,8 @@ export default function ProjectDevelopmentView({
.querySelectorAll<HTMLElement>('[data-resource-section-scroll]')
.forEach((viewport) => {
const category = viewport.dataset.resourceSectionScroll as
ResourceCategory | undefined;
| ResourceCategory
| undefined;
if (!category) {
return;
}
@@ -2081,7 +2084,8 @@ export default function ProjectDevelopmentView({
?.querySelectorAll<HTMLElement>('[data-resource-section-scroll]')
.forEach((viewport) => {
const category = viewport.dataset.resourceSectionScroll as
ResourceCategory | undefined;
| ResourceCategory
| undefined;
if (!category) {
return;
}
@@ -3785,16 +3789,16 @@ export default function ProjectDevelopmentView({
dependencyLayoutSettled: dependencyLayout.settled,
dependencyPositioned: Boolean(
intent.resourceId &&
dependencyLayout.layout.positions.some(
(position) => position.resourceId === intent.resourceId,
),
dependencyLayout.layout.positions.some(
(position) => position.resourceId === intent.resourceId,
),
),
typeLayoutSettled: typeLayout.settled,
typePositioned: Boolean(
intent.resourceId &&
typeLayout.layout.positions.some(
(position) => position.resourceId === intent.resourceId,
),
typeLayout.layout.positions.some(
(position) => position.resourceId === intent.resourceId,
),
),
visible: targetVisible,
domRendered: Boolean(card),
@@ -35,6 +35,31 @@ export function registerAuthTests() {
delete window.__TAURI__;
});
it('renders the unauthenticated client with the shared light platform theme and product image', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
throw new Error(`unexpected fetch ${String(input)}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }),
),
);
const login = await screen.findByRole('main', { name: '登录' });
expect(login.className).toContain('platform-theme');
expect(login.className).toContain('platform-theme--light');
expect(screen.getByRole('img', { name: '陶泥儿' })).not.toBeNull();
expect(
screen.getByRole('heading', { name: '登录陶泥儿 GameAgent' }),
).not.toBeNull();
});
it('keeps the client at login when the native platform session cannot be installed', async () => {
window.__TAURI__ = {
core: {
@@ -1170,6 +1170,60 @@
}
}
},
"/api/external/v1/editor/images/background-removals": {
"post": {
"tags": [
"Editor Images"
],
"operationId": "removeExternalEditorImageBackground",
"summary": "去除编辑器图片背景",
"description": "提交已有静态图片素材的异步去背景任务。sourceImageSrc 只接受当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID;禁止 Data URL、Blob URL 和临时 signed URL。assetKind 只能表达静态图片,并且存在权威来源记录时必须与其类型一致;视频、音频、动画和图片序列在入队前返回 400。服务端固定使用 complex 去背景模式,不会在失败时切换到其它 provider。需要写入画布时提供 projectId 与 canvasCompletion;仅需原位替换既有图层时提供 projectId 与 targetLayerId,且来源与目标必须指向同一权威对象。",
"security": [
{
"ExternalApiKey": []
}
],
"parameters": [
{
"$ref": "#/components/parameters/IdempotencyKey"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EditorBackgroundRemovalRequest"
}
}
}
},
"responses": {
"202": {
"description": "去背景任务已持久化入队",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"502": {
"$ref": "#/components/responses/UpstreamError"
}
}
}
},
"/api/external/v1/editor/icon-spritesheets/generations": {
"post": {
"tags": [
@@ -2386,7 +2440,8 @@
"type": [
"string",
"null"
]
],
"description": "可选的来源项目资源 ID,用于在同一 objectKey 被多个资源以不同权威元数据登记时消歧。sourceImageSrc 已是项目资源 ID 或素材 ID 时按该显式 ID 定点解析;纯 objectKey 无法唯一确定权威来源时必须提供本字段,否则返回 400。原位替换使用纯 objectKey 且省略本字段时,服务端自动绑定 targetLayerId 对应的当前项目资源,并把该绑定写入队列供 Worker 复验。"
},
"assetKind": {
"type": [
@@ -3597,6 +3652,75 @@
},
"additionalProperties": false
},
"EditorBackgroundRemovalRequest": {
"type": "object",
"required": [
"sourceImageSrc"
],
"properties": {
"sourceImageSrc": {
"type": "string",
"minLength": 1,
"description": "当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID。禁止 Data URL、Blob URL 和临时 signed URL。"
},
"projectId": {
"type": [
"string",
"null"
],
"description": "可选项目上下文。提供 targetLayerId 时必须同时提供非空 projectId,否则在入队前返回 400。"
},
"targetLayerId": {
"type": [
"string",
"null"
],
"description": "提供时必须同时提供 projectId。仅在未提供 canvasCompletion 时用于原位替换既有画布图层,此时目标图层必须存在、关联当前项目静态图片资源;若图层提供 assetKindOverride,则以覆盖后的有效类型参与静态媒体门禁和来源类型比较。来源与目标必须指向同一权威对象:双方都有 assetObjectId 时按 ID 比较,任一缺失时回退 canonical bucket/objectKey;违反任一条件均在入队前返回 400。存在 canvasCompletion 时不参与原位替换。省略 targetLayerId 与 canvasCompletion 时只持久化请求指定的项目资源或素材库记录,不自动写入画布。"
},
"sourceResourceId": {
"type": [
"string",
"null"
],
"description": "可选的来源项目资源 ID,用于在同一 objectKey 被多个资源以不同权威元数据登记时消歧。sourceImageSrc 已是项目资源 ID 或素材 ID 时按该显式 ID 定点解析;纯 objectKey 无法唯一确定权威来源时必须提供本字段,否则返回 400。原位替换使用纯 objectKey 且省略本字段时,服务端自动绑定 targetLayerId 对应的当前项目资源,并把该绑定写入队列供 Worker 复验。"
},
"assetKind": {
"type": [
"string",
"null"
],
"description": "可选静态图片语义类型。服务端优先采用来源项目资源或素材库记录的权威语义类型;资产对象存储类型只参与非静态媒体门禁,不覆盖语义类型。请求值与权威来源类型不一致,或任一记录表示 video、audio、animation、image-sequence、sound-effect、background-music 等非静态媒体时,在入队前返回 400。",
"x-genarrative-media-family": "static-image"
},
"generationInputs": {
"$ref": "#/components/schemas/JsonValue"
},
"assetFolderId": {
"type": [
"string",
"null"
]
},
"assetLabel": {
"type": [
"string",
"null"
]
},
"canvasCompletion": {
"anyOf": [
{
"$ref": "#/components/schemas/EditorCanvasGenerationCompletion"
},
{
"type": "null"
}
],
"description": "画布生成占位完成指令。提供时优先按生成完成链路写入结果,targetLayerId 不参与原位替换。"
}
},
"additionalProperties": false
},
"EditorImageGenerationResponse": {
"type": "object",
"required": [
@@ -1,5 +1,19 @@
# 决策记录
## 2026-08-24 AGC Direct 媒体能力只通过客户端语义工具开放
- 背景:资源页已经补齐视频、角色动画、音效和背景音乐的 create/derive 能力,但 Direct Codex 只能准备标准美术包,无法查询已登记源资源或表达新增媒体意图。直接开放 Tauri invoke 会把项目路径、revision、operation、幂等键、登录态和事务权力交给模型。
- 决策:只新增 `agc_list_registered_assets``agc_create_or_derive_resource` 两个语义工具。Codex 只能提交资源过滤条件或 kind/mode/localAssetId/prompt/name;客户端权威解析 manifest 来源,生成并恢复稳定 operation/idempotency,串行付费调用,执行权限、项目锁、画布/素材目录准备、下载校验和 manifest CAS。完全匹配的 pending 请求自动恢复,不创建替代付费请求。
- 输出边界:资源查询和生成结果只投影相对路径、稳定 Canvas/resource/asset/task 身份、序列帧身份、pending 状态及脱敏告警;不返回完整 manifest、prompt、model、provider route、绝对路径、URL、Token、Cookie 或 API Key。角色动画及视频/音频新请求统一携带同名画布与素材目录上下文;已有冻结请求不迁移、不重写。
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md``apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md`
## 2026-08-23 External 去背景绑定权威静态来源与真实画布尺寸
- 背景:External v1 去背景曾把调用方 `assetKind` 原样带入持久化,并允许 `sourceImageSrc=A + targetLayerId=B` 覆盖不同资源;Python helper 又为所有画布完成请求固定生成 `1024×1024` 占位,导致非方形透明结果按占位尺寸拉伸。
- 决策:入队前从当前 owner 的项目资源或素材库解析来源权威语义类型,资产对象存储类型只参与非静态媒体门禁;显式项目资源 ID / 素材 ID 优先于 objectKey 回退,同一纯 objectKey 对应的候选权威元数据不一致时返回 `400` 并要求用 `sourceResourceId` 或业务 ID 消歧,禁止按列表首条决定类型。请求类型冲突或任一记录属于视频、音频、动画、图片序列时返回 `400`,队列只保存服务端解析出的静态语义类型。无 `canvasCompletion` 的原位替换优先比较双方 `assetObjectId`,任一缺失时回退 canonical `(bucket, objectKey)`,并要求默认类型一致;纯 objectKey 省略 `sourceResourceId` 时自动绑定目标图层资源并写入队列,由 Worker 复验同一绑定。helper 使用画布会话时必须取得真实源宽高或显式 `canvasWidth + canvasHeight`,不再猜测方形尺寸。
- 影响范围:External v1 去背景入队与 worker 复验、OpenAPI、Python helper、外部编辑器 skill 和相关契约测试;不修改 SpacetimeDB schema、BgFilter 协议或去背景输出尺寸语义。
- 验证方式:覆盖非静态类型与权威类型冲突、来源/目标不同对象拒绝及同对象通过、非方形 helper completion;运行 api-server 定向测试、helper self-test、OpenAPI 解析、编码与 diff 门禁。
## 2026-08-20 UI Editor LLM 递归输出与参考图单文件限制
- 背景:结构识别、界面语义建议和多图合并直接把 LLM 工具 arguments 反序列化为递归树;结构识别与语义建议还在 async command 中同步读取并 base64 编码参考图。模型异常输出或过大图片可能造成不受控内存、栈和 async worker 占用。
@@ -6492,6 +6506,7 @@
- Agent 发现:新增公开 `agent-integration.json``skill/SKILL.md``skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``.codex/skills/genarrative-external-editor-api/SKILL.md`
- 2026-08-23 补充:新增 `POST /api/external/v1/editor/images/background-removals` 后,External v1 生成 POST 由八类增至九类;该入口继续使用 `editor:image-generate` scope、稳定 `Idempotency-Key``202 + operationId` 与统一查询合同。外部请求只允许 OpenAPI 声明的去背景字段,拒绝内部 `taskId` 和其它未声明字段;入队前按当前 owner 解析稳定来源并规范化为权威 objectKey,同时预检、规范化项目与素材目录目标。提供 `targetLayerId` 时始终必须同时提供 `projectId`;没有 `canvasCompletion` 时目标图层必须存在并关联当前项目资源,存在 `canvasCompletion` 时沿用生成完成链路且不执行原位替换;目标无效、引用未登记或越权时不创建任务。未提供任一画布完成字段时不自动写入画布;任务 ID 仅由服务端队列生成。
## 2026-08-04 图片画布 BGM Prompt 采用唯一可见规范化文本与面板级同步提交锁
@@ -13564,6 +13579,7 @@
- Agent 发现:新增公开 `agent-integration.json``skill/SKILL.md``skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``.codex/skills/genarrative-external-editor-api/SKILL.md`
- 2026-08-23 补充:新增 `POST /api/external/v1/editor/images/background-removals` 后,External v1 生成 POST 由八类增至九类;该入口继续使用 `editor:image-generate` scope、稳定 `Idempotency-Key``202 + operationId` 与统一查询合同。外部请求只允许 OpenAPI 声明的去背景字段,拒绝内部 `taskId` 和其它未声明字段;入队前按当前 owner 解析稳定来源并规范化为权威 objectKey,同时预检、规范化项目与素材目录目标,未登记、越权引用和无效目标不创建任务;任务 ID 仅由服务端队列生成。托管 MCP 不再维护异步生成 operation 的幂等硬编码名单,而是从 OpenAPI operation/path 的 required `Idempotency-Key` header 自动生成 `idempotencyKey` 工具参数并转发同名 HTTP 头,避免新增 operation 只出现在 `tools/list` 却无法实际提交。
## 2026-08-04 图片画布 BGM Prompt 采用唯一可见规范化文本与面板级同步提交锁
@@ -14438,3 +14454,7 @@
- Direct 恢复 claim:同一 App 实例重复水合相同 stable turn 并收到“仍在运行”时,必须释放该 `projectPath + clientTurnId` 的恢复 claim,且不得写稳定 assistant 终态。后续显式刷新对话可按原身份重新读取或续跑;不新增无界自动重试。
- 严格图集崩溃收口:workflow 在严格图集调用前先持久化 `strictSpritesheetPending` 并冻结底层严格事务覆盖的九项旧合同身份;旧路径可精确冻结为缺失。Provider 完成结果先绑定原 retained stage ledger。恢复在同一项目锁内对账严格事务;只有新九项合同、规范图/背景图替换锚点与 retained spritesheet result 三者一致才补写 `completed`,旧九项合同才允许补偿。旧合同判定、写 `compensating`、恢复两项素材与登记、回读和清锚点必须在同一项目锁内,重启已有 `compensating` 也重新判定;第三种混合、漂移或 foreign result 状态进入 reconciliation。不能在主图集与四切片已整体提交后仍按两文件 rollback 制造混合包;若中断前阶段告警尚未进入 durable completed result,恢复结果追加“原阶段告警无法完整重放”的明确 warning,不静默清空。
- Direct 对话恢复从新到旧扫描全部合法 User 回合,遇到较新已回答回合继续向前,不得丢失更早未回答回合。成功返回时 Rust 已先持久化 assistant,前端冗余 append 失败也不得重跑 Provider;普通错误终态的显式 append 失败后,恢复 claim 必须保持到 React fallback writer 对同一稳定 assistant messageId 的写入明确成功或失败,不能在 writer 尚在途时按旧 `/history` 快照重跑。fallback 成功后释放 claimfallback 失败时跳过该 writer 的无界迟到重试并释放 claim,后续显式 `/history` 才可复用原稳定 `clientTurnId`。终态收敛后删除 claim,避免长会话无界增长。
# 2026-08-24 AGC Direct 抠图语义工具
- 决策:将 External v1 `/api/external/v1/editor/images/background-removals` 通过 `agc_remove_background` 加入受控 `agc_tools`。工具只接受当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;客户端负责正式 resourceId、画布/素材目录、稳定 operation/idempotency 身份、权限和错误脱敏,不向 Codex 暴露内部 BgFilter worker、凭据或任意 API。
- 约束:异步结果只投影有界队列状态,不允许模型自行构造源 URL 或在不确定提交后更换请求身份;External v1 负责 API Key、幂等接收与统一 operation 查询,客户端不得绕过该契约。
@@ -8,7 +8,7 @@
## 背景
VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部调用方的 HTTP 请求长期等待上游、OSS 持久化和 SpacetimeDB 回写。站内保留受控 `inline` 排障模式;External v1 的类生成则固定使用持久队列和统一查询接口,避免调用方超时后重复提交、重复扣费或丢失已完成结果。
VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部调用方的 HTTP 请求长期等待上游、OSS 持久化和 SpacetimeDB 回写。站内保留受控 `inline` 排障模式;External v1 的类生成则固定使用持久队列和统一查询接口,避免调用方超时后重复提交、重复扣费或丢失已完成结果。
## 目标
@@ -117,7 +117,7 @@ pending/running -> cancelled (预留)
- `queue`:默认值,HTTP handler 入队 `external_generation_job`,由 `external-generation-worker` 角色 claim lease 后执行;生产、预发和压测默认使用该模式。
- `inline`HTTP handler 直接调用同一个 worker executor,同步等待 provider、OSS 和 SpacetimeDB 写回完成后返回 `operation.status = completed`;只用于本地或低并发排查,不提供队列持久化、lease 重领和 worker 横向扩容。
External v1 类生成不读取上述模式分支:即使进程配置为 `inline`External handler 仍只做校验、幂等入队并返回 HTTP `202`。调用方按 `/api/external/v1/generations/{operationId}` 查询;这条外部契约不能因部署环境不同而从异步退化为同步响应。
External v1 类生成不读取上述模式分支:即使进程配置为 `inline`External handler 仍只做校验、幂等入队并返回 HTTP `202`。调用方按 `/api/external/v1/generations/{operationId}` 查询;这条外部契约不能因部署环境不同而从异步退化为同步响应。
同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换:
@@ -220,7 +220,7 @@ inline 完成结果与 External v1 completed compact result 继续使用结构
### External v1 异步提交与查询
External v1 复用上述九类 editor job kind 中除手动去背景外的八类生成 kind。外部 POST handler 只负责 API Key scope、owner、请求校验和入队,不调用 `*_for_owner` 同步执行函数:
External v1 复用上述全部九类 editor job kind,包括手动去背景。外部 POST handler 只负责 API Key scope、owner、请求校验和入队,不调用 `*_for_owner` 同步执行函数;去背景在入队前还会解析当前 owner 的稳定来源,并预检、规范化项目与素材目录目标。提供 `targetLayerId` 时必须同时提供 `projectId`;没有 `canvasCompletion` 时还要确认目标图层存在并关联当前项目资源,存在 `canvasCompletion` 时继续按生成完成链路写入而不执行原位替换
1. 每个生成 POST 必须携带 `Idempotency-Key`。服务端把 owner、job kind、稳定键和规范请求纳入 dedupe;未知结果重试必须复用原键。
2. 成功入队返回 HTTP `202``operationId``statusUrl``pollAfterMs`,并设置 `Location` / `Retry-After`;不返回 project、asset 或媒体结果。
@@ -1,5 +1,13 @@
# AI 游戏创作智能体 App 实施计划
## 2026-08-24 Direct Codex 已登记资源查询与媒体生成语义工具
- `agc_tools` 新增 `agc_list_registered_assets``agc_create_or_derive_resource`。前者按 `kind / assetId / offset / limit` 有界查询客户端权威 manifest,并可显式返回角色动画正式序列帧的稳定 objectKey、assetObjectId 和尺寸;结果不包含完整 manifest、prompt、model、provider route、签名 URL、宿主路径或凭据。后者只接受 `kind / mode / sourceLocalAssetId / prompt / assetName``create` 仅允许无源视频、音效和背景音乐,`derive` 必须引用当前项目已登记的 localAssetId,角色动画固定为 derive。
- 项目路径、projectId、当前 revision、源文件路径与媒体类型、operationId、Idempotency-Key、登录态、项目锁、付费提交、轮询恢复、下载校验与 manifest 事务全部由客户端持有。模型不能提交或覆盖这些字段。同一 Direct `clientTurnId + 规范语义参数` 生成稳定 UUID v4 身份;单回合同参重试复用原 operation,不同请求串行且最多四项。跨回合存在完全匹配的 pending 账本时优先恢复原 operation,不能换键重发。
- 资源查询同时投影未完成 operation 的安全状态。媒体工具成功只返回 operation、本地相对路径、资源类型、Canvas/resource/asset/task 身份、正式序列帧以及脱敏后的 `warnings / sliceWarnings`;错误继续使用统一脱敏边界。客户端资源账本持久化 completed 结果的两类告警,committed replay 不能把历史告警伪装成空集合。
- 角色动画、视频、音效和背景音乐在构造新的远端请求前统一准备当前项目同名画布与素材目录上下文,并在端点支持时携带 `projectId / assetFolderId / canvasCompletion`。角色动画 placeholder 使用源图片真实宽高,避免非方形角色进入画布时失真;正式 resource/asset 与序列帧继续直接复用 External 返回身份,不从首帧伪造重复资源。已有冻结 request body 或已受理 operation 保持不变,不因本次升级重建请求或重复扣费。
- 抠图通过新增 `agc_remove_background` 语义工具开放:模型只提交当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;客户端解析稳定 `resourceId`,准备同名画布/素材目录并生成稳定 operation/idempotency 身份,调用 External v1 `/api/external/v1/editor/images/background-removals` 后只返回有界队列状态。抠图服务仍由客户端和服务端负责源校验、BgFilter、素材登记与画布事务,Codex 不获得内部 worker、凭据或任意 API 调用权。
## 2026-08-23 AGC 资源生成补齐(视频 / 动画 / 音效 / 背景音乐)
- 统一资源编辑命令新增 `generationMode=create|derive``create` 用于无源生成视频、音效和背景音乐,`derive` 保持现有“基于已有资源派生”。视频新建请求不再携带 `referenceVideoSrcs`;音效 / 背景音乐新建使用同款文本生成端点,但提示词前缀改为“生成新音频”。
@@ -9,7 +17,7 @@
- 新建视频 / 音效 / 背景音乐时,先复用 `prepare_external_canvas_generation_context` 创建或匹配同名画板项目与素材库目录,并在生成请求中携带 `projectId / assetFolderId / canvasCompletion`,结果同时进入平台画布 / 素材库与本地 manifest。
- durable 账本新增 `remoteLegacyPublicPath` 字段(旧账本默认缺省),用于角色动画预览视频的换签下载与恢复;资源提交、轮询、下载、manifest 提交和恢复队列继续复用既有资源编辑事务链。
- 资源编辑请求指纹纳入 `generationMode`,同一 `operationId` 换生成模式重试时不再按旧账本模式恢复;旧账本继续通过 legacy 指纹兼容。
- 去背景`/api/editor/images/background-removals`)仍缺 External v1 契约,不在本次补齐范围;本次只补视频 / 角色动画 / 音效 / 背景音乐
- 去背景通过 External v1 语义工具开放;原始服务端 worker 和账号态内部路由仍不直接暴露给 Codex
## 2026-08-19 UI Editor 节点右键菜单
@@ -773,6 +781,7 @@ game-project/
- `apps/ai-game-creator-shell` 是独立 Tauri App,不复用 `apps/desktop-shell`
- 独立客户端启动时先进入平台登录检查;未登录页默认展示手机号验证码登录,并保留密码登录切换。验证码登录调用平台后端 `/api/auth/phone/send-code``/api/auth/phone/login`,密码登录继续调用 `/api/auth/entry`Tauri dev 下 `/api` 走本轮动态 AGC Vite 代理,普通发布版静态窗口下通过 Tauri 原生 HTTP transport 固定访问开发服务器 `https://dev.genarrative.world/api/*`,避开 WebView CORS 预检并持久化刷新 Cookie。原生 HTTP capability 只允许该 HTTPS origin 的 `/api/*`,客户端解析层也拒绝其它 release origingame-chat release 继续不依赖平台登录和 `api-server`。网络层失败时展示登录服务不可达提示,不裸露 WebView 的 `Load failed`
- 登录检查、未登录和客户端页面加载失败三种认证外壳统一使用共享陶泥儿产品形象与平台浅色主题 token;登录卡片、输入框、方式切换和主按钮沿用平台暖色视觉,并在不超过 `480px` 的窄视口收紧留白。视觉优化不改变服务器选择、验证码、密码、登录态安装或错误恢复语义。
- `npm run agc` 的本地 SpacetimeDB owner identity 以独立 `spacetimeDataDir` 为作用域,不绑定可能漂移的监听端口;旧端口作用域记录仅在同一 data dir 下身份唯一时自动迁移,出现多个不同旧身份时失败关闭。`.app/dev-stack.json` 必须记录规范化 `spacetimeDataDir`,独立壳只复用数据库名和该目录同时匹配且健康的后端,旧 schema 状态或共享目录状态缺少此字段时不得复用。POSIX 子进程在 `spawn` 返回时立即登记 `error / exit` 生命周期、保存 detached leader 的 PGID 并把句柄交给外层;即使 direct leader 已先退出,也必须继续向负 PGID 发信号清理同组后代。后端 ready 前的 SIGINT、SIGTERM、超时或 ENOENT 都必须走同一进程组清理链路,不能遗留 npm、Cargo 或 SpacetimeDB。非 Linux Runtime 执行 `project.verify` 时,`npm run` 参数校验必须允许受控的 `--silent``--ignore-scripts` 位于脚本名前,并继续拒绝缺少真实脚本名的调用。
- Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。
- 本地项目初始化会创建 `game/``assets/``memory/``memory/agents/``exports/``.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`
@@ -1188,7 +1197,7 @@ game-project/
- 普通项目对话只由一个 project-bound Codex app-server thread 执行。客户端系统提示词只放最小工程合同、当前游戏源码有界快照、项目 prompts 和审核 Skill 索引;不再批量读取项目 `.codex/.agents/.hermes` Skill 正文,也不恢复 Supervisor、专业 Agent 或 harness。
- 首页恢复“做游戏 / 做素材 / 做方案”三个创作类型,默认“做游戏”。该选择与设置页的 Agent Runtime 模式无关;每次首页提交仍只自动创建一个新项目并进入项目工作台。用户正文原样进入项目对话,`game|art|doc` 仅作为受限结构化首轮上下文传给同一 Codex thread,不拼接“初始意图”文案、不产生首页对话、不切换 Provider 或恢复旧 Runtime 编排。
- `agc-skill-pack.v1` 只包含项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影五项 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化时必须同步重算对应清单指纹并提升版本。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。
- DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP,基础工具固定为审核引用读取、标准陶泥儿美术准备和 desktop/mobile 浏览器试玩;`webSearchEnabled=true` 时才追加受控联网搜索。MCP 进程只做协议;真实浏览器、付费 External v1 调用和受控搜索通过随机 loopback 地址回到客户端主进程,因此不复制 GUI 登录态、开发者 Key项目路径到模型上下文。已登记工具固定自动批准,通用 shell、Codex 原生 webSearch、任意网络、多 Agent、插件和外部 MCP 继续关闭。
- DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP,基础工具固定为审核引用读取、标准陶泥儿美术准备、已登记资源有界查询、视频/角色动画/音效/BGM 的 create-or-derive 语义生成和 desktop/mobile 浏览器试玩;`webSearchEnabled=true` 时才追加受控联网搜索。MCP 进程只做协议;真实浏览器、付费 External v1 调用和受控搜索通过随机 loopback 地址回到客户端主进程,因此不复制 GUI 登录态、开发者 Key项目路径、revision、operation 或幂等键到模型上下文。已登记工具固定自动批准,但付费资源工具仍由客户端绑定稳定回合身份、限制单回合请求数、串行执行并优先恢复匹配账本;通用 shell、Codex 原生 webSearch、任意网络、多 Agent、插件和外部 MCP 继续关闭。
- `llm.webSearchEnabled=true``codex_app_server` 模式下只把 `agc_web_search` 加入 DirectProject 的 `agc_tools` 目录,并作为 app-server 连接池隔离键;关闭时目录与 MCP 环境白名单均不含该能力。客户端主进程只允许固定 Bing RSS 出站请求,禁用代理和重定向,设置 20 秒超时、400 字符查询上限、512 KiB 响应上限和最多 5 条结果;解析后仅向模型返回去 HTML 的有界标题、摘要和公网 HTTPS 链接,拒绝 loopback、私网、带凭据 URL 和非 HTTPS 结果。网页结果始终标记为不可信资料,只能引用,不能作为用户或系统指令执行。
- 陶泥儿生成继续复用既有私有 Key、持久幂等账本、operation 恢复、来源/下载/PNG 解码和 manifest 登记。完整可信图集缺切片可以继续,固定四切片只是推荐路径;凭据失效、来源不明或结果未知时失败关闭,不能自动换 Key 或重新扣费。
- 自定义 LLM API Key 路由只在 DirectHome/DirectProject 经 loopback `/responses` 流式代理转发。代理不注入 Key,只要求请求自带 Bearer,并剥离开发网关错误携带的 `X-Codex-*` ChatGPT 账户额度头,防止隔离 app-server 把 API Provider 误判为余额 0;旧 ToolHost 保持原 Provider 行为。

Some files were not shown because too many files have changed in this diff Show More