Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1fa973d25 | |||
| 9fa05eccaf | |||
| 709ef6256c | |||
| 37d45d2de8 | |||
| 6b13d1d613 | |||
| d5eca8cce6 |
@@ -102,7 +102,7 @@ Use OpenAPI as the final authority; these common values are a routing aid:
|
|||||||
|
|
||||||
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
|
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
|
||||||
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
|
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
|
||||||
- Image `model`: `gpt-image-2.5`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. Persisted `gpt-image-2` is a legacy value resolved only when submitting a new task; the retired `gpt-image-2-c` is no longer accepted and is handled as an unsupported value.
|
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
|
||||||
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
||||||
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
||||||
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
|
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: gpt-image-2-apimart
|
name: gpt-image-2-apimart
|
||||||
description: Generate or inspect project image assets through this repository's Tiantoken GPT Image 2.5 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow with gpt-image-2-c fallback. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
||||||
---
|
---
|
||||||
|
|
||||||
# GPT Image 2.5 project image workflow
|
# gpt-image-2 VectorEngine
|
||||||
|
|
||||||
Use this skill for project-local image asset generation that must match the repository's image request contract. Provider routing is owned by `server-rs`; when this skill talks to the provider directly it must send the concrete provider model and the matching provider credentials, because the provider side only accepts concrete models:
|
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine image path. Keep the product/price model identifier and primary provider request as `gpt-image-2`, then fall back once to `gpt-image-2-c` for eligible provider failures. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||||
|
|
||||||
- generation (no reference images): `gpt-image-2.5-flare-c` through Tiantoken
|
|
||||||
- edits (any reference image): `gpt-image-2.5-sunburst-c` through Tiantoken
|
|
||||||
- nanobanana (`gemini-3.1-flash-image-preview`) stays on VectorEngine
|
|
||||||
|
|
||||||
The business model name `gpt-image-2.5` is resolved to a concrete key by `server-rs` at the task boundary and is never sent to a provider directly. This client must not perform a cross-model fallback; the only retry the bundled scripts do is a same-model retry on transient upstream statuses (`408` / `429` / `5xx`, 1s/2s backoff, at most 3 attempts), which never changes the model. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -30,15 +24,15 @@ The business model name `gpt-image-2.5` is resolved to a concrete key by `server
|
|||||||
```
|
```
|
||||||
|
|
||||||
5. Save final project assets under `public/` or another explicitly requested workspace path.
|
5. Save final project assets under `public/` or another explicitly requested workspace path.
|
||||||
6. Never print `TIANTOKEN_API_KEY`. Report only whether configuration exists.
|
6. Never print `VECTOR_ENGINE_API_KEY`. Report only whether configuration exists.
|
||||||
|
|
||||||
## Request Contract
|
## Request Contract
|
||||||
|
|
||||||
The repository image path uses:
|
The repository image path uses:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST {TIANTOKEN_BASE_URL}/v1/images/generations
|
POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations
|
||||||
Authorization: Bearer {TIANTOKEN_API_KEY}
|
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -46,7 +40,7 @@ Default body:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-image-2.5-flare-c",
|
"model": "gpt-image-2",
|
||||||
"prompt": "<prompt>",
|
"prompt": "<prompt>",
|
||||||
"n": 1,
|
"n": 1,
|
||||||
"size": "1024x1024"
|
"size": "1024x1024"
|
||||||
@@ -56,22 +50,22 @@ Default body:
|
|||||||
For visual references, use the edit endpoint instead of the create endpoint:
|
For visual references, use the edit endpoint instead of the create endpoint:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST {TIANTOKEN_BASE_URL}/v1/images/edits
|
POST {VECTOR_ENGINE_BASE_URL}/v1/images/edits
|
||||||
Authorization: Bearer {TIANTOKEN_API_KEY}
|
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
|
||||||
Content-Type: multipart/form-data
|
Content-Type: multipart/form-data
|
||||||
```
|
```
|
||||||
|
|
||||||
Multipart fields:
|
Multipart fields:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
model=gpt-image-2.5-sunburst-c
|
model=gpt-image-2
|
||||||
prompt=<prompt>
|
prompt=<prompt>
|
||||||
n=1
|
n=1
|
||||||
size=1024x1024
|
size=1024x1024
|
||||||
image=@reference.png
|
image=@reference.png
|
||||||
```
|
```
|
||||||
|
|
||||||
In this repository, calls with no reference images use `POST /v1/images/generations` with `gpt-image-2.5-flare-c`; calls with any reference image use `POST /v1/images/edits` with `gpt-image-2.5-sunburst-c` and pass references as one or more `image` form parts. Server-side calls still submit the business model `gpt-image-2.5` and let `server-rs` resolve the concrete key; provider routing and retry policy remain server-owned, and pricing follows the same reference-image rule (generation with a reference image is charged at the edit tier). Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
|
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths prefer `gpt-image-2`; on an eligible upstream/model failure they retry with `gpt-image-2-c`. Do not fall back for authentication, local validation, request-budget exhaustion, uncertain send/connection failure, content-safety rejection, or a generated image URL download failure. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
|
||||||
|
|
||||||
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints.
|
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints.
|
||||||
|
|
||||||
@@ -81,14 +75,12 @@ Load environment values from process env first, then `.env.secrets.local`, `.env
|
|||||||
|
|
||||||
Required for live generation:
|
Required for live generation:
|
||||||
|
|
||||||
- `TIANTOKEN_BASE_URL`
|
- `VECTOR_ENGINE_BASE_URL`
|
||||||
- `TIANTOKEN_API_KEY`
|
- `VECTOR_ENGINE_API_KEY`
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
- `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS`
|
- `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`
|
||||||
|
|
||||||
`VECTOR_ENGINE_*` values no longer serve GPT Image 2.5: those credentials belong to VectorEngine, which only serves nanobanana. Do not fall back from `TIANTOKEN_*` to `VECTOR_ENGINE_*`.
|
|
||||||
|
|
||||||
If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat.
|
If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat.
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ const skillRoot = path.resolve(__dirname, '..');
|
|||||||
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
|
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
|
||||||
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
|
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
|
||||||
const defaultTimeoutMs = 1000000;
|
const defaultTimeoutMs = 1000000;
|
||||||
// GPT Image 2.5 生成任务只接受 concrete provider model;业务模型名 `gpt-image-2.5`
|
const preferredImageModel = 'gpt-image-2';
|
||||||
// 由 server-rs 在任务边界解析,脚本直连 provider 时必须自己给出 concrete key。
|
const fallbackImageModel = 'gpt-image-2-c';
|
||||||
const imageModel = 'gpt-image-2.5-flare-c';
|
|
||||||
|
|
||||||
const prompts = [
|
const prompts = [
|
||||||
{
|
{
|
||||||
@@ -102,18 +101,18 @@ function resolveEnv() {
|
|||||||
...process.env,
|
...process.env,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
|
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\/+$/u, ''),
|
.replace(/\/+$/u, ''),
|
||||||
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
|
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||||
timeoutMs: Number.parseInt(
|
timeoutMs: Number.parseInt(
|
||||||
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||||
10,
|
10,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTiantokenImagesGenerationUrl(baseUrl) {
|
function buildVectorEngineImagesGenerationUrl(baseUrl) {
|
||||||
return baseUrl.endsWith('/v1')
|
return baseUrl.endsWith('/v1')
|
||||||
? `${baseUrl}/images/generations`
|
? `${baseUrl}/images/generations`
|
||||||
: `${baseUrl}/v1/images/generations`;
|
: `${baseUrl}/v1/images/generations`;
|
||||||
@@ -223,15 +222,6 @@ function inferExtensionFromBytes(bytes) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tiantoken 偶发 408/429/5xx 时重试同一个 concrete model;跨模型回退仍由
|
|
||||||
// server-rs 负责,脚本不做任何模型替换。
|
|
||||||
const transientRetryMaxAttempts = 3;
|
|
||||||
const transientRetryBaseDelayMs = 1000;
|
|
||||||
|
|
||||||
function isTransientProviderStatus(status) {
|
|
||||||
return status === 408 || status === 429 || status >= 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson(url, options, timeoutMs) {
|
async function fetchJson(url, options, timeoutMs) {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||||
@@ -243,21 +233,22 @@ async function fetchJson(url, options, timeoutMs) {
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
`Tiantoken ${response.status}: ${text.slice(0, 600)}`,
|
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
|
||||||
);
|
);
|
||||||
error.providerStatus = response.status;
|
error.vectorEngineStatus = response.status;
|
||||||
|
error.vectorEngineBody = text;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
error.vectorEngineResponseParse = true;
|
||||||
`Tiantoken 返回无法解析的响应体:${error.message};响应片段:${text.slice(0, 600)}`,
|
error.vectorEngineBody = text;
|
||||||
);
|
throw error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === 'AbortError') {
|
if (error?.name === 'AbortError') {
|
||||||
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
|
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -265,36 +256,50 @@ async function fetchJson(url, options, timeoutMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJsonWithRetry(url, options, timeoutMs, requestId) {
|
function shouldFallbackImageModel(error) {
|
||||||
for (let attempt = 1; ; attempt += 1) {
|
const raw =
|
||||||
try {
|
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
||||||
return await fetchJson(url, options, timeoutMs);
|
if (error?.vectorEngineResponseParse) {
|
||||||
} catch (error) {
|
return !containsContentRejection(raw);
|
||||||
if (
|
|
||||||
attempt >= transientRetryMaxAttempts ||
|
|
||||||
!isTransientProviderStatus(error?.providerStatus)
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
const delayMs = transientRetryBaseDelayMs * 2 ** (attempt - 1);
|
const status = Number(error?.vectorEngineStatus || 0);
|
||||||
console.warn(
|
if (status === 408 || status >= 500) {
|
||||||
`Tiantoken ${error.providerStatus} on ${requestId}, retrying the same model in ${delayMs}ms (attempt ${attempt + 1}/${transientRetryMaxAttempts})`,
|
return true;
|
||||||
|
}
|
||||||
|
if (status === 429) {
|
||||||
|
return !containsContentRejection(raw);
|
||||||
|
}
|
||||||
|
const mentionsImageModel =
|
||||||
|
raw.includes('model') ||
|
||||||
|
raw.includes('模型') ||
|
||||||
|
raw.includes(preferredImageModel) ||
|
||||||
|
raw.includes(fallbackImageModel);
|
||||||
|
return (
|
||||||
|
[400, 404, 422].includes(status) &&
|
||||||
|
mentionsImageModel &&
|
||||||
|
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
|
||||||
|
raw,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function containsContentRejection(raw) {
|
||||||
|
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
|
||||||
|
raw,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestImagePayload(env, entry) {
|
async function requestImagePayload(env, entry) {
|
||||||
const model = imageModel;
|
for (const model of [preferredImageModel, fallbackImageModel]) {
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
model,
|
model,
|
||||||
prompt: buildPrompt(entry),
|
prompt: buildPrompt(entry),
|
||||||
n: 1,
|
n: 1,
|
||||||
size: '1024x1024',
|
size: '1024x1024',
|
||||||
};
|
};
|
||||||
const payload = await fetchJsonWithRetry(
|
try {
|
||||||
buildTiantokenImagesGenerationUrl(env.baseUrl),
|
const payload = await fetchJson(
|
||||||
|
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -305,16 +310,28 @@ async function requestImagePayload(env, entry) {
|
|||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
},
|
},
|
||||||
env.timeoutMs,
|
env.timeoutMs,
|
||||||
entry.id,
|
|
||||||
);
|
);
|
||||||
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
|
const base64Image = decodeStrictBase64Image(
|
||||||
|
extractBase64Images(payload)[0],
|
||||||
|
);
|
||||||
if (extractImageUrls(payload)[0] || base64Image) {
|
if (extractImageUrls(payload)[0] || base64Image) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
throw new Error(
|
const error = new Error(`VectorEngine returned no image for ${entry.id}`);
|
||||||
`Tiantoken returned no image for ${entry.id}:${JSON.stringify(payload).slice(0, 600)}`,
|
error.vectorEngineResponseParse = true;
|
||||||
|
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||||
|
throw error;
|
||||||
|
} catch (error) {
|
||||||
|
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`VectorEngine returned no image for ${entry.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadUrl(url, timeoutMs) {
|
async function downloadUrl(url, timeoutMs) {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -356,7 +373,7 @@ async function generateOne(env, entry, outDir) {
|
|||||||
const bytes = decodeStrictBase64Image(b64Images[0]);
|
const bytes = decodeStrictBase64Image(b64Images[0]);
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Tiantoken returned invalid base64 image for ${entry.id}`,
|
`VectorEngine returned invalid base64 image for ${entry.id}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
image = {
|
image = {
|
||||||
@@ -364,7 +381,7 @@ async function generateOne(env, entry, outDir) {
|
|||||||
extension: inferExtensionFromBytes(bytes),
|
extension: inferExtensionFromBytes(bytes),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Tiantoken returned no image for ${entry.id}`);
|
throw new Error(`VectorEngine returned no image for ${entry.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
mkdirSync(outDir, { recursive: true });
|
||||||
@@ -391,8 +408,9 @@ if (dryRun) {
|
|||||||
requests: selectedPrompts.map((entry) => ({
|
requests: selectedPrompts.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
|
fallbackModel: fallbackImageModel,
|
||||||
body: {
|
body: {
|
||||||
model: imageModel,
|
model: preferredImageModel,
|
||||||
prompt: buildPrompt(entry),
|
prompt: buildPrompt(entry),
|
||||||
n: 1,
|
n: 1,
|
||||||
size: '1024x1024',
|
size: '1024x1024',
|
||||||
@@ -411,7 +429,7 @@ if (!env.baseUrl || !env.apiKey) {
|
|||||||
console.error(
|
console.error(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
|
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||||||
hasBaseUrl: Boolean(env.baseUrl),
|
hasBaseUrl: Boolean(env.baseUrl),
|
||||||
hasApiKey: Boolean(env.apiKey),
|
hasApiKey: Boolean(env.apiKey),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ const defaultOutDir = path.join(
|
|||||||
'puzzle-creation-templates',
|
'puzzle-creation-templates',
|
||||||
);
|
);
|
||||||
const defaultTimeoutMs = 1000000;
|
const defaultTimeoutMs = 1000000;
|
||||||
// GPT Image 2.5 生成任务只接受 concrete provider model;业务模型名 `gpt-image-2.5`
|
const preferredImageModel = 'gpt-image-2';
|
||||||
// 由 server-rs 在任务边界解析,脚本直连 provider 时必须自己给出 concrete key。
|
const fallbackImageModel = 'gpt-image-2-c';
|
||||||
const imageModel = 'gpt-image-2.5-flare-c';
|
|
||||||
|
|
||||||
const args = new Map();
|
const args = new Map();
|
||||||
for (let index = 2; index < process.argv.length; index += 1) {
|
for (let index = 2; index < process.argv.length; index += 1) {
|
||||||
@@ -72,18 +71,18 @@ function resolveEnv() {
|
|||||||
...process.env,
|
...process.env,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
|
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\/+$/u, ''),
|
.replace(/\/+$/u, ''),
|
||||||
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
|
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||||
timeoutMs: Number.parseInt(
|
timeoutMs: Number.parseInt(
|
||||||
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||||
10,
|
10,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTiantokenImagesGenerationUrl(baseUrl) {
|
function buildVectorEngineImagesGenerationUrl(baseUrl) {
|
||||||
return baseUrl.endsWith('/v1')
|
return baseUrl.endsWith('/v1')
|
||||||
? `${baseUrl}/images/generations`
|
? `${baseUrl}/images/generations`
|
||||||
: `${baseUrl}/v1/images/generations`;
|
: `${baseUrl}/v1/images/generations`;
|
||||||
@@ -193,15 +192,6 @@ function inferExtensionFromBytes(bytes) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tiantoken 偶发 408/429/5xx 时重试同一个 concrete model;跨模型回退仍由
|
|
||||||
// server-rs 负责,脚本不做任何模型替换。
|
|
||||||
const transientRetryMaxAttempts = 3;
|
|
||||||
const transientRetryBaseDelayMs = 1000;
|
|
||||||
|
|
||||||
function isTransientProviderStatus(status) {
|
|
||||||
return status === 408 || status === 429 || status >= 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson(url, options, timeoutMs) {
|
async function fetchJson(url, options, timeoutMs) {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||||
@@ -213,21 +203,22 @@ async function fetchJson(url, options, timeoutMs) {
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
`Tiantoken ${response.status}: ${text.slice(0, 600)}`,
|
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
|
||||||
);
|
);
|
||||||
error.providerStatus = response.status;
|
error.vectorEngineStatus = response.status;
|
||||||
|
error.vectorEngineBody = text;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
error.vectorEngineResponseParse = true;
|
||||||
`Tiantoken 返回无法解析的响应体:${error.message};响应片段:${text.slice(0, 600)}`,
|
error.vectorEngineBody = text;
|
||||||
);
|
throw error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === 'AbortError') {
|
if (error?.name === 'AbortError') {
|
||||||
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
|
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -235,36 +226,50 @@ async function fetchJson(url, options, timeoutMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJsonWithRetry(url, options, timeoutMs, requestId) {
|
function shouldFallbackImageModel(error) {
|
||||||
for (let attempt = 1; ; attempt += 1) {
|
const raw =
|
||||||
try {
|
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
||||||
return await fetchJson(url, options, timeoutMs);
|
if (error?.vectorEngineResponseParse) {
|
||||||
} catch (error) {
|
return !containsContentRejection(raw);
|
||||||
if (
|
|
||||||
attempt >= transientRetryMaxAttempts ||
|
|
||||||
!isTransientProviderStatus(error?.providerStatus)
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
const delayMs = transientRetryBaseDelayMs * 2 ** (attempt - 1);
|
const status = Number(error?.vectorEngineStatus || 0);
|
||||||
console.warn(
|
if (status === 408 || status >= 500) {
|
||||||
`Tiantoken ${error.providerStatus} on ${requestId}, retrying the same model in ${delayMs}ms (attempt ${attempt + 1}/${transientRetryMaxAttempts})`,
|
return true;
|
||||||
|
}
|
||||||
|
if (status === 429) {
|
||||||
|
return !containsContentRejection(raw);
|
||||||
|
}
|
||||||
|
const mentionsImageModel =
|
||||||
|
raw.includes('model') ||
|
||||||
|
raw.includes('模型') ||
|
||||||
|
raw.includes(preferredImageModel) ||
|
||||||
|
raw.includes(fallbackImageModel);
|
||||||
|
return (
|
||||||
|
[400, 404, 422].includes(status) &&
|
||||||
|
mentionsImageModel &&
|
||||||
|
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
|
||||||
|
raw,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function containsContentRejection(raw) {
|
||||||
|
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
|
||||||
|
raw,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestImagePayload(env, template) {
|
async function requestImagePayload(env, template) {
|
||||||
const model = imageModel;
|
for (const model of [preferredImageModel, fallbackImageModel]) {
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
model,
|
model,
|
||||||
prompt: buildPrompt(template),
|
prompt: buildPrompt(template),
|
||||||
n: 1,
|
n: 1,
|
||||||
size: '1024x1024',
|
size: '1024x1024',
|
||||||
};
|
};
|
||||||
const payload = await fetchJsonWithRetry(
|
try {
|
||||||
buildTiantokenImagesGenerationUrl(env.baseUrl),
|
const payload = await fetchJson(
|
||||||
|
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -275,15 +280,29 @@ async function requestImagePayload(env, template) {
|
|||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
},
|
},
|
||||||
env.timeoutMs,
|
env.timeoutMs,
|
||||||
template.id,
|
|
||||||
);
|
);
|
||||||
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
|
const base64Image = decodeStrictBase64Image(
|
||||||
|
extractBase64Images(payload)[0],
|
||||||
|
);
|
||||||
if (extractImageUrls(payload)[0] || base64Image) {
|
if (extractImageUrls(payload)[0] || base64Image) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
throw new Error(
|
const error = new Error(
|
||||||
`Tiantoken returned no image for ${template.id}:${JSON.stringify(payload).slice(0, 600)}`,
|
`VectorEngine returned no image for ${template.id}`,
|
||||||
);
|
);
|
||||||
|
error.vectorEngineResponseParse = true;
|
||||||
|
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||||
|
throw error;
|
||||||
|
} catch (error) {
|
||||||
|
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`VectorEngine returned no image for ${template.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadUrl(url, timeoutMs) {
|
async function downloadUrl(url, timeoutMs) {
|
||||||
@@ -326,7 +345,7 @@ async function generateOne(env, template, outDir) {
|
|||||||
const bytes = decodeStrictBase64Image(b64Images[0]);
|
const bytes = decodeStrictBase64Image(b64Images[0]);
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Tiantoken returned invalid base64 image for ${template.id}`,
|
`VectorEngine returned invalid base64 image for ${template.id}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
image = {
|
image = {
|
||||||
@@ -334,7 +353,7 @@ async function generateOne(env, template, outDir) {
|
|||||||
extension: inferExtensionFromBytes(bytes),
|
extension: inferExtensionFromBytes(bytes),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Tiantoken returned no image for ${template.id}`);
|
throw new Error(`VectorEngine returned no image for ${template.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
mkdirSync(outDir, { recursive: true });
|
||||||
@@ -365,8 +384,9 @@ if (dryRun) {
|
|||||||
requests: selectedTemplates.map((template) => ({
|
requests: selectedTemplates.map((template) => ({
|
||||||
id: template.id,
|
id: template.id,
|
||||||
title: template.title,
|
title: template.title,
|
||||||
|
fallbackModel: fallbackImageModel,
|
||||||
body: {
|
body: {
|
||||||
model: imageModel,
|
model: preferredImageModel,
|
||||||
prompt: buildPrompt(template),
|
prompt: buildPrompt(template),
|
||||||
n: 1,
|
n: 1,
|
||||||
size: '1024x1024',
|
size: '1024x1024',
|
||||||
@@ -385,7 +405,7 @@ if (!env.baseUrl || !env.apiKey) {
|
|||||||
console.error(
|
console.error(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
|
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||||||
hasBaseUrl: Boolean(env.baseUrl),
|
hasBaseUrl: Boolean(env.baseUrl),
|
||||||
hasApiKey: Boolean(env.apiKey),
|
hasApiKey: Boolean(env.apiKey),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,5 +4,3 @@
|
|||||||
src/components/AdventurePanel.tsx
|
src/components/AdventurePanel.tsx
|
||||||
src/data/customWorldCharacterLoadout.ts
|
src/data/customWorldCharacterLoadout.ts
|
||||||
dist_check_monster_position/**
|
dist_check_monster_position/**
|
||||||
# 固定上游UTF-8测试刻意包含U+FFFD;upstream-integrity.test.mjs逐字节验证来源hash。
|
|
||||||
apps/ai-game-creator-shell/src-tauri/vendor/codex-utils-path-uri/src/api_path_string_tests.rs
|
|
||||||
|
|||||||
@@ -235,10 +235,6 @@ VITE_DEBUG_MODE=""
|
|||||||
# This is read by api-server and exposed through /api/runtime/frontend-config.
|
# This is read by api-server and exposed through /api/runtime/frontend-config.
|
||||||
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
||||||
|
|
||||||
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
|
|
||||||
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
|
||||||
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
|
||||||
|
|
||||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||||
# the fixed `bge-large-zh` embedding model.
|
# the fixed `bge-large-zh` embedding model.
|
||||||
|
|||||||
@@ -23,6 +23,3 @@
|
|||||||
*.meta text
|
*.meta text
|
||||||
*.anim text
|
*.anim text
|
||||||
*.controller text
|
*.controller text
|
||||||
|
|
||||||
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
|
|
||||||
packages/shared/src/contracts/generated/** linguist-generated=true
|
|
||||||
|
|||||||
+81
-104
@@ -31,26 +31,28 @@ env:
|
|||||||
RUSTC_WRAPPER: ''
|
RUSTC_WRAPPER: ''
|
||||||
CARGO_BUILD_RUSTC_WRAPPER: ''
|
CARGO_BUILD_RUSTC_WRAPPER: ''
|
||||||
|
|
||||||
# job 声明顺序就是 runner 领取顺序,因此把两条最长尾的客户端 Rust lane 排在前面,
|
# job 声明顺序就是 runner 领取顺序,因此把最长尾的客户端 Rust 门禁排在前面,
|
||||||
# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。
|
# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。
|
||||||
#
|
#
|
||||||
# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在
|
# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在
|
||||||
# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job:
|
# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job:
|
||||||
# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、
|
# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、
|
||||||
# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell
|
# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell
|
||||||
# Rust lane 1/2`、`lane 2/2`(每条 lane 顺序运行两片 AGC 壳 bin 单测)、`AI game creator
|
# Rust shard 1/4` 到 `4/4`(AGC 壳 bin 单测按名单分 4 片)、`AI game creator shell
|
||||||
# shell Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的
|
# Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的
|
||||||
# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地
|
# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地
|
||||||
# `npm run check:native-shells` 仍是同一条串行序列。
|
# `npm run check:native-shells` 仍是同一条串行序列。
|
||||||
#
|
#
|
||||||
# AGC 壳的 bin 单测(2466 条)仍按名单分 4 片、片内保持 `--test-threads=1`;两条 lane
|
# AGC 壳的 bin 单测(2466 条)按名单分 4 片、一片一个 job:片内保持 `--test-threads=1`
|
||||||
# 各自顺序运行两片,让一次 job 只做一次依赖预热,同时避免同一容器内多进程并行争抢
|
# (当年线程并行会互相干扰的是进程内后台锁与异步终态),片与片之间靠 job 级并发摊开。
|
||||||
# 共享 HOME、target 与固定临时路径。每个分片调用仍会自校验「片并集等于全集且互斥」。
|
# 不要改回「一个 job 里多进程并行这几片」:同一容器内它们会争抢共享 HOME、target 与固定
|
||||||
|
# 临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」。
|
||||||
jobs:
|
jobs:
|
||||||
# AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;这里不装 npm
|
# AGC 壳自身的 Rust bin 单测分片,4 片各自独立 job 并发执行、片内仍保持
|
||||||
# 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。
|
# `--test-threads=1`。这里不装 npm 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,
|
||||||
ai-game-creator-shell-rust-lane-1:
|
# 也只需要 AGC 壳自己那份锁定依赖。
|
||||||
name: AI game creator shell Rust lane 1/2
|
ai-game-creator-shell-rust-shard-1:
|
||||||
|
name: AI game creator shell Rust shard 1/4
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout full history from Gitea
|
- name: Checkout full history from Gitea
|
||||||
@@ -82,25 +84,11 @@ jobs:
|
|||||||
sleep $((attempt * 2))
|
sleep $((attempt * 2))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Prepare isolated Rust compilation cache
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
node --test scripts/ci-rust-cache.test.mjs
|
|
||||||
bash scripts/ci-rust-cache.sh prepare
|
|
||||||
|
|
||||||
- name: Run AI game creator shell Rust shard 1/4
|
- name: Run AI game creator shell Rust shard 1/4
|
||||||
run: npm run check:native-shells:agc-rust-shard-1
|
run: npm run check:native-shells:agc-rust-shard-1
|
||||||
|
|
||||||
- name: Run AI game creator shell Rust shard 2/4
|
ai-game-creator-shell-rust-shard-2:
|
||||||
run: npm run check:native-shells:agc-rust-shard-2
|
name: AI game creator shell Rust shard 2/4
|
||||||
|
|
||||||
- name: Report isolated Rust compilation cache
|
|
||||||
if: always()
|
|
||||||
run: bash scripts/ci-rust-cache.sh report
|
|
||||||
|
|
||||||
ai-game-creator-shell-rust-lane-2:
|
|
||||||
name: AI game creator shell Rust lane 2/2
|
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout full history from Gitea
|
- name: Checkout full history from Gitea
|
||||||
@@ -129,19 +117,75 @@ jobs:
|
|||||||
sleep $((attempt * 2))
|
sleep $((attempt * 2))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Prepare isolated Rust compilation cache
|
- name: Run AI game creator shell Rust shard 2/4
|
||||||
run: bash scripts/ci-rust-cache.sh prepare
|
run: npm run check:native-shells:agc-rust-shard-2
|
||||||
|
|
||||||
|
ai-game-creator-shell-rust-shard-3:
|
||||||
|
name: AI game creator shell Rust shard 3/4
|
||||||
|
runs-on: genarrative-ci
|
||||||
|
steps:
|
||||||
|
- name: Checkout full history from Gitea
|
||||||
|
env:
|
||||||
|
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
||||||
|
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||||
|
run: genarrative-gitea-checkout
|
||||||
|
|
||||||
|
- name: Validate preinstalled CI job image and sandbox
|
||||||
|
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||||
|
|
||||||
|
- name: Prepare AI game creator shell Rust dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in $(seq 1 5); do
|
||||||
|
if cargo fetch --locked \
|
||||||
|
--target x86_64-unknown-linux-gnu \
|
||||||
|
--manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "${attempt}" -eq 5 ]]; then
|
||||||
|
echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep $((attempt * 2))
|
||||||
|
done
|
||||||
|
|
||||||
- name: Run AI game creator shell Rust shard 3/4
|
- name: Run AI game creator shell Rust shard 3/4
|
||||||
run: npm run check:native-shells:agc-rust-shard-3
|
run: npm run check:native-shells:agc-rust-shard-3
|
||||||
|
|
||||||
|
ai-game-creator-shell-rust-shard-4:
|
||||||
|
name: AI game creator shell Rust shard 4/4
|
||||||
|
runs-on: genarrative-ci
|
||||||
|
steps:
|
||||||
|
- name: Checkout full history from Gitea
|
||||||
|
env:
|
||||||
|
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
||||||
|
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||||
|
run: genarrative-gitea-checkout
|
||||||
|
|
||||||
|
- name: Validate preinstalled CI job image and sandbox
|
||||||
|
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||||
|
|
||||||
|
- name: Prepare AI game creator shell Rust dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in $(seq 1 5); do
|
||||||
|
if cargo fetch --locked \
|
||||||
|
--target x86_64-unknown-linux-gnu \
|
||||||
|
--manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "${attempt}" -eq 5 ]]; then
|
||||||
|
echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep $((attempt * 2))
|
||||||
|
done
|
||||||
|
|
||||||
- name: Run AI game creator shell Rust shard 4/4
|
- name: Run AI game creator shell Rust shard 4/4
|
||||||
run: npm run check:native-shells:agc-rust-shard-4
|
run: npm run check:native-shells:agc-rust-shard-4
|
||||||
|
|
||||||
- name: Report isolated Rust compilation cache
|
|
||||||
if: always()
|
|
||||||
run: bash scripts/ci-rust-cache.sh report
|
|
||||||
|
|
||||||
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖,
|
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖,
|
||||||
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
|
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
|
||||||
ai-game-creator-shell-rust-smoke:
|
ai-game-creator-shell-rust-smoke:
|
||||||
@@ -174,17 +218,11 @@ jobs:
|
|||||||
sleep $((attempt * 2))
|
sleep $((attempt * 2))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Prepare isolated Rust compilation cache
|
|
||||||
run: bash scripts/ci-rust-cache.sh prepare
|
|
||||||
|
|
||||||
- name: Run AI game creator shell agent-run smoke
|
- name: Run AI game creator shell agent-run smoke
|
||||||
run: npm run check:native-shells:agc-rust-smoke
|
run: npm run check:native-shells:agc-rust-smoke
|
||||||
|
|
||||||
- name: Report isolated Rust compilation cache
|
# AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate
|
||||||
if: always()
|
# 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。
|
||||||
run: bash scripts/ci-rust-cache.sh report
|
|
||||||
|
|
||||||
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
|
|
||||||
ai-game-creator-shell-rust-crates:
|
ai-game-creator-shell-rust-crates:
|
||||||
name: AI game creator shell Rust crates
|
name: AI game creator shell Rust crates
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
@@ -225,15 +263,13 @@ jobs:
|
|||||||
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
|
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
|
||||||
# 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红
|
# 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红
|
||||||
# (见 #327 / PR #316 run 1950)。
|
# (见 #327 / PR #316 run 1950)。
|
||||||
# Cocos 插件也使用独立且未提交的锁文件,一并预热。
|
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
||||||
# 这些 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
|
||||||
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
||||||
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
||||||
# 解析,不再触碰 registry index。
|
# 解析,不再触碰 registry index。
|
||||||
for manifest_path in \
|
for manifest_path in \
|
||||||
server-rs/crates/agent-runtime-core/Cargo.toml \
|
server-rs/crates/agent-runtime-core/Cargo.toml \
|
||||||
server-rs/crates/agent-runtime-orchestration/Cargo.toml \
|
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
|
||||||
plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml; do
|
|
||||||
for attempt in $(seq 1 5); do
|
for attempt in $(seq 1 5); do
|
||||||
if cargo fetch \
|
if cargo fetch \
|
||||||
--target x86_64-unknown-linux-gnu \
|
--target x86_64-unknown-linux-gnu \
|
||||||
@@ -248,50 +284,9 @@ jobs:
|
|||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Prepare Unity plugin Rust dependencies
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for attempt in $(seq 1 5); do
|
|
||||||
if cargo fetch --locked \
|
|
||||||
--target x86_64-unknown-linux-gnu \
|
|
||||||
--manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [[ "${attempt}" -eq 5 ]]; then
|
|
||||||
echo 'Unity plugin Cargo dependency fetch failed after 5 attempts.' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep $((attempt * 2))
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Prepare Godot plugin Rust dependencies
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for attempt in $(seq 1 5); do
|
|
||||||
if cargo fetch --locked \
|
|
||||||
--target x86_64-unknown-linux-gnu \
|
|
||||||
--manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [[ "${attempt}" -eq 5 ]]; then
|
|
||||||
echo 'Godot plugin Cargo dependency fetch failed after 5 attempts.' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep $((attempt * 2))
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Prepare isolated Rust compilation cache
|
|
||||||
run: bash scripts/ci-rust-cache.sh prepare
|
|
||||||
|
|
||||||
- name: Run AI game creator shell shared crate gates
|
- name: Run AI game creator shell shared crate gates
|
||||||
run: npm run check:native-shells:agc-rust-crates
|
run: npm run check:native-shells:agc-rust-crates
|
||||||
|
|
||||||
- name: Report isolated Rust compilation cache
|
|
||||||
if: always()
|
|
||||||
run: bash scripts/ci-rust-cache.sh report
|
|
||||||
|
|
||||||
backend-tests:
|
backend-tests:
|
||||||
name: Backend tests
|
name: Backend tests
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
@@ -362,9 +357,6 @@ jobs:
|
|||||||
- name: Check server-rs boundaries
|
- name: Check server-rs boundaries
|
||||||
run: npm run check:server-rs-ddd
|
run: npm run check:server-rs-ddd
|
||||||
|
|
||||||
- name: Prepare isolated Rust compilation cache
|
|
||||||
run: bash scripts/ci-rust-cache.sh prepare
|
|
||||||
|
|
||||||
- name: Run server-rs workspace tests
|
- name: Run server-rs workspace tests
|
||||||
run: cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml
|
run: cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml
|
||||||
|
|
||||||
@@ -377,10 +369,6 @@ jobs:
|
|||||||
- name: Check SpacetimeDB module
|
- name: Check SpacetimeDB module
|
||||||
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
|
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
|
||||||
|
|
||||||
- name: Report isolated Rust compilation cache
|
|
||||||
if: always()
|
|
||||||
run: bash scripts/ci-rust-cache.sh report
|
|
||||||
|
|
||||||
# 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁,
|
# 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁,
|
||||||
# 以及依赖发布产物的构建 smoke。
|
# 以及依赖发布产物的构建 smoke。
|
||||||
native-shell-tests:
|
native-shell-tests:
|
||||||
@@ -423,26 +411,15 @@ jobs:
|
|||||||
- name: Run native shell contract gates
|
- name: Run native shell contract gates
|
||||||
run: npm run check:native-shells:contract
|
run: npm run check:native-shells:contract
|
||||||
|
|
||||||
- name: Prepare isolated Rust compilation cache
|
|
||||||
run: bash scripts/ci-rust-cache.sh prepare
|
|
||||||
|
|
||||||
- name: Run native shell gates
|
- name: Run native shell gates
|
||||||
run: npm run check:native-shells:shells
|
run: npm run check:native-shells:shells
|
||||||
|
|
||||||
- name: Run native shell release build smoke
|
- name: Run native shell release build smoke
|
||||||
# 发布构建有独立 profile/features 和资源 staging,不消费测试对象快照。
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: ''
|
|
||||||
CARGO_BUILD_RUSTC_WRAPPER: ''
|
|
||||||
run: npm run check:native-shells:release
|
run: npm run check:native-shells:release
|
||||||
|
|
||||||
- name: Ensure native lockfiles are unchanged
|
- name: Ensure native lockfiles are unchanged
|
||||||
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
|
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
|
||||||
|
|
||||||
- name: Report isolated Rust compilation cache
|
|
||||||
if: always()
|
|
||||||
run: bash scripts/ci-rust-cache.sh report
|
|
||||||
|
|
||||||
frontend-tests:
|
frontend-tests:
|
||||||
name: Frontend tests
|
name: Frontend tests
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
@@ -460,7 +437,7 @@ jobs:
|
|||||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||||
|
|
||||||
- name: Run frontend and script tests
|
- name: Run frontend and script tests
|
||||||
run: npm run test:ci:frontend
|
run: npm run test
|
||||||
|
|
||||||
- name: Run BgFilter worker smoke harness tests
|
- name: Run BgFilter worker smoke harness tests
|
||||||
run: npm run bgfilter-worker:smoke-test
|
run: npm run bgfilter-worker:smoke-test
|
||||||
|
|||||||
@@ -47,16 +47,8 @@ temp*build*/
|
|||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-arm64/
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-x64/
|
|
||||||
/plugins/agc-cocos-editor/native/payload/
|
/plugins/agc-cocos-editor/native/payload/
|
||||||
/plugins/agc-unity-editor/dotnet/**/bin/
|
|
||||||
/plugins/agc-unity-editor/dotnet/**/obj/
|
|
||||||
/plugins/agc-unity-editor/dotnet/publish/
|
|
||||||
/plugins/agc-unity-editor/dotnet/native-build/
|
|
||||||
/apps/ai-game-creator-shell/logs/
|
/apps/ai-game-creator-shell/logs/
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
|
|
||||||
/apps/ai-game-creator-shell/.llm-drafts/
|
/apps/ai-game-creator-shell/.llm-drafts/
|
||||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||||
/apps/mobile-shell/.expo/
|
/apps/mobile-shell/.expo/
|
||||||
|
|||||||
+1
-10
@@ -1,14 +1,5 @@
|
|||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all"
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "packages/shared/src/contracts/generated/**/*.ts",
|
|
||||||
"options": {
|
|
||||||
"printWidth": 1000,
|
|
||||||
"singleQuote": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,12 @@
|
|||||||
|
|
||||||
- 本文件只保留 Agent 进入仓库后必须立即遵守的最高优先级规则;完整执行细则见 [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
- 本文件只保留 Agent 进入仓库后必须立即遵守的最高优先级规则;完整执行细则见 [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
||||||
- 团队级长期项目记忆位于 [`docs/project-memory/`](docs/project-memory/),供 3 名开发人员和各自本地 Agent 通过 Git 同步。
|
- 团队级长期项目记忆位于 [`docs/project-memory/`](docs/project-memory/),供 3 名开发人员和各自本地 Agent 通过 Git 同步。
|
||||||
- [`.codex/`](.codex/) 保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;长期项目知识写入 `docs/` 与 `docs/project-memory/`。
|
- [`.codex/`](.codex/) 只保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;长期项目知识不要写入 `.codex/`。
|
||||||
- 若 `docs/project-memory/shared-memory/` 与当前代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
|
- 若 `docs/project-memory/shared-memory/` 与当前代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
|
||||||
|
|
||||||
## 开始任务前
|
## 开始任务前
|
||||||
|
|
||||||
- 先写清一句话交付结果、验收判据和修改范围,再按“必须项 / 风险项 / 可选项”排序;优先完成修改、定向验证和边界检查组成的最小闭环。设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,其余记录为后续事项。
|
- 先写清一句话交付结果、验收判据和不做项,再按“必须项 / 风险项 / 可选项”排序;优先完成修改、定向验证和边界检查组成的最小闭环。设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,否则记录为后续事项;工具探测、历史整理或验证便利不能自行改变任务目标。
|
||||||
- Agent 可见内容直接描述当前任务、输入和成功条件,细节按调用需要提供。
|
|
||||||
- 简单自包含任务可以直接执行;复杂开发、跨模块修改、后端 / UI / 文档体系调整前,按顺序读取:
|
- 简单自包含任务可以直接执行;复杂开发、跨模块修改、后端 / UI / 文档体系调整前,按顺序读取:
|
||||||
1. 本文件。
|
1. 本文件。
|
||||||
2. [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
2. [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
||||||
@@ -24,20 +23,24 @@
|
|||||||
|
|
||||||
- 禁止提交个人 `~/.codex` 配置、`.env`、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。
|
- 禁止提交个人 `~/.codex` 配置、`.env`、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。
|
||||||
- 不要在 `.gitignore` 中新增 `.env.local`。
|
- 不要在 `.gitignore` 中新增 `.env.local`。
|
||||||
- 现有中文文案、注释、剧情和文档保持中文;看到乱码时先确认真实编码并恢复正确中文。
|
- 不要擅自把现有中文文案、注释、剧情或文档改写成英文;看到中文乱码时先确认真实编码,不要沿用乱码或用英文替换。
|
||||||
- 修改包含中文的文件时优先局部补丁;修改后优先运行仓库编码检查。
|
- 修改包含中文的文件时优先局部补丁,避免整文件重写;修改后优先运行仓库编码检查。
|
||||||
- 新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。
|
- 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。
|
||||||
- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。
|
- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。
|
||||||
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件。
|
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。
|
||||||
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用。共享组件承载通用表现与交互,领域规则、后端副作用和正式业务状态由后端负责。
|
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。
|
||||||
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,直接清理实现、专属测试和说明,将权威文档更新为当前状态;历史由 Git 保存。
|
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,坚持“四不写”:
|
||||||
- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移按实际需求保留最小、白名单化的兼容,并配套契约或迁移测试;迁移完成后同步删除兼容实现与对应测试。
|
1. 不写历史兼容代码。
|
||||||
- UI 面板优先呈现任务内容与操作,说明按当前操作需要提供;移动端优先,同时保证网页端可正常显示和操作。
|
2. 不写用于维持退役行为的防御性兼容测试。
|
||||||
- 点击按钮弹出的独立面板使用弹窗、抽屉、popover 或页面级 portal。
|
3. 不写仅说明其曾存在或已删除的墓碑注释。
|
||||||
|
4. 不写仅记录其已删除的墓碑文档;直接将权威文档更新为当前状态。
|
||||||
|
- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移不适用“四不写”;必要兼容应最小化、白名单化并配套契约或迁移测试,迁移完成后同步删除兼容实现与对应测试。
|
||||||
|
- UI 面板中不要默认写功能说明、规则描述或开发解释文案;移动端优先,同时保证网页端可正常显示和操作。
|
||||||
|
- 点击按钮弹出独立面板的设计,不要实现成在当前面板下面追加内容。
|
||||||
|
|
||||||
## 任务路由
|
## 任务路由
|
||||||
|
|
||||||
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI,不使用 GitHub `gh` 或 GitLab `glab`,除非仓库已迁移。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
||||||
- 需要仓库级 Codex skills/plugins 时,再读取 [`.codex/README.md`](.codex/README.md)。
|
- 需要仓库级 Codex skills/plugins 时,再读取 [`.codex/README.md`](.codex/README.md)。
|
||||||
- 涉及 AI 游戏创作独立 App、多智能体 Runtime、本地项目产物或本地 HTTP 预览时,先读取 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
|
- 涉及 AI 游戏创作独立 App、多智能体 Runtime、本地项目产物或本地 HTTP 预览时,先读取 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
|
||||||
- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。
|
- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。
|
||||||
@@ -47,10 +50,10 @@
|
|||||||
## 后端红线
|
## 后端红线
|
||||||
|
|
||||||
- 后端最新技术约束以 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 为准。
|
- 后端最新技术约束以 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 为准。
|
||||||
- 后端路线固定为 `server-rs + Axum + SpacetimeDB`。
|
- 后端路线固定为 `server-rs + Axum + SpacetimeDB`;旧 `server-node`、Express、PostgreSQL、Go 服务端、`maincloud` / `Maincloud` / `MAINCLOUD` 只作为历史残留,不作为兼容目标。
|
||||||
- DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。
|
- DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。
|
||||||
- 前端负责表现、交互和临时 UI 状态;正式业务状态与规则以后端投影和后端 API 为准。
|
- 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。
|
||||||
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准。
|
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准;不得在前端、`api-server` 或临时兼容层中重新发明旧接口。
|
||||||
- 凡修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义,必须在同一次变更中同步更新权威契约 [`docs/openapi/genarrative-external-v1.openapi.json`](docs/openapi/genarrative-external-v1.openapi.json) 及对应契约测试;Rust 实现与 OpenAPI 未保持一致时任务不得视为完成。
|
- 凡修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义,必须在同一次变更中同步更新权威契约 [`docs/openapi/genarrative-external-v1.openapi.json`](docs/openapi/genarrative-external-v1.openapi.json) 及对应契约测试;Rust 实现与 OpenAPI 未保持一致时任务不得视为完成。
|
||||||
- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值;需要删除、改名、重排或改类型时,必须先询问用户并确认迁移计划。
|
- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值;需要删除、改名、重排或改类型时,必须先询问用户并确认迁移计划。
|
||||||
- 修改 SpacetimeDB schema 后必须同步 `migration.rs`、表目录和生成绑定,并运行 `npm run check:spacetime-schema`。
|
- 修改 SpacetimeDB schema 后必须同步 `migration.rs`、表目录和生成绑定,并运行 `npm run check:spacetime-schema`。
|
||||||
|
|||||||
-37
@@ -44,35 +44,6 @@ _Avoid_: 把同一资源的全局元数据和某一次摆放坐标混在同一
|
|||||||
由图片生成或图片修改流程产生的画布资源,必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。
|
由图片生成或图片修改流程产生的画布资源,必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。
|
||||||
_Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果
|
_Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果
|
||||||
|
|
||||||
**图片模型历史值与使用端解析**:
|
|
||||||
图片资源中已持久化的 `gpt-image-2` 是历史业务事实,读回时保持原值;新任务使用业务模型值 `gpt-image-2.5`。当用户基于历史资源再次发起生成或编辑任务时,服务端只在新任务的使用端把历史值解析为当前业务模型,不改写历史资源。provider route 属于服务端执行与审计边界,前端不接收、不持久化、不展示,也不据此分支。
|
|
||||||
_Avoid_: 读取数据库时改写历史模型值、把 provider route 暴露为前端模型选项或公开 DTO
|
|
||||||
|
|
||||||
**图片 provider 显式路由**:
|
|
||||||
api-server 在任务入口按业务语义显式选择具体 provider model name(生成或编辑),并把同一具体名传给图片平台适配器和后台定价解析;图片平台适配器不从参考图数量或前端字段猜测任务。具体 provider model name 只存在于服务端调用、定价配置和审计边界。
|
|
||||||
后台管理 Web/API 是明确例外,可以查看和编辑两个具体定价 key;主站普通前端与公开定价 API 不接收这些 key。
|
|
||||||
_Avoid_: 让图片适配器隐式猜路由、让主站前端携带 provider model name
|
|
||||||
|
|
||||||
**业务模型**:
|
|
||||||
面向任务与产品契约的稳定模型值;当前 GPT 图片新任务的业务模型是 `gpt-image-2.5`。业务模型不等同于 provider 的具体计费/请求 model,也不暴露 provider 凭证或 endpoint。
|
|
||||||
_Avoid_: 把 provider concrete model 当作前端业务选项、用业务模型值直接推断 provider 凭证
|
|
||||||
|
|
||||||
**具体模型**:
|
|
||||||
服务端发送请求和定价使用的 concrete model name。GPT Image 2.5 生成与编辑分别是 `gpt-image-2.5-flare-c` 和 `gpt-image-2.5-sunburst-c`;nanobanana 仍使用 `gemini-3.1-flash-image-preview`。具体模型只在服务端执行、定价和审计边界出现。
|
|
||||||
_Avoid_: 把具体模型写入普通前端 DTO、让未知字符串自动选择 provider
|
|
||||||
|
|
||||||
**provider client**:
|
|
||||||
按具体模型选出的外部图片 provider 连接配置,包含 provider identity、base URL 和 API key;VectorEngine 与 Tiantoken client 共享图片协议执行器,不复制请求/响应业务逻辑。两套 required client 在 api-server 启动时构造。
|
|
||||||
_Avoid_: 在首次请求时才创建 client、在 provider client 中复制尺寸/重试/审计逻辑、跨 provider credential fallback
|
|
||||||
|
|
||||||
**历史模型值**:
|
|
||||||
已持久化的 `gpt-image-2` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。已退役的 `gpt-image-2-c` 已从代码整体删除,只有数据库里的历史审计字符串原样保留,任何入口传入该值都按不支持的值处理。
|
|
||||||
_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端、为已删除的 `gpt-image-2-c` 重新加回常量或解析分支
|
|
||||||
|
|
||||||
**GPT Image 2.5 新生成展示名**:
|
|
||||||
`GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。
|
|
||||||
_Avoid_: 把新生成展示名扩散到历史记录、历史生成器或旧编辑上下文
|
|
||||||
|
|
||||||
**系列素材图集生成**:
|
**系列素材图集生成**:
|
||||||
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
|
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
|
||||||
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
|
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
|
||||||
@@ -203,14 +174,6 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
|||||||
|
|
||||||
## 项目开发对话(DirectProject)
|
## 项目开发对话(DirectProject)
|
||||||
|
|
||||||
**DirectProject 专属聊天模块**:
|
|
||||||
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
|
||||||
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
|
|
||||||
|
|
||||||
**项目工作台布局**:
|
|
||||||
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
|
|
||||||
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
|
|
||||||
|
|
||||||
**项目对话历史**:
|
**项目对话历史**:
|
||||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ import { afterEach, expect, test, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
createAdminAccount,
|
createAdminAccount,
|
||||||
executeAdminRechargeRefund,
|
executeAdminRechargeRefund,
|
||||||
getAdminAgcTemplates,
|
|
||||||
getAdminFeatureGateConfig,
|
getAdminFeatureGateConfig,
|
||||||
getAdminUserDetail,
|
getAdminUserDetail,
|
||||||
importAdminAgcTemplates,
|
|
||||||
listAdminRechargeOrders,
|
listAdminRechargeOrders,
|
||||||
reconcileAdminUserConsumption,
|
reconcileAdminUserConsumption,
|
||||||
resolveAdminRechargeRefundManualReview,
|
resolveAdminRechargeRefundManualReview,
|
||||||
updateAdminAccount,
|
updateAdminAccount,
|
||||||
updateAdminAgcTemplate,
|
|
||||||
uploadAdminEditorShowcaseCampaignImage,
|
uploadAdminEditorShowcaseCampaignImage,
|
||||||
upsertAdminFeatureGateConfig,
|
upsertAdminFeatureGateConfig,
|
||||||
upsertProfileWalletConfig,
|
upsertProfileWalletConfig,
|
||||||
@@ -21,93 +18,6 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
|
|
||||||
const library = { revision: 'revision-new', writable: true, templates: [] };
|
|
||||||
const fetchMock = vi.fn().mockImplementation(
|
|
||||||
async () =>
|
|
||||||
new Response(JSON.stringify({ ok: true, data: library }), {
|
|
||||||
status: 200,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
const controller = new AbortController();
|
|
||||||
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
|
|
||||||
library,
|
|
||||||
);
|
|
||||||
const update = {
|
|
||||||
expectedRevision: 'revision-old',
|
|
||||||
title: '空白模板',
|
|
||||||
summary: '简介',
|
|
||||||
tags: ['2D'],
|
|
||||||
enabled: true,
|
|
||||||
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
|
|
||||||
};
|
|
||||||
expect(
|
|
||||||
await updateAdminAgcTemplate('admin-token', 'template/1', update),
|
|
||||||
).toEqual(library);
|
|
||||||
expect(fetchMock.mock.calls[0]).toEqual([
|
|
||||||
'/admin/api/agc-templates',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'GET',
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(fetchMock.mock.calls[1]).toEqual([
|
|
||||||
'/admin/api/agc-templates/template%2F1',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'PUT',
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer admin-token',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}),
|
|
||||||
body: JSON.stringify(update),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
|
|
||||||
const imported = {
|
|
||||||
revision: 'rev-2',
|
|
||||||
writable: true,
|
|
||||||
templates: [],
|
|
||||||
imported: [
|
|
||||||
{
|
|
||||||
id: 'alpha',
|
|
||||||
templateVersion: '0.1.0',
|
|
||||||
zipSizeBytes: 4,
|
|
||||||
zipSha256: 'a'.repeat(64),
|
|
||||||
reusedObjects: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const fetchMock = vi.fn().mockImplementation(
|
|
||||||
async () =>
|
|
||||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
|
||||||
status: 200,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.append(
|
|
||||||
'manifest',
|
|
||||||
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
|
|
||||||
);
|
|
||||||
form.append(
|
|
||||||
'zip_0',
|
|
||||||
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
|
|
||||||
const [url, init] = fetchMock.mock.calls[0]!;
|
|
||||||
expect(url).toBe('/admin/api/agc-templates/import');
|
|
||||||
expect(init.method).toBe('POST');
|
|
||||||
expect(init.body).toBe(form);
|
|
||||||
expect(init.headers).not.toHaveProperty('Content-Type');
|
|
||||||
expect(init.headers.Authorization).toBe('Bearer admin-token');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
||||||
const fetchMock = vi.fn().mockImplementation(() =>
|
const fetchMock = vi.fn().mockImplementation(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminAccountListResponse,
|
AdminAccountListResponse,
|
||||||
AdminAgcTemplateLibraryResponse,
|
|
||||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||||
AdminCreateAccountRequest,
|
AdminCreateAccountRequest,
|
||||||
AdminCreateAccountResponse,
|
AdminCreateAccountResponse,
|
||||||
@@ -30,13 +29,9 @@ import type {
|
|||||||
AdminExternalApiKeyListQuery,
|
AdminExternalApiKeyListQuery,
|
||||||
AdminExternalApiKeyListResponse,
|
AdminExternalApiKeyListResponse,
|
||||||
AdminFeatureGateConfigResponse,
|
AdminFeatureGateConfigResponse,
|
||||||
AdminImportAgcTemplatesResponse,
|
|
||||||
AdminLoginResponse,
|
AdminLoginResponse,
|
||||||
AdminMeResponse,
|
AdminMeResponse,
|
||||||
AdminOverviewResponse,
|
AdminOverviewResponse,
|
||||||
AdminProjectSnapshotChannelsResponse,
|
|
||||||
AdminProjectSnapshotListQuery,
|
|
||||||
AdminProjectSnapshotListResponse,
|
|
||||||
AdminRechargeOrderListQuery,
|
AdminRechargeOrderListQuery,
|
||||||
AdminRechargeOrderListResponse,
|
AdminRechargeOrderListResponse,
|
||||||
AdminRechargeRefundActionResponse,
|
AdminRechargeRefundActionResponse,
|
||||||
@@ -50,7 +45,6 @@ import type {
|
|||||||
AdminTrackingEventListResponse,
|
AdminTrackingEventListResponse,
|
||||||
AdminUpdateAccountRequest,
|
AdminUpdateAccountRequest,
|
||||||
AdminUpdateAccountResponse,
|
AdminUpdateAccountResponse,
|
||||||
AdminUpdateAgcTemplateRequest,
|
|
||||||
AdminUploadedEditorShowcaseCampaignImage,
|
AdminUploadedEditorShowcaseCampaignImage,
|
||||||
AdminUpsertEditorShowcaseCampaignRequest,
|
AdminUpsertEditorShowcaseCampaignRequest,
|
||||||
AdminUpsertFeatureGateConfigRequest,
|
AdminUpsertFeatureGateConfigRequest,
|
||||||
@@ -89,8 +83,6 @@ interface AdminRequestOptions {
|
|||||||
method?: string;
|
method?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
|
|
||||||
formData?: FormData;
|
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
@@ -178,8 +170,6 @@ export async function request<T>(
|
|||||||
if (typeof options.body !== 'undefined') {
|
if (typeof options.body !== 'undefined') {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
init.body = JSON.stringify(options.body);
|
init.body = JSON.stringify(options.body);
|
||||||
} else if (options.formData) {
|
|
||||||
init.body = options.formData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(buildRequestUrl(path), init);
|
const response = await fetch(buildRequestUrl(path), init);
|
||||||
@@ -208,107 +198,6 @@ export function listAdminAccounts(token: string) {
|
|||||||
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
|
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAdminProjectSnapshots(
|
|
||||||
token: string,
|
|
||||||
query: AdminProjectSnapshotListQuery = {},
|
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (query.cursor) params.set('cursor', query.cursor);
|
|
||||||
if (query.channel) params.set('channel', query.channel);
|
|
||||||
params.set('limit', String(query.limit ?? 20));
|
|
||||||
return request<AdminProjectSnapshotListResponse>(
|
|
||||||
`/admin/api/project-snapshots?${params.toString()}`,
|
|
||||||
{ token, signal },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAdminProjectSnapshotChannels(
|
|
||||||
token: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
|
||||||
return request<AdminProjectSnapshotChannelsResponse>(
|
|
||||||
'/admin/api/project-snapshots/channels',
|
|
||||||
{ token, signal },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadAdminProjectSnapshot(
|
|
||||||
token: string,
|
|
||||||
channel: string,
|
|
||||||
userId: string,
|
|
||||||
projectId: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (channel) params.set('channel', channel);
|
|
||||||
const query = params.toString();
|
|
||||||
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`;
|
|
||||||
const response = await fetch(buildRequestUrl(path), {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token.trim()}`,
|
|
||||||
Accept: 'application/zip',
|
|
||||||
[API_RESPONSE_ENVELOPE_HEADER]: 'v1',
|
|
||||||
},
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const responseText = await response.text();
|
|
||||||
throw buildAdminApiError(
|
|
||||||
response,
|
|
||||||
parseJsonResponse(responseText),
|
|
||||||
responseText,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const contentType = response.headers
|
|
||||||
.get('content-type')
|
|
||||||
?.split(';')[0]
|
|
||||||
?.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (contentType !== 'application/zip') {
|
|
||||||
await response.body?.cancel();
|
|
||||||
throw new AdminApiError({
|
|
||||||
message: '下载失败:服务端未返回 ZIP 工程文件',
|
|
||||||
status: response.status,
|
|
||||||
code: 'INVALID_PROJECT_ARCHIVE_RESPONSE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
blob: await response.blob(),
|
|
||||||
filename: projectArchiveFilename(
|
|
||||||
response.headers.get('content-disposition'),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectArchiveFilename(contentDisposition: string | null): string {
|
|
||||||
const extended = contentDisposition?.match(
|
|
||||||
/(?:^|;)\s*filename\*=UTF-8'[^']*'([^;]+)/i,
|
|
||||||
);
|
|
||||||
const ordinary = contentDisposition?.match(
|
|
||||||
/(?:^|;)\s*filename=(?:"((?:[^"\\]|\\.)*)"|([^;]+))/i,
|
|
||||||
);
|
|
||||||
let filename =
|
|
||||||
ordinary?.[1]?.replace(/\\(.)/g, '$1') ?? ordinary?.[2]?.trim() ?? '';
|
|
||||||
if (extended?.[1]) {
|
|
||||||
try {
|
|
||||||
filename = decodeURIComponent(extended[1].trim());
|
|
||||||
} catch {
|
|
||||||
// 非法扩展编码继续使用普通文件名。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const safeName = Array.from(filename, (character) => {
|
|
||||||
const code = character.charCodeAt(0);
|
|
||||||
return code < 32 || code === 127 ? '_' : character;
|
|
||||||
})
|
|
||||||
.join('')
|
|
||||||
.replace(/[<>:"/\\|?*]/g, '_')
|
|
||||||
.trim()
|
|
||||||
.replace(/[. ]+$/, '');
|
|
||||||
if (!safeName || safeName.length > 240) return 'project.zip';
|
|
||||||
return /\.zip$/i.test(safeName) ? safeName : `${safeName}.zip`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdminAccount(
|
export function createAdminAccount(
|
||||||
token: string,
|
token: string,
|
||||||
payload: AdminCreateAccountRequest,
|
payload: AdminCreateAccountRequest,
|
||||||
@@ -1199,33 +1088,3 @@ export function saveAgcModelCatalog(
|
|||||||
{ token, method: 'PUT', body },
|
{ token, method: 'PUT', body },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
|
|
||||||
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
|
|
||||||
token,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateAdminAgcTemplate(
|
|
||||||
token: string,
|
|
||||||
id: string,
|
|
||||||
body: AdminUpdateAgcTemplateRequest,
|
|
||||||
) {
|
|
||||||
return request<AdminAgcTemplateLibraryResponse>(
|
|
||||||
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
|
|
||||||
{ token, method: 'PUT', body },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
|
|
||||||
export function importAdminAgcTemplates(token: string, formData: FormData) {
|
|
||||||
return request<AdminImportAgcTemplatesResponse>(
|
|
||||||
'/admin/api/agc-templates/import',
|
|
||||||
{
|
|
||||||
token,
|
|
||||||
method: 'POST',
|
|
||||||
formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -96,36 +96,6 @@ export interface AdminMeResponse {
|
|||||||
admin: AdminSessionPayload;
|
admin: AdminSessionPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProjectSnapshotEntry {
|
|
||||||
userId: string;
|
|
||||||
projectId: string;
|
|
||||||
projectName: string | null;
|
|
||||||
syncRevision: number;
|
|
||||||
syncedAtMs: number;
|
|
||||||
fileCount: number;
|
|
||||||
totalBytes: number;
|
|
||||||
status: 'ready' | 'partial' | 'unverified';
|
|
||||||
channel: string;
|
|
||||||
authorDisplayName?: string | null;
|
|
||||||
authorPublicUserCode?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminProjectSnapshotListQuery {
|
|
||||||
cursor?: string | null;
|
|
||||||
limit?: number;
|
|
||||||
channel?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminProjectSnapshotListResponse {
|
|
||||||
items: AdminProjectSnapshotEntry[];
|
|
||||||
nextCursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminProjectSnapshotChannelsResponse {
|
|
||||||
defaultChannel: string;
|
|
||||||
channels: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminErrorReportEntry {
|
export interface AdminErrorReportEntry {
|
||||||
batchId: string;
|
batchId: string;
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
@@ -153,8 +123,6 @@ export interface AdminErrorReportListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
||||||
firstFingerprint?: string;
|
|
||||||
firstSource?: string;
|
|
||||||
note?: string;
|
note?: string;
|
||||||
events: Array<Record<string, unknown>>;
|
events: Array<Record<string, unknown>>;
|
||||||
logNames: string[];
|
logNames: string[];
|
||||||
@@ -1042,67 +1010,3 @@ export interface AdminAgcModelCatalog {
|
|||||||
defaultModelId: string;
|
defaultModelId: string;
|
||||||
models: AdminAgcModel[];
|
models: AdminAgcModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAgcTemplatePayload {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
tags: string[];
|
|
||||||
runtime: string;
|
|
||||||
engine: string;
|
|
||||||
engineVersion: string;
|
|
||||||
templateVersion: string;
|
|
||||||
enabled: boolean;
|
|
||||||
coverUrl: string;
|
|
||||||
zipSizeBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminAgcTemplateLibraryResponse {
|
|
||||||
revision: string;
|
|
||||||
writable: boolean;
|
|
||||||
templates: AdminAgcTemplatePayload[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUpdateAgcTemplateRequest {
|
|
||||||
expectedRevision: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
tags: string[];
|
|
||||||
enabled: boolean;
|
|
||||||
cover?: {
|
|
||||||
contentType: string;
|
|
||||||
dataBase64: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminImportAgcTemplateItemPayload {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
tags: string[];
|
|
||||||
runtime: string;
|
|
||||||
engine: string;
|
|
||||||
engineVersion: string;
|
|
||||||
templateVersion: string;
|
|
||||||
entry: string;
|
|
||||||
zipField: string;
|
|
||||||
coverField: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminImportAgcTemplatesManifest {
|
|
||||||
expectedRevision: string;
|
|
||||||
templates: AdminImportAgcTemplateItemPayload[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminImportAgcTemplateResult {
|
|
||||||
id: string;
|
|
||||||
templateVersion: string;
|
|
||||||
zipSizeBytes: number;
|
|
||||||
zipSha256: string;
|
|
||||||
reusedObjects: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminImportAgcTemplatesResponse
|
|
||||||
extends AdminAgcTemplateLibraryResponse {
|
|
||||||
imported: AdminImportAgcTemplateResult[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import { afterEach, expect, test, vi } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
downloadAdminProjectSnapshot,
|
|
||||||
getAdminProjectSnapshotChannels,
|
|
||||||
listAdminProjectSnapshots,
|
|
||||||
} from './adminApiClient';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('项目列表携带分页与后台授权,解析标准响应', async () => {
|
|
||||||
const payload = { items: [], nextCursor: 'next' };
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ ok: true, data: payload })),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
const controller = new AbortController();
|
|
||||||
expect(
|
|
||||||
await listAdminProjectSnapshots(
|
|
||||||
'admin-token',
|
|
||||||
{ cursor: 'user/a+项目', limit: 20, channel: 'release' },
|
|
||||||
controller.signal,
|
|
||||||
),
|
|
||||||
).toEqual(payload);
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
|
||||||
signal: controller.signal,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('渠道列表按后台授权读取,解析本部署渠道', async () => {
|
|
||||||
const payload = { defaultChannel: 'release', channels: ['dev', 'release'] };
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ ok: true, data: payload })),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload);
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'/admin/api/project-snapshots/channels',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
|
||||||
new Response('PK\u0003\u0004', {
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/zip',
|
|
||||||
'content-disposition':
|
|
||||||
"attachment; filename=project.zip; filename*=UTF-8''%E4%B8%89%E6%B6%88-r2.zip",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const archive = await downloadAdminProjectSnapshot(
|
|
||||||
'admin-token',
|
|
||||||
'release',
|
|
||||||
'user/a',
|
|
||||||
'project/b',
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
expect(archive.filename).toBe('三消-r2.zip');
|
|
||||||
expect(archive.blob.type).toBe('application/zip');
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer admin-token',
|
|
||||||
Accept: 'application/zip',
|
|
||||||
}),
|
|
||||||
signal: controller.signal,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
['attachment; filename="工程.zip"; filename*=UTF-8\'\'%broken', '工程.zip'],
|
|
||||||
['attachment; filename="../secret.zip"', '.._secret.zip'],
|
|
||||||
["attachment; filename*=UTF-8''unsafe%00%1F%7F.zip", 'unsafe___.zip'],
|
|
||||||
[null, 'project.zip'],
|
|
||||||
])('ZIP 附件名兼容安全回退 %s', async (header, expected) => {
|
|
||||||
const headers: Record<string, string> = { 'content-type': 'application/zip' };
|
|
||||||
// Response 的 Headers 只接受 Latin-1;真实 UTF-8 文件名使用 filename*。
|
|
||||||
if (header)
|
|
||||||
headers['content-disposition'] = header.replace('工程', 'project');
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
|
|
||||||
.filename,
|
|
||||||
).toBe(expected.replace('工程', 'project'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([401, 403, 409, 500])(
|
|
||||||
'下载 HTTP %s 保留后台错误,不返回 ZIP',
|
|
||||||
async (status) => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
error: { code: 'SNAPSHOT_FAILURE', message: '工程尚未同步完成' },
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
status,
|
|
||||||
code: 'SNAPSHOT_FAILURE',
|
|
||||||
message: '工程尚未同步完成',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(
|
|
||||||
new Response('{"ok":false}', {
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
|
||||||
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
|
||||||
});
|
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from '../auth/adminAuthStore';
|
} from '../auth/adminAuthStore';
|
||||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||||
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
|
||||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||||
@@ -32,7 +31,6 @@ import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
|||||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||||
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
||||||
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
||||||
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
|
|
||||||
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
||||||
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
|
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
|
||||||
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
|
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
|
||||||
@@ -295,12 +293,6 @@ export function AdminApp() {
|
|||||||
{activeRouteId === 'agc-models' ? (
|
{activeRouteId === 'agc-models' ? (
|
||||||
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
|
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||||
) : null}
|
) : null}
|
||||||
{activeRouteId === 'agc-templates' ? (
|
|
||||||
<AdminAgcTemplatesPage
|
|
||||||
token={token}
|
|
||||||
onUnauthorized={handleUnauthorized}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{activeRouteId === 'editor-showcase' ? (
|
{activeRouteId === 'editor-showcase' ? (
|
||||||
<AdminEditorShowcaseReviewPage
|
<AdminEditorShowcaseReviewPage
|
||||||
token={token}
|
token={token}
|
||||||
@@ -316,12 +308,6 @@ export function AdminApp() {
|
|||||||
{activeRouteId === 'accounts' ? (
|
{activeRouteId === 'accounts' ? (
|
||||||
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
|
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||||
) : null}
|
) : null}
|
||||||
{activeRouteId === 'project-snapshots' ? (
|
|
||||||
<AdminProjectSnapshotsPage
|
|
||||||
token={token}
|
|
||||||
onUnauthorized={handleUnauthorized}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Bug,
|
Bug,
|
||||||
Coins,
|
Coins,
|
||||||
Database,
|
Database,
|
||||||
FolderArchive,
|
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Images,
|
Images,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -50,10 +49,8 @@ const routeIcons = {
|
|||||||
'editor-generation-pricing': Coins,
|
'editor-generation-pricing': Coins,
|
||||||
'editor-showcase': Star,
|
'editor-showcase': Star,
|
||||||
'editor-assets': Images,
|
'editor-assets': Images,
|
||||||
'project-snapshots': FolderArchive,
|
|
||||||
accounts: Users,
|
accounts: Users,
|
||||||
'agc-models': ListChecks,
|
'agc-models': ListChecks,
|
||||||
'agc-templates': Images,
|
|
||||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||||
|
|
||||||
export function AdminShell({
|
export function AdminShell({
|
||||||
|
|||||||
@@ -122,55 +122,3 @@ test('零权限 member 不回落到 Dashboard', () => {
|
|||||||
expect(routes).toEqual([]);
|
expect(routes).toEqual([]);
|
||||||
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
|
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('项目工程入口对 owner 与已授权 member 开放且可分配权限', () => {
|
|
||||||
const route = {
|
|
||||||
id: 'project-snapshots',
|
|
||||||
label: '项目工程',
|
|
||||||
hash: '#project-snapshots',
|
|
||||||
};
|
|
||||||
expect(adminRoutes.filter((item) => !item.ownerOnly)).toContainEqual(route);
|
|
||||||
expect(resolveAdminRoute('#project-snapshots')).toBe('project-snapshots');
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }),
|
|
||||||
).toContainEqual(route);
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['project-snapshots'],
|
|
||||||
}),
|
|
||||||
).toEqual([route]);
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['tracking'],
|
|
||||||
}),
|
|
||||||
).not.toContainEqual(route);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
|
|
||||||
expect(adminRoutes).toContainEqual({
|
|
||||||
id: 'agc-templates',
|
|
||||||
label: '模板管理',
|
|
||||||
hash: '#agc-templates',
|
|
||||||
});
|
|
||||||
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
|
|
||||||
expect(routeHash('agc-templates')).toBe('#agc-templates');
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
|
|
||||||
(route) => route.id === 'agc-templates',
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['agc-templates'],
|
|
||||||
}).map((route) => route.id),
|
|
||||||
).toEqual(['agc-templates']);
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['editor-assets'],
|
|
||||||
}).some((route) => route.id === 'agc-templates'),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ export type AdminRouteId =
|
|||||||
| 'editor-generation-pricing'
|
| 'editor-generation-pricing'
|
||||||
| 'editor-showcase'
|
| 'editor-showcase'
|
||||||
| 'editor-assets'
|
| 'editor-assets'
|
||||||
| 'project-snapshots'
|
|
||||||
| 'agc-models'
|
| 'agc-models'
|
||||||
| 'agc-templates'
|
|
||||||
| 'accounts';
|
| 'accounts';
|
||||||
|
|
||||||
export type AdminTabPermission = Exclude<
|
export type AdminTabPermission = Exclude<
|
||||||
@@ -54,10 +52,8 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
|||||||
hash: '#editor-generation-pricing',
|
hash: '#editor-generation-pricing',
|
||||||
},
|
},
|
||||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
||||||
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
|
|
||||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
|
||||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import '@genarrative/shared/styles.css';
|
|
||||||
import './styles/admin.css';
|
import './styles/admin.css';
|
||||||
|
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
formatAdminTimestamp,
|
|
||||||
parseAdminTimestamp,
|
|
||||||
} from './AdminErrorReportsPage';
|
|
||||||
|
|
||||||
describe('错误报告时间格式化', () => {
|
|
||||||
it('解析 SpacetimeDB seconds.microsZ 时间', () => {
|
|
||||||
expect(parseAdminTimestamp('1778207451.731746Z')).toBe(1778207451731);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('解析微秒字符串并拒绝无效时间', () => {
|
|
||||||
expect(parseAdminTimestamp('1778207451731746')).toBe(1778207451731);
|
|
||||||
expect(formatAdminTimestamp('not-a-date')).toBe('-');
|
|
||||||
expect(formatAdminTimestamp('999999999999999999999999')).toBe('-');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -17,57 +17,6 @@ type Props = { token: string; onUnauthorized: (message?: string) => void };
|
|||||||
|
|
||||||
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
||||||
const ERROR_REPORT_PAGE_SIZE = 50;
|
const ERROR_REPORT_PAGE_SIZE = 50;
|
||||||
const ERROR_REPORT_STATUS_LABELS: Record<string, string> = {
|
|
||||||
new: '待处理',
|
|
||||||
'in-progress': '处理中',
|
|
||||||
resolved: '已解决',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseAdminTimestamp(value: string | null | undefined) {
|
|
||||||
const normalized = value?.trim() ?? '';
|
|
||||||
if (/^-?\d+\.\d{6}Z$/u.test(normalized)) {
|
|
||||||
const [secondsText, microsText] = normalized.slice(0, -1).split('.');
|
|
||||||
const seconds = Number(secondsText);
|
|
||||||
const micros = Number(microsText);
|
|
||||||
return Number.isFinite(seconds) && Number.isFinite(micros)
|
|
||||||
? seconds * 1000 + Math.floor(micros / 1000)
|
|
||||||
: Number.NaN;
|
|
||||||
}
|
|
||||||
if (/^-?\d+$/u.test(normalized)) {
|
|
||||||
const numeric = Number(normalized);
|
|
||||||
if (!Number.isFinite(numeric)) return Number.NaN;
|
|
||||||
if (Math.abs(numeric) >= 1e14) return Math.floor(numeric / 1000);
|
|
||||||
if (Math.abs(numeric) >= 1e11) return numeric;
|
|
||||||
return numeric * 1000;
|
|
||||||
}
|
|
||||||
return Date.parse(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAdminTimestamp(value: string | null | undefined) {
|
|
||||||
const timestamp = parseAdminTimestamp(value);
|
|
||||||
if (!Number.isFinite(timestamp)) return '-';
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
if (!Number.isFinite(date.getTime())) return '-';
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
}).format(date);
|
|
||||||
} catch {
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventText(event: Record<string, unknown>, key: string) {
|
|
||||||
const value = event[key];
|
|
||||||
return typeof value === 'string' || typeof value === 'number'
|
|
||||||
? String(value)
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||||
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
||||||
@@ -80,24 +29,6 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
|
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
|
||||||
const openReportRequestId = useRef(0);
|
const openReportRequestId = useRef(0);
|
||||||
const loadRequestId = useRef(0);
|
const loadRequestId = useRef(0);
|
||||||
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selected) return;
|
|
||||||
const previousFocus = document.activeElement as HTMLElement | null;
|
|
||||||
const focusFrame = window.requestAnimationFrame(() => {
|
|
||||||
detailCloseButtonRef.current?.focus();
|
|
||||||
});
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') setSelected(null);
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', handleEscape);
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(focusFrame);
|
|
||||||
window.removeEventListener('keydown', handleEscape);
|
|
||||||
previousFocus?.focus?.();
|
|
||||||
};
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const requestId = ++loadRequestId.current;
|
const requestId = ++loadRequestId.current;
|
||||||
@@ -253,15 +184,9 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
<td>{report.batchId}</td>
|
<td>{report.batchId}</td>
|
||||||
<td>{report.eventCount}</td>
|
<td>{report.eventCount}</td>
|
||||||
<td>{report.source ?? '-'}</td>
|
<td>{report.source ?? '-'}</td>
|
||||||
<td>
|
<td>{report.status}</td>
|
||||||
<span
|
|
||||||
className={`admin-error-report-status is-${report.status}`}
|
|
||||||
>
|
|
||||||
{ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{report.userId}</td>
|
<td>{report.userId}</td>
|
||||||
<td>{formatAdminTimestamp(report.createdAt)}</td>
|
<td>{new Date(report.createdAt).toLocaleString()}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -299,124 +224,24 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
className="admin-detail-modal"
|
className="admin-detail-modal"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="错误报告详情"
|
aria-label="错误报告详情"
|
||||||
aria-modal="true"
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
if (event.target === event.currentTarget) setSelected(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="admin-detail-modal__panel admin-error-report-detail">
|
<div className="admin-detail-modal__panel">
|
||||||
<header className="admin-error-report-detail__header">
|
<header>
|
||||||
<div className="admin-error-report-detail__heading">
|
|
||||||
<span className="admin-error-report-detail__eyebrow">
|
|
||||||
错误报告详情
|
|
||||||
</span>
|
|
||||||
<h2>{selected.batchId}</h2>
|
<h2>{selected.batchId}</h2>
|
||||||
<div className="admin-error-report-detail__badges">
|
<button type="button" onClick={() => setSelected(null)}>
|
||||||
<span
|
关闭
|
||||||
className={`admin-error-report-status is-${selected.status}`}
|
|
||||||
>
|
|
||||||
{ERROR_REPORT_STATUS_LABELS[selected.status] ??
|
|
||||||
selected.status}
|
|
||||||
</span>
|
|
||||||
<span className="admin-error-report-detail__badge">
|
|
||||||
{selected.firstSource ?? selected.source ?? '未知来源'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-error-report-detail__close"
|
|
||||||
ref={detailCloseButtonRef}
|
|
||||||
onClick={() => setSelected(null)}
|
|
||||||
aria-label="关闭错误报告详情"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="admin-error-report-detail__summary">
|
<p>
|
||||||
<div>
|
用户:{selected.userId} · 事件:{selected.eventCount} · 日志:
|
||||||
<span>用户</span>
|
{selected.logCount}
|
||||||
<strong>{selected.userId}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>发生时间</span>
|
|
||||||
<strong>{formatAdminTimestamp(selected.createdAt)}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>错误事件</span>
|
|
||||||
<strong>{selected.eventCount}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>应用日志</span>
|
|
||||||
<strong>{selected.logCount}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-error-report-detail__body">
|
|
||||||
<section className="admin-error-report-detail__section">
|
|
||||||
<div className="admin-error-report-detail__section-heading">
|
|
||||||
<div>
|
|
||||||
<h3>错误事件</h3>
|
|
||||||
<span>展示最近 20 条,完整内容请下载诊断包。</span>
|
|
||||||
</div>
|
|
||||||
<span className="admin-error-report-detail__count">
|
|
||||||
{selected.events.length} 条
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="admin-error-report-event-list">
|
|
||||||
{selected.events.length === 0 ? (
|
|
||||||
<p className="admin-error-report-detail__empty">
|
|
||||||
归档中没有可展示的错误事件。
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
<pre>{JSON.stringify(selected.events.slice(0, 20), null, 2)}</pre>
|
||||||
{selected.events.slice(0, 20).map((event, index) => {
|
|
||||||
const message = eventText(event, 'message') || '未知错误';
|
|
||||||
const stack = eventText(event, 'stack');
|
|
||||||
const occurredAt = eventText(event, 'occurredAt');
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
className="admin-error-report-event"
|
|
||||||
key={`${index}-${message}`}
|
|
||||||
>
|
|
||||||
<div className="admin-error-report-event__topline">
|
|
||||||
<span className="admin-error-report-event__index">
|
|
||||||
#{index + 1}
|
|
||||||
</span>
|
|
||||||
<span>{eventText(event, 'source') || 'client'}</span>
|
|
||||||
<span>{eventText(event, 'count') || '1'} 次</span>
|
|
||||||
<time>{formatAdminTimestamp(occurredAt)}</time>
|
|
||||||
</div>
|
|
||||||
<p className="admin-error-report-event__message">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
{eventText(event, 'fingerprint') ? (
|
|
||||||
<code>{eventText(event, 'fingerprint')}</code>
|
|
||||||
) : null}
|
|
||||||
{stack ? (
|
|
||||||
<details>
|
|
||||||
<summary>查看调用栈</summary>
|
|
||||||
<pre>{stack}</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{selected.userDescription ? (
|
{selected.userDescription ? (
|
||||||
<section className="admin-error-report-detail__section">
|
<p>用户描述:{selected.userDescription}</p>
|
||||||
<div className="admin-error-report-detail__section-heading">
|
|
||||||
<div>
|
|
||||||
<h3>用户描述</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="admin-error-report-detail__description">
|
|
||||||
{selected.userDescription}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
) : null}
|
) : null}
|
||||||
<section className="admin-error-report-detail__section admin-error-report-detail__section--note">
|
|
||||||
<label className="admin-detail-modal__note">
|
<label className="admin-detail-modal__note">
|
||||||
<span>处理备注</span>
|
处理备注
|
||||||
<textarea
|
<textarea
|
||||||
value={selected.note ?? ''}
|
value={selected.note ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -432,11 +257,7 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
<div className="admin-detail-modal__actions">
|
||||||
</div>
|
|
||||||
<footer className="admin-error-report-detail__footer">
|
|
||||||
<label className="admin-error-report-detail__status-field">
|
|
||||||
状态
|
|
||||||
<select
|
<select
|
||||||
value={selected.status}
|
value={selected.status}
|
||||||
onChange={(event) => void saveStatus(event.target.value)}
|
onChange={(event) => void saveStatus(event.target.value)}
|
||||||
@@ -444,18 +265,16 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
>
|
>
|
||||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value}>
|
||||||
{ERROR_REPORT_STATUS_LABELS[value]}
|
{value}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
|
||||||
<div className="admin-error-report-detail__footer-actions">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void saveStatus(selected.status)}
|
onClick={() => void saveStatus(selected.status)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
{busy ? '保存中…' : '保存备注'}
|
保存备注
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -465,7 +284,6 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
{downloading ? '下载中…' : '下载诊断包'}
|
{downloading ? '下载中…' : '下载诊断包'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user