Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70a2246e8b | |||
| b7e3dac661 | |||
| 2e4a1996c8 | |||
| fe4e952853 | |||
| 18654b6806 | |||
| ee7d00c0b1 | |||
| 9cd1a94369 | |||
| 470e85c0ff |
@@ -32,7 +32,6 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
|
||||
- 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.
|
||||
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
|
||||
- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing.
|
||||
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
|
||||
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
|
||||
|
||||
|
||||
@@ -52,7 +52,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` | `sliceMode`, `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
| 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` |
|
||||
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
@@ -94,7 +94,7 @@ For image edit/redraw, confirming an upload is not sufficient: create a project
|
||||
|
||||
The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL.
|
||||
|
||||
`sliceMode` is required and has no default, so every request must state it. Use `"connected-components"` to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each) only when the requirement names equal grid cells or fixed slots; the dimensions must come from that requirement. `connected-components` must not carry `gridX`/`gridY`, and `sliceCount` constrains the connected-component result instead of expressing a grid. Omitting `sliceMode`, or contradicting the declared mode with grid dimensions, returns 400 before pricing, enqueueing, or any provider call.
|
||||
`sliceLayout: "grid-2x2"` is an opt-in contract for four fixed game-runtime assets. The provider prompt and server persistence both preserve the ordered slots left-top, right-top, left-bottom, right-bottom. Omit it to retain the default connected-component slicing behaviour for ordinary free-form icon sheets.
|
||||
|
||||
## Common Values
|
||||
|
||||
|
||||
@@ -79,9 +79,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system
|
||||
|
||||
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
|
||||
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
|
||||
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. `sliceMode` is required and has no default: send `sliceMode: "grid"` with `gridX`/`gridY` only when the requirement itself fixes the slots or names the column/row count, and otherwise send `sliceMode: "connected-components"` (with `sliceCount` when a subject count must be constrained); never invent a grid to express "kinds of assets", and never send `gridX`/`gridY` with `connected-components`.
|
||||
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For the four-category game contract it must also send `sliceLayout: "grid-2x2"`; this is an explicit fixed-slot contract, not a client-side guessed crop.
|
||||
|
||||
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When the requirement fixes grid slots, require the response `sliceMode` to match the declared `grid` request and exactly `gridX × gridY` slices before registering the local runtime sheet; a connected-components request is instead judged by its own `sliceCount` or by the requirement, and both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
|
||||
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require response `sliceLayout: "grid-2x2"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
|
||||
|
||||
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
|
||||
|
||||
|
||||
@@ -617,7 +617,6 @@ class GenarrativeExternalClient:
|
||||
self,
|
||||
reference_id: str,
|
||||
icon_descriptions: list[str],
|
||||
slice_mode: str,
|
||||
**fields: Any,
|
||||
) -> Any:
|
||||
reference_id = normalize_optional_text(reference_id)
|
||||
@@ -626,20 +625,6 @@ class GenarrativeExternalClient:
|
||||
descriptions = [item.strip() for item in icon_descriptions if item.strip()]
|
||||
if not descriptions:
|
||||
raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item")
|
||||
slice_mode = normalize_optional_text(slice_mode)
|
||||
if slice_mode not in ("connected-components", "grid"):
|
||||
raise GenarrativeApiError(
|
||||
"slice_mode must be declared explicitly as 'connected-components' or 'grid'; the API has no default"
|
||||
)
|
||||
grid_x = fields.get("gridX")
|
||||
grid_y = fields.get("gridY")
|
||||
if slice_mode == "grid":
|
||||
if grid_x is None or grid_y is None:
|
||||
raise GenarrativeApiError("slice_mode='grid' requires both gridX and gridY")
|
||||
elif grid_x is not None or grid_y is not None:
|
||||
raise GenarrativeApiError(
|
||||
"slice_mode='connected-components' must not carry gridX/gridY"
|
||||
)
|
||||
label = fields.get("assetLabel", "图标图集")
|
||||
self._apply_canvas_session_fields(fields, label, 1024, 1024)
|
||||
fields.setdefault("screenColor", "auto")
|
||||
@@ -650,7 +635,6 @@ class GenarrativeExternalClient:
|
||||
**fields,
|
||||
"referenceId": reference_id,
|
||||
"iconDescriptions": descriptions,
|
||||
"sliceMode": slice_mode,
|
||||
},
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
@@ -895,7 +879,6 @@ def _self_test() -> None:
|
||||
client.generate_icon_spritesheet(
|
||||
"editor-resource-spec",
|
||||
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
|
||||
"connected-components",
|
||||
canvasSession=session,
|
||||
assetLabel="贪吃蛇透明图集",
|
||||
referenceId="must-not-override-explicit-reference",
|
||||
@@ -904,7 +887,6 @@ def _self_test() -> None:
|
||||
assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations"
|
||||
assert calls[0]["body"]["referenceId"] == "editor-resource-spec"
|
||||
assert calls[0]["body"]["screenColor"] == "auto"
|
||||
assert calls[0]["body"]["sliceMode"] == "connected-components"
|
||||
assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上"
|
||||
assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo"
|
||||
print("self-test ok")
|
||||
|
||||
+5
-19
@@ -1,8 +1,8 @@
|
||||
# Server-side OpenAI-compatible LLM endpoint base URL.
|
||||
LLM_BASE_URL="https://api.tiantoken.com/v1"
|
||||
LLM_BASE_URL="https://api.vectorengine.cn/v1"
|
||||
|
||||
# Server-side API key used by the local Vite proxy.
|
||||
# Recommended: set `LLM_API_KEY` locally, or use `TIANTOKEN_API_KEY`
|
||||
# Recommended: set `LLM_API_KEY` locally, or use `VECTOR_ENGINE_API_KEY`
|
||||
# through the Rust api-server proxy.
|
||||
# Legacy compatibility: `VITE_LLM_API_KEY` is still supported by the proxy,
|
||||
# but it should not be relied on by browser code.
|
||||
@@ -122,7 +122,7 @@ WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=""
|
||||
# Model name for chat completions.
|
||||
VITE_LLM_MODEL="gpt-5.4-mini"
|
||||
GENARRATIVE_LLM_PROVIDER="openai-compatible"
|
||||
GENARRATIVE_LLM_BASE_URL="https://api.tiantoken.com/v1"
|
||||
GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1"
|
||||
GENARRATIVE_LLM_API_KEY=""
|
||||
GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
|
||||
|
||||
@@ -130,15 +130,10 @@ GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
|
||||
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
|
||||
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
|
||||
|
||||
# Tiantoken LLM and GPT-image-2 / Gemini image generation config.
|
||||
TIANTOKEN_BASE_URL="https://api.tiantoken.com"
|
||||
TIANTOKEN_API_KEY=""
|
||||
TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS="1000000"
|
||||
|
||||
# VectorEngine is retained for Suno audio generation only.
|
||||
# VectorEngine LLM and GPT-image-2 / Gemini image generation config.
|
||||
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.cn"
|
||||
VECTOR_ENGINE_API_KEY=""
|
||||
VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS="180000"
|
||||
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000"
|
||||
|
||||
# ElevenLabs editor sound-effect generation is server-side only.
|
||||
ELEVENLABS_BASE_URL="https://api.elevenlabs.io"
|
||||
@@ -158,15 +153,6 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600"
|
||||
ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520"
|
||||
ALIYUN_OSS_SUCCESS_ACTION_STATUS="200"
|
||||
|
||||
# AGC 项目定时快照上传目标。对象只落在服务端私有前缀
|
||||
# `agc/project-snapshots/v1/{user}/{project}/` 下,客户端直传票据不覆盖该前缀。
|
||||
# bucket 与凭据可以与资源 bucket 分离;凭据未设置时回退使用 ALIYUN_OSS_ACCESS_KEY_*,
|
||||
# 但 bucket / endpoint 默认指向 AGC 发行 bucket,需要该凭据具备目标 bucket 的 PutObject 权限。
|
||||
GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET="agc-dev"
|
||||
GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT="oss-rg-china-mainland.aliyuncs.com"
|
||||
GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID=""
|
||||
GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET=""
|
||||
|
||||
# BgFilter 受限资源 worker。父 api-server / external-generation-worker 与唯一的
|
||||
# `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程必须使用同一个内部 Token。
|
||||
# `npm run dev` 与 `npm run dev:api-server` 都会自动带起并验活唯一 worker,不要再开第二个终端重复启动。
|
||||
|
||||
@@ -164,7 +164,6 @@ module.exports = {
|
||||
'server-rs/target-*',
|
||||
'apps/desktop-shell/src-tauri/target',
|
||||
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
|
||||
'target',
|
||||
'src/main.tsx',
|
||||
'src/App.tsx',
|
||||
|
||||
@@ -7,10 +7,6 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: project-ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
|
||||
# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
|
||||
npm run format:staged
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
|
||||
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
|
||||
npm run check:pre-push-master -- "$@"
|
||||
|
||||
@@ -38,7 +38,6 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
|
||||
interface MockIntersectionObserverController {
|
||||
enter: (target: Element) => void;
|
||||
enterAll: (targets: Element[]) => void;
|
||||
isObserved: (target: Element) => boolean;
|
||||
}
|
||||
|
||||
@@ -107,25 +106,6 @@ function installIntersectionObserverMock(): MockIntersectionObserverController {
|
||||
);
|
||||
});
|
||||
},
|
||||
enterAll(targets) {
|
||||
act(() => {
|
||||
for (const target of targets) {
|
||||
const record = observed.get(target);
|
||||
if (!record) {
|
||||
throw new Error('目标缩略图尚未进入 IntersectionObserver');
|
||||
}
|
||||
record.callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
target,
|
||||
} as IntersectionObserverEntry,
|
||||
],
|
||||
record.observer,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
isObserved(target) {
|
||||
return observed.has(target);
|
||||
},
|
||||
@@ -773,10 +753,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
||||
const thumbnails = entries.map((entry) =>
|
||||
thumbnailElementForLabel(entry.label),
|
||||
);
|
||||
for (const thumbnail of thumbnails) {
|
||||
thumbnails.forEach((thumbnail) => {
|
||||
expect(observer.isObserved(thumbnail)).toBe(true);
|
||||
}
|
||||
observer.enterAll(thumbnails);
|
||||
observer.enter(thumbnail);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
@@ -796,7 +776,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
||||
const observer = installIntersectionObserverMock();
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
"schemaVersion": "game-creator-config.v2",
|
||||
"agentMode": "codex_app_server",
|
||||
"llm": {
|
||||
"customEnabled": false,
|
||||
"visibleModels": [],
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||
"model": "gpt-6-astra",
|
||||
@@ -15,7 +13,7 @@
|
||||
"autoCompactTokenLimit": 64000,
|
||||
"toolOutputTokenLimit": 12000,
|
||||
"requestTimeoutMs": 180000,
|
||||
"maxRetries": 10,
|
||||
"maxRetries": 2,
|
||||
"retryBackoffMs": 500
|
||||
},
|
||||
"agentLlm": {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.47",
|
||||
"version": "0.1.29",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
@@ -47,7 +47,6 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
"@tauri-apps/plugin-updater": "2.11.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"focus-trap-react": "^12.0.3",
|
||||
"lexical": "^0.47.0",
|
||||
@@ -58,10 +57,7 @@
|
||||
"react-colorful": "^5.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-window": "^1.8.11",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"three": "^0.184.0",
|
||||
"vite": "^6.2.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
@@ -73,8 +69,6 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.184.1",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
"vitest": "^0.34.6"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -113,18 +113,9 @@ const allowedUncalledTauriCommands = [
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
|
||||
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
|
||||
// (`src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
|
||||
// `start_local_project_asset_generation`。
|
||||
'generate_local_project_asset',
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_direct_project_conversation',
|
||||
// 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用;
|
||||
// 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。
|
||||
'read_local_project_snapshot_state',
|
||||
'sync_local_project_snapshot',
|
||||
'reset_design_agent_session',
|
||||
'stop_local_game_preview_if_matches',
|
||||
'start_game_creator_external_mcp',
|
||||
@@ -1299,7 +1290,7 @@ for (const requiredSource of [
|
||||
}
|
||||
}
|
||||
|
||||
if (tauriConfig.productName !== '陶泥儿') {
|
||||
if (tauriConfig.productName !== 'Genarrative AI Game Creator') {
|
||||
throw new Error('AI game creator shell productName drifted');
|
||||
}
|
||||
|
||||
@@ -1311,19 +1302,19 @@ const expectedBundledDesignAgentResources = {
|
||||
'design-agent': 'design-agent',
|
||||
};
|
||||
const expectedBundledWindowsResources = {
|
||||
'resources/codex/win-x64/bin/codex.exe': 'coding-agent/win-x64/bin/codex.exe',
|
||||
'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe',
|
||||
'resources/codex/win-x64/bin/codex-code-mode-host.exe':
|
||||
'coding-agent/win-x64/bin/codex-code-mode-host.exe',
|
||||
'codex/win-x64/bin/codex-code-mode-host.exe',
|
||||
'resources/codex/win-x64/codex-path/rg.exe':
|
||||
'coding-agent/win-x64/codex-path/rg.exe',
|
||||
'codex/win-x64/codex-path/rg.exe',
|
||||
'resources/codex/win-x64/codex-resources/codex-command-runner.exe':
|
||||
'coding-agent/win-x64/codex-resources/codex-command-runner.exe',
|
||||
'codex/win-x64/codex-resources/codex-command-runner.exe',
|
||||
'resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe':
|
||||
'coding-agent/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
|
||||
'codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
|
||||
'resources/codex/win-x64/codex-package.json':
|
||||
'coding-agent/win-x64/codex-package.json',
|
||||
'resources/codex/win-x64/NOTICE.md': 'coding-agent/win-x64/NOTICE.md',
|
||||
'resources/codex/win-x64/manifest.json': 'coding-agent/win-x64/manifest.json',
|
||||
'codex/win-x64/codex-package.json',
|
||||
'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md',
|
||||
'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json',
|
||||
'resources/plugins': 'plugins',
|
||||
};
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -724,7 +724,6 @@ function canvasAssetCall(agentId) {
|
||||
assetKind: 'art-spritesheet',
|
||||
assetLabel: '游戏首版核心美术素材',
|
||||
replaceExisting: false,
|
||||
sliceMode: 'connected-components',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2692,7 +2691,7 @@ function createDeterministicCanvasFixture(apiKey) {
|
||||
'deterministic spritesheet fixture',
|
||||
model: 'deterministic-canvas-v1',
|
||||
provider: 'deterministic-loopback',
|
||||
sliceMode: 'connected-components',
|
||||
sliceLayout: 'grid-2x2',
|
||||
spritesheetResource: {
|
||||
resourceId,
|
||||
projectId,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* `agc` 开发启动下发给 Vite 的客户端特性开关默认值。
|
||||
*
|
||||
* 开发态默认关闭客户端更新检查:`npm run agc` / `agc:serve` 启动的客户端不请求 OSS
|
||||
* 更新清单,也不显示更新入口。需要联调更新流程时显式传
|
||||
* `VITE_AGC_ENABLE_APP_UPDATE_CHECK=1`;此处不覆盖已经显式配置的取值。
|
||||
*/
|
||||
const agcAppUpdateCheckEnvKey = 'VITE_AGC_ENABLE_APP_UPDATE_CHECK';
|
||||
|
||||
function withAgcDevFeatureFlags(env = process.env) {
|
||||
if (String(env[agcAppUpdateCheckEnvKey] ?? '').trim()) {
|
||||
return env;
|
||||
}
|
||||
return {
|
||||
...env,
|
||||
[agcAppUpdateCheckEnvKey]: '0',
|
||||
};
|
||||
}
|
||||
|
||||
export { agcAppUpdateCheckEnvKey, withAgcDevFeatureFlags };
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
const agcDevHost = '127.0.0.1';
|
||||
const legacyAgcDevPort = 3080;
|
||||
const agcVitePortEnvKey = 'GENARRATIVE_AGC_VITE_PORT';
|
||||
const agcAdminWebHost = '127.0.0.1';
|
||||
const legacyAgcAdminWebPort = 3102;
|
||||
// 与 scripts/dev.mjs 的后台 Web 端口配置保持同一环境变量名。
|
||||
const agcAdminWebPortEnvKey = 'ADMIN_WEB_PORT';
|
||||
|
||||
function readConfiguredAgcDevPort(env = process.env) {
|
||||
const rawPort = String(env[agcVitePortEnvKey] ?? '').trim();
|
||||
@@ -103,100 +99,13 @@ function withAgcDevEndpointEnv(endpoint, env = process.env) {
|
||||
};
|
||||
}
|
||||
|
||||
function readConfiguredAgcAdminWebPort(env = process.env) {
|
||||
const rawPort = String(env[agcAdminWebPortEnvKey] ?? '').trim();
|
||||
if (!rawPort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = normalizePort(rawPort, -1);
|
||||
if (port < 1024) {
|
||||
throw new Error(`${agcAdminWebPortEnvKey} 必须是 1024-65535 的有效端口`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function createAgcAdminWebEndpoint(port, portRange = null) {
|
||||
const origin = `http://${agcAdminWebHost}:${port}`;
|
||||
return {
|
||||
host: agcAdminWebHost,
|
||||
port,
|
||||
origin,
|
||||
basePath: '/admin/',
|
||||
url: `${origin}/admin/`,
|
||||
portRange,
|
||||
};
|
||||
}
|
||||
|
||||
// AGC 开发态的后台 Web 与 `npm run dev` 的后台 Vite 共用同一套优先端口约定:
|
||||
// Linux 取当前用户端口段的 `start + 3` 槽位,非 Linux 保留 `3102` 兼容首选并允许统一漂移。
|
||||
async function resolveAgcAdminWebEndpoint({
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
strictConfigured = false,
|
||||
reservedPorts = [],
|
||||
reservePortRange = reserveLinuxDevPortRange,
|
||||
findPort = findAvailablePort,
|
||||
} = {}) {
|
||||
const configuredPort = readConfiguredAgcAdminWebPort(env);
|
||||
let portRange = null;
|
||||
let preferredPort = configuredPort ?? legacyAgcAdminWebPort;
|
||||
|
||||
if (platform === 'linux') {
|
||||
const allocation = await reservePortRange({ env });
|
||||
if (!allocation?.range) {
|
||||
throw new Error('无法取得当前 Linux 用户的 dev 端口段');
|
||||
}
|
||||
portRange = allocation.range;
|
||||
const mappedAdminWebPort = mapDevPortsToPortRange(portRange)?.adminWebPort;
|
||||
if (!Number.isInteger(mappedAdminWebPort)) {
|
||||
throw new Error(
|
||||
`当前 Linux dev 端口段 ${portRange.label} 缺少后台 Web 槽位;请先迁移为至少 6 个端口且不与其它用户重叠的端口段`,
|
||||
);
|
||||
}
|
||||
preferredPort = configuredPort ?? mappedAdminWebPort;
|
||||
}
|
||||
|
||||
const reservedPortSet = new Set(
|
||||
reservedPorts.filter((value) => Number.isInteger(value) && value > 0),
|
||||
);
|
||||
const port = await findPort({
|
||||
host: agcAdminWebHost,
|
||||
preferredPort,
|
||||
portRange,
|
||||
reservedPorts: reservedPortSet,
|
||||
strict: strictConfigured && configuredPort != null,
|
||||
});
|
||||
console.log(
|
||||
formatPortDecision({
|
||||
name: 'ai-game-creator-shell-admin-web',
|
||||
host: agcAdminWebHost,
|
||||
preferredPort,
|
||||
resolvedPort: port,
|
||||
}),
|
||||
);
|
||||
if (portRange) {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] admin-web port-range: ${portRange.label}`,
|
||||
);
|
||||
}
|
||||
|
||||
return createAgcAdminWebEndpoint(port, portRange);
|
||||
}
|
||||
|
||||
export {
|
||||
agcAdminWebHost,
|
||||
agcAdminWebPortEnvKey,
|
||||
agcDevHost,
|
||||
agcVitePortEnvKey,
|
||||
createAgcAdminWebEndpoint,
|
||||
createAgcDevEndpoint,
|
||||
legacyAgcAdminWebPort,
|
||||
legacyAgcDevPort,
|
||||
readAgcDevEndpoint,
|
||||
readConfiguredAgcAdminWebPort,
|
||||
readConfiguredAgcDevPort,
|
||||
resolveAgcAdminWebEndpoint,
|
||||
resolveAgcDevEndpoint,
|
||||
withAgcDevEndpointEnv,
|
||||
};
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
|
||||
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
|
||||
*/
|
||||
const redactedCredential = '<redacted>';
|
||||
|
||||
export function readReleaseDryRun(env = process.env) {
|
||||
const value = env.AGC_RELEASE_DRY_RUN?.trim().toLowerCase();
|
||||
return value === '1' || value === 'true';
|
||||
}
|
||||
|
||||
function quoteArgument(value) {
|
||||
return /[\s"']/u.test(value) ? JSON.stringify(value) : value;
|
||||
}
|
||||
|
||||
export function formatOssutilCommand({ binary, args, endpoint, credentials }) {
|
||||
const parts = [binary, ...args, '--endpoint', endpoint];
|
||||
if (credentials) {
|
||||
parts.push(
|
||||
'--access-key-id',
|
||||
redactedCredential,
|
||||
'--access-key-secret',
|
||||
redactedCredential,
|
||||
);
|
||||
}
|
||||
return parts.map(quoteArgument).join(' ');
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
||||
|
||||
test('dry run only accepts explicit truthy values', () => {
|
||||
assert.equal(readReleaseDryRun({}), false);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '1' }), true);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: ' true ' }), true);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '0' }), false);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '' }), false);
|
||||
});
|
||||
|
||||
test('printed upload command keeps arguments and hides credentials', () => {
|
||||
const command = formatOssutilCommand({
|
||||
binary: 'ossutil',
|
||||
args: [
|
||||
'cp',
|
||||
'--force',
|
||||
'陶泥儿 0.1.48.exe',
|
||||
'oss://agc-dev/agc/dev-win/x.exe',
|
||||
],
|
||||
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||
credentials: true,
|
||||
});
|
||||
assert.match(command, /^ossutil cp --force /u);
|
||||
assert.match(command, /"陶泥儿 0\.1\.48\.exe"/u);
|
||||
assert.match(command, /oss:\/\/agc-dev\/agc\/dev-win\/x\.exe/u);
|
||||
assert.match(
|
||||
command,
|
||||
/--access-key-id <redacted> --access-key-secret <redacted>/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('uploader gates every ossutil call behind the dry run switch', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
|
||||
assert.match(source, /if \(dryRun\) \{/u);
|
||||
assert.match(source, /dry-run:未写入任何 OSS 对象/u);
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
||||
|
||||
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
||||
const endpoint =
|
||||
process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com';
|
||||
@@ -10,7 +8,6 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) {
|
||||
throw new Error('OSS bucket 或 endpoint 配置无效');
|
||||
}
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
|
||||
const dryRun = readReleaseDryRun();
|
||||
|
||||
const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } =
|
||||
await import('./build-release.mjs');
|
||||
@@ -22,18 +19,6 @@ function runOssutil(args) {
|
||||
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
|
||||
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
|
||||
}
|
||||
if (dryRun) {
|
||||
// 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。
|
||||
console.log(
|
||||
`[dry-run] ${formatOssutilCommand({
|
||||
binary,
|
||||
args,
|
||||
endpoint,
|
||||
credentials: Boolean(accessKeyId),
|
||||
})}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const credentialArgs = accessKeyId
|
||||
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
|
||||
: [];
|
||||
@@ -53,40 +38,11 @@ function runOssutil(args) {
|
||||
|
||||
await prepareReleaseVersion();
|
||||
runTauriBuild([]);
|
||||
const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
|
||||
await generateUpdateManifest();
|
||||
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
|
||||
const { artifact, manifestPath, manifest } = generateUpdateManifest();
|
||||
const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`;
|
||||
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
|
||||
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
|
||||
runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]);
|
||||
runOssutil([
|
||||
'cp',
|
||||
'--force',
|
||||
`${artifact}.sig`,
|
||||
`oss://${bucket}/${artifactKey}.sig`,
|
||||
]);
|
||||
runOssutil([
|
||||
'cp',
|
||||
'--force',
|
||||
manifestPath,
|
||||
`oss://${bucket}/agc/${channel}/latest.json`,
|
||||
]);
|
||||
runOssutil(['cp', '--force', manifestPath, `oss://${bucket}/agc/latest.json`]);
|
||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`,
|
||||
);
|
||||
if (legacyManifestPath) {
|
||||
// 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。
|
||||
runOssutil([
|
||||
'cp',
|
||||
'--force',
|
||||
legacyManifestPath,
|
||||
`oss://${bucket}/agc/latest.json`,
|
||||
]);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`,
|
||||
);
|
||||
}
|
||||
if (dryRun) {
|
||||
console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
|
||||
}
|
||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/latest.json`);
|
||||
|
||||
@@ -3,7 +3,6 @@ import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { withAgcDevFeatureFlags } from './dev-feature-flags.mjs';
|
||||
import { resolveAgcDevEndpoint, withAgcDevEndpointEnv } from './dev-port.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -105,7 +104,7 @@ const child = spawn(
|
||||
],
|
||||
{
|
||||
cwd: appRoot,
|
||||
env: withAgcDevFeatureFlags(withAgcDevEndpointEnv(endpoint)),
|
||||
env: withAgcDevEndpointEnv(endpoint),
|
||||
stdio: 'inherit',
|
||||
// Node 18.20+/20+/24 on Windows rejects spawning .cmd (npm.cmd) without a shell (EINVAL).
|
||||
shell: true,
|
||||
|
||||
@@ -11,18 +11,15 @@ import {
|
||||
stopWindowsProcessTree,
|
||||
stopWindowsWorktreeProcesses,
|
||||
} from '../../../scripts/dev-windows-process.mjs';
|
||||
import { withAgcDevFeatureFlags } from './dev-feature-flags.mjs';
|
||||
import {
|
||||
agcVitePortEnvKey,
|
||||
readAgcDevEndpoint,
|
||||
resolveAgcAdminWebEndpoint,
|
||||
resolveAgcDevEndpoint,
|
||||
withAgcDevEndpointEnv,
|
||||
} from './dev-port.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = resolve(appRoot, '../..');
|
||||
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
||||
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
|
||||
const apiServerExePath = resolve(
|
||||
repoRoot,
|
||||
@@ -35,8 +32,6 @@ const backendSpacetimeDataDir = resolve(
|
||||
repoRoot,
|
||||
'server-rs/.spacetimedb/ai-game-creator/data',
|
||||
);
|
||||
// 后台 Web 默认跟随 AGC 一起起来,便于联调后台页面;`AGC_DEV_ADMIN_WEB=0` 可关闭。
|
||||
const agcDevAdminWebEnvKey = 'AGC_DEV_ADMIN_WEB';
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const childLifecycles = new WeakMap();
|
||||
|
||||
@@ -205,122 +200,36 @@ function urlPort(url) {
|
||||
}
|
||||
}
|
||||
|
||||
// 端口归属探测脚本。历史实现用 `Get-NetTCPConnection` 取监听进程,而它底层走
|
||||
// WMI:实测单端口单次 11.2 秒、再叠加每个 PID 的 `Get-CimInstance` 3.3 秒,
|
||||
// 一轮探测约 43 秒,直接把"配套后端就绪"等待拖到分钟级。改用原生
|
||||
// `netstat -ano`(约 30 毫秒)取端口 -> PID,再用 .NET `Process` 读进程名和
|
||||
// 可执行文件路径(毫秒级);只有核对 SpacetimeDB `--data-dir` 归属时才按 PID
|
||||
// 取命令行,并允许调用方把已知命令行传进来复用。
|
||||
const windowsPortOwnerProbeCommand = [
|
||||
'$ErrorActionPreference = "SilentlyContinue"',
|
||||
'$queriedPorts = @()',
|
||||
'foreach ($raw in ($env:GENARRATIVE_QUERY_PORTS -split ",")) {',
|
||||
' if ($raw -match "^\\d+$") { $queriedPorts += [int]$raw }',
|
||||
'}',
|
||||
'$knownCommandLines = @{}',
|
||||
'if ($env:GENARRATIVE_KNOWN_COMMAND_LINES) {',
|
||||
' try {',
|
||||
' foreach ($property in (ConvertFrom-Json $env:GENARRATIVE_KNOWN_COMMAND_LINES).PSObject.Properties) {',
|
||||
' $knownCommandLines[[int]$property.Name] = [string]$property.Value',
|
||||
' }',
|
||||
' } catch { }',
|
||||
'}',
|
||||
'$listenerPidByPort = @{}',
|
||||
'foreach ($line in (netstat -ano -p tcp)) {',
|
||||
' $fields = @($line -split "\\s+" | Where-Object { $_ })',
|
||||
' if ($fields.Count -lt 4) { continue }',
|
||||
' if ($fields[0] -ne "TCP") { continue }',
|
||||
' # A listening socket always has foreign address 0.0.0.0:0 / [::]:0, which',
|
||||
' # is locale-independent unlike the localized netstat State column.',
|
||||
' if ($fields[2] -notmatch ":0$") { continue }',
|
||||
' $localPort = [int]($fields[1].Split(":")[-1])',
|
||||
' if ($queriedPorts -notcontains $localPort) { continue }',
|
||||
' # The PID is the last column; do not hardcode its index.',
|
||||
' if ($fields[-1] -notmatch "^\\d+$") { continue }',
|
||||
' $listenerPidByPort[$localPort] = [int]$fields[-1]',
|
||||
'}',
|
||||
'$result = @()',
|
||||
'foreach ($port in ($listenerPidByPort.Keys | Sort-Object)) {',
|
||||
' $processId = $listenerPidByPort[$port]',
|
||||
' $name = $null',
|
||||
' $executablePath = $null',
|
||||
' $commandLine = $null',
|
||||
' try {',
|
||||
' $process = [System.Diagnostics.Process]::GetProcessById($processId)',
|
||||
' $name = $process.ProcessName + ".exe"',
|
||||
' try { $executablePath = $process.MainModule.FileName } catch { }',
|
||||
' } catch { }',
|
||||
' if ($knownCommandLines.ContainsKey($processId)) {',
|
||||
' $commandLine = $knownCommandLines[$processId]',
|
||||
' } elseif (($name -like "spacetime*") -or (-not $executablePath)) {',
|
||||
' try { $commandLine = (Get-CimInstance Win32_Process -Filter ("ProcessId=" + $processId)).CommandLine } catch { }',
|
||||
' }',
|
||||
' $result += [pscustomobject]@{ port = [int]$port; processId = $processId; name = $name; executablePath = $executablePath; commandLine = $commandLine }',
|
||||
'}',
|
||||
'ConvertTo-Json -InputObject @($result) -Compress',
|
||||
].join('\n');
|
||||
|
||||
// 进程命令行在进程生命周期内不变,但 PID 会被系统复用;按 PID 记 TTL 缓存,
|
||||
// 让"等配套后端就绪"的轮询只在首个周期付出 WMI 成本。TTL 取 5 分钟:本轮实测
|
||||
// 这台机器上首次 WMI 调用约 18 秒(热调用 3.3 秒),而 PID 在 5 分钟内被复用
|
||||
// 成另一个运行本工作树 data dir 的 SpacetimeDB 才能造成误判,概率可忽略。
|
||||
// 默认实现才缓存,注入实现(测试)与显式 env 始终重新读取。
|
||||
const WINDOWS_COMMAND_LINE_CACHE_TTL_MS = 300_000;
|
||||
const windowsPortOwnerCommandLineCache = new Map();
|
||||
|
||||
function resolveCommandLineCache({ spawnImpl, env }) {
|
||||
return spawnImpl === spawnSync && env === process.env
|
||||
? windowsPortOwnerCommandLineCache
|
||||
: new Map();
|
||||
}
|
||||
|
||||
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如系统缺少
|
||||
// netstat),此时调用方必须退化为旧行为,不能让本地启动直接失败。
|
||||
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少
|
||||
// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。
|
||||
function readWindowsPortOwnerIdentities(
|
||||
ports,
|
||||
{
|
||||
spawnImpl = spawnSync,
|
||||
env = process.env,
|
||||
now = Date.now,
|
||||
commandLineTtlMs = WINDOWS_COMMAND_LINE_CACHE_TTL_MS,
|
||||
commandLineCache = resolveCommandLineCache({ spawnImpl, env }),
|
||||
} = {},
|
||||
{ spawnImpl = spawnSync, env = process.env } = {},
|
||||
) {
|
||||
const uniquePorts = [...new Set(ports.filter((port) => port > 0))];
|
||||
if (uniquePorts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const knownCommandLines = {};
|
||||
for (const [processId, record] of [...commandLineCache]) {
|
||||
if (record && now() - record.at < commandLineTtlMs) {
|
||||
knownCommandLines[processId] = record.commandLine;
|
||||
} else {
|
||||
commandLineCache.delete(processId);
|
||||
}
|
||||
}
|
||||
|
||||
const childEnv = {
|
||||
...env,
|
||||
GENARRATIVE_QUERY_PORTS: uniquePorts.join(','),
|
||||
};
|
||||
if (Object.keys(knownCommandLines).length > 0) {
|
||||
childEnv.GENARRATIVE_KNOWN_COMMAND_LINES =
|
||||
JSON.stringify(knownCommandLines);
|
||||
}
|
||||
const command = [
|
||||
'$ErrorActionPreference = "SilentlyContinue"',
|
||||
'$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }',
|
||||
'$result = @()',
|
||||
'foreach ($port in $ports) {',
|
||||
' $connection = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue | Select-Object -First 1',
|
||||
' if (-not $connection) { continue }',
|
||||
' $owner = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $connection.OwningProcess) -ErrorAction SilentlyContinue',
|
||||
' $result += [pscustomobject]@{ port = [int]$port; processId = [int]$connection.OwningProcess; name = $owner.Name; executablePath = $owner.ExecutablePath; commandLine = $owner.CommandLine }',
|
||||
'}',
|
||||
'ConvertTo-Json -InputObject @($result) -Compress',
|
||||
].join('\n');
|
||||
|
||||
const result = spawnImpl(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
windowsPortOwnerProbeCommand,
|
||||
],
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: childEnv,
|
||||
env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') },
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
@@ -331,22 +240,9 @@ function readWindowsPortOwnerIdentities(
|
||||
const owners = new Map();
|
||||
for (const entry of parseWindowsProcessSnapshot(result.stdout)) {
|
||||
const port = Number(entry?.port);
|
||||
if (!Number.isInteger(port) || port <= 0) {
|
||||
continue;
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
owners.set(port, entry);
|
||||
}
|
||||
const processId = Number(entry?.processId);
|
||||
if (
|
||||
Number.isInteger(processId) &&
|
||||
processId > 0 &&
|
||||
typeof entry?.commandLine === 'string' &&
|
||||
entry.commandLine
|
||||
) {
|
||||
commandLineCache.set(processId, {
|
||||
commandLine: entry.commandLine,
|
||||
at: now(),
|
||||
});
|
||||
}
|
||||
owners.set(port, entry);
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
@@ -979,109 +875,14 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
|
||||
'--port',
|
||||
String(endpoint.port),
|
||||
],
|
||||
{
|
||||
cwd: appRoot,
|
||||
env: withAgcDevFeatureFlags(withAgcDevEndpointEnv(endpoint)),
|
||||
},
|
||||
{ cwd: appRoot, env: withAgcDevEndpointEnv(endpoint) },
|
||||
);
|
||||
}
|
||||
|
||||
function readAdminWebEnabled(env = process.env) {
|
||||
return String(env[agcDevAdminWebEnvKey] ?? '').trim() !== '0';
|
||||
}
|
||||
|
||||
// 后台 Web 与 AGC Vite 一样直接由本启动器持有,不经过 `dev.mjs admin-web`:
|
||||
// 后者会整体重写 `.app/dev-stack.json`,把本次配套后端的状态覆盖掉。
|
||||
function startAdminWeb(
|
||||
apiUrl,
|
||||
endpoint,
|
||||
{ env = process.env, spawnImpl = spawnChild } = {},
|
||||
) {
|
||||
return spawnImpl(
|
||||
npm,
|
||||
[
|
||||
'--prefix',
|
||||
'../..',
|
||||
'exec',
|
||||
'vite',
|
||||
'--',
|
||||
'--host',
|
||||
endpoint.host,
|
||||
'--port',
|
||||
String(endpoint.port),
|
||||
'--strictPort',
|
||||
],
|
||||
{
|
||||
cwd: adminWebDir,
|
||||
env: {
|
||||
...env,
|
||||
ADMIN_API_TARGET: apiUrl,
|
||||
GENARRATIVE_API_TARGET: apiUrl,
|
||||
GENARRATIVE_API_PORT: String(urlPort(apiUrl) || 8082),
|
||||
ADMIN_WEB_BASE: endpoint.basePath,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function formatStartupSummary({
|
||||
frontendUrl = '',
|
||||
apiUrl = '',
|
||||
adminWebUrl = '',
|
||||
spacetimeUrl = '',
|
||||
bgfilterWorkerUrl = '',
|
||||
} = {}) {
|
||||
const segments = [
|
||||
['前端', frontendUrl],
|
||||
['后端', apiUrl],
|
||||
['后台', adminWebUrl],
|
||||
['数据库', spacetimeUrl],
|
||||
['bgfilter-worker', bgfilterWorkerUrl],
|
||||
]
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([label, value]) => `${label} ${value}`);
|
||||
return `[ai-game-creator-shell] 启动汇总: ${segments.join(' | ')}`;
|
||||
}
|
||||
|
||||
// 后台 Web 是可选联调服务:端口解析或启动失败只告警,不能阻断 AGC 客户端与配套后端。
|
||||
async function ensureAdminWeb({
|
||||
apiUrl,
|
||||
reservedPorts = [],
|
||||
env = process.env,
|
||||
enabled = readAdminWebEnabled(env),
|
||||
resolveEndpoint = resolveAgcAdminWebEndpoint,
|
||||
spawnAdminWeb = startAdminWeb,
|
||||
waitForExit = waitForChildTermination,
|
||||
warn = (message) => console.warn(message),
|
||||
} = {}) {
|
||||
if (!enabled) {
|
||||
return { endpoint: null, child: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = await resolveEndpoint({ env, reservedPorts });
|
||||
const child = spawnAdminWeb(apiUrl, endpoint, { env });
|
||||
waitForExit(child).then((failure) => {
|
||||
warn(
|
||||
`[ai-game-creator-shell] 后台 Web 已退出(${formatChildFailure(failure)}),AGC 继续运行。`,
|
||||
);
|
||||
});
|
||||
return { endpoint, child };
|
||||
} catch (error) {
|
||||
warn(
|
||||
`[ai-game-creator-shell] 后台 Web 未能启动(${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}),AGC 继续运行。`,
|
||||
);
|
||||
return { endpoint: null, child: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let backendChild = null;
|
||||
let startedBackend = false;
|
||||
let viteChild = null;
|
||||
let adminWebChild = null;
|
||||
let shutdownSignal = '';
|
||||
const signalHandlers = new Map();
|
||||
|
||||
@@ -1104,7 +905,6 @@ async function main() {
|
||||
const handler = () => {
|
||||
shutdownSignal = signal;
|
||||
stopChild(viteChild, signal);
|
||||
stopChild(adminWebChild, signal);
|
||||
stopChild(backendChild, signal);
|
||||
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
|
||||
sweepStartedBackend();
|
||||
@@ -1137,25 +937,6 @@ async function main() {
|
||||
throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`);
|
||||
}
|
||||
|
||||
const adminWeb = await ensureAdminWeb({
|
||||
apiUrl: backend.targets.apiUrl,
|
||||
// AGC Vite 端口尚未监听,必须显式保留,避免被后台 Web 抢先占用。
|
||||
reservedPorts: [endpoint.port],
|
||||
});
|
||||
adminWebChild = adminWeb.child;
|
||||
if (shutdownSignal) {
|
||||
throw new Error(`启动期收到 ${shutdownSignal},已停止后台 Web`);
|
||||
}
|
||||
console.log(
|
||||
formatStartupSummary({
|
||||
frontendUrl: endpoint.url,
|
||||
apiUrl: backend.targets.apiUrl,
|
||||
adminWebUrl: adminWeb.endpoint?.url ?? '',
|
||||
spacetimeUrl: backend.targets.spacetimeUrl,
|
||||
bgfilterWorkerUrl: backend.targets.bgfilterWorkerUrl,
|
||||
}),
|
||||
);
|
||||
|
||||
const children = [backendChild, viteChild].filter(Boolean);
|
||||
if (children.length === 0) {
|
||||
return 0;
|
||||
@@ -1165,12 +946,10 @@ async function main() {
|
||||
children.map((child) => waitForChildTermination(child)),
|
||||
);
|
||||
stopChild(viteChild);
|
||||
stopChild(adminWebChild);
|
||||
stopChild(backendChild);
|
||||
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
|
||||
} catch (error) {
|
||||
stopChild(viteChild);
|
||||
stopChild(adminWebChild);
|
||||
stopChild(backendChild);
|
||||
console.error(
|
||||
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -1179,7 +958,6 @@ async function main() {
|
||||
} finally {
|
||||
await Promise.all([
|
||||
terminateChildTree(viteChild),
|
||||
terminateChildTree(adminWebChild),
|
||||
terminateChildTree(backendChild),
|
||||
]);
|
||||
sweepStartedBackend();
|
||||
@@ -1197,12 +975,9 @@ function isDirectModuleExecution() {
|
||||
}
|
||||
|
||||
export {
|
||||
agcDevAdminWebEnvKey,
|
||||
ensureAdminWeb,
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
formatOwnerLabel,
|
||||
formatStartupSummary,
|
||||
isAiGameCreatorServer,
|
||||
isBackendReady,
|
||||
isDirectModuleExecution,
|
||||
@@ -1210,7 +985,6 @@ export {
|
||||
isWorktreeApiServerOwner,
|
||||
isWorktreeSpacetimeOwner,
|
||||
preflightExistingVite,
|
||||
readAdminWebEnabled,
|
||||
readBackendServiceFailure,
|
||||
readChildFailure,
|
||||
readExistingViteServer,
|
||||
@@ -1219,7 +993,6 @@ export {
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
startAdminWeb,
|
||||
stopChild,
|
||||
terminateChildTree,
|
||||
verifyAgcBackendOwnership,
|
||||
|
||||
@@ -22,6 +22,7 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = resolve(appRoot, '../..');
|
||||
const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
|
||||
const AGC_DESIGN_DEBUG_ENV = 'GENARRATIVE_AGC_DESIGN_DEBUG';
|
||||
const AGC_DESIGN_DEBUG_VITE_ENV = 'VITE_GENARRATIVE_AGC_DESIGN_DEBUG';
|
||||
const designDebugEnabled =
|
||||
process.env[AGC_DESIGN_DEBUG_ENV]?.trim() === '0' ? '0' : '1';
|
||||
|
||||
@@ -136,6 +137,7 @@ async function runTauriDev(
|
||||
env: {
|
||||
...withAgcDevEndpointEnv(endpoint),
|
||||
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
|
||||
[AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled,
|
||||
},
|
||||
});
|
||||
const childResult = waitForCli(child);
|
||||
@@ -189,6 +191,7 @@ async function prepareFrontendDev(endpoint, { onChild, signal }) {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...withAgcDevEndpointEnv(endpoint),
|
||||
[AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createServer, loadConfigFromFile, normalizePath } from 'vite';
|
||||
|
||||
test(
|
||||
'AGC 排除 Rust 构建目录且保留源码与共享组件监听',
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const loaded = await loadConfigFromFile(
|
||||
{ command: 'serve', mode: 'development' },
|
||||
fileURLToPath(new URL('../vite.config.ts', import.meta.url)),
|
||||
);
|
||||
assert.ok(loaded);
|
||||
assert.notEqual(loaded.config.server?.watch, null);
|
||||
assert.notEqual(loaded.config.server?.hmr, false);
|
||||
assert.ok(
|
||||
[loaded.config.server?.watch?.ignored]
|
||||
.flat()
|
||||
.includes('**/src-tauri/target/**'),
|
||||
);
|
||||
|
||||
const fixture = await mkdtemp(join(tmpdir(), 'agc-vite-watch-'));
|
||||
const root = join(fixture, 'apps', 'ai-game-creator-shell');
|
||||
const source = join(root, 'src', 'main.js');
|
||||
const css = join(root, 'src', 'styles.css');
|
||||
const shared = join(fixture, 'packages', 'shared', 'src', 'component.js');
|
||||
const target = join(root, 'src-tauri', 'target');
|
||||
const artifact = join(target, 'debug', 'incremental', 'cache.bin');
|
||||
let server;
|
||||
try {
|
||||
for (const file of [source, css, shared, artifact]) {
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(
|
||||
file,
|
||||
file === css ? 'body { color: red; }' : 'export default 1;',
|
||||
);
|
||||
}
|
||||
// 使用真实 Vite watcher 和实际配置,仅将扫描根替换为小型夹具;
|
||||
// 不加载业务插件、后端或原生窗口,也不扫描开发机上的大型 target。
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
root,
|
||||
logLevel: 'silent',
|
||||
server: {
|
||||
watch: loaded.config.server?.watch,
|
||||
middlewareMode: true,
|
||||
hmr: false,
|
||||
fs: { allow: [fixture] },
|
||||
},
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
});
|
||||
const waitForWatchedFile = async (file) => {
|
||||
const normalized = normalizePath(file);
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (
|
||||
Object.entries(server.watcher.getWatched()).some(
|
||||
([directory, names]) =>
|
||||
names.some(
|
||||
(name) => normalizePath(join(directory, name)) === normalized,
|
||||
),
|
||||
)
|
||||
)
|
||||
return;
|
||||
await delay(50);
|
||||
}
|
||||
assert.fail(`源码必须仍被监听:${normalized}`);
|
||||
};
|
||||
await waitForWatchedFile(source);
|
||||
|
||||
// 真实模块转换应将 root 外的共享源码加入监听。
|
||||
await server.transformRequest(`/@fs/${normalizePath(shared)}`);
|
||||
for (const file of [source, css, shared]) {
|
||||
const normalized = normalizePath(file);
|
||||
await waitForWatchedFile(file);
|
||||
const changed = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
server.watcher.off('change', onChange);
|
||||
reject(new Error(`未收到源码变更:${normalized}`));
|
||||
}, 5_000);
|
||||
function onChange(path) {
|
||||
if (normalizePath(path) !== normalized) return;
|
||||
clearTimeout(timer);
|
||||
server.watcher.off('change', onChange);
|
||||
resolve();
|
||||
}
|
||||
server.watcher.on('change', onChange);
|
||||
});
|
||||
await writeFile(
|
||||
file,
|
||||
file === css ? 'body { color: blue; }' : 'export default 2;',
|
||||
);
|
||||
await changed;
|
||||
}
|
||||
const targetPath = normalizePath(target);
|
||||
const targetDirectories = Object.keys(server.watcher.getWatched())
|
||||
.map(normalizePath)
|
||||
.filter(
|
||||
(path) => path === targetPath || path.startsWith(`${targetPath}/`),
|
||||
);
|
||||
assert.deepEqual(targetDirectories, [], 'Rust target 不应创建目录监听器');
|
||||
} finally {
|
||||
await server?.close();
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user