Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6973a2cb45 | |||
| 19e1883f71 | |||
| 4e41bd3e56 | |||
| 5315b233c8 | |||
| a11cdf13d7 | |||
| 085181c795 | |||
| 8968762274 | |||
| 7745264990 | |||
| 295d4af384 | |||
| 6981648796 | |||
| 4698527025 | |||
| ec408bfafd | |||
| ab52805d3c | |||
| 94471ff191 | |||
| f3097e1096 | |||
| f1294d4d6b | |||
| ba3bd59f24 | |||
| 317c8557f4 | |||
| 7b1f7bdf17 | |||
| abb2988875 | |||
| 912f9afcd2 | |||
| 0224ef7b9d | |||
| 1c830e6fe6 | |||
| 860f3f09d4 | |||
| dc1cac1c15 | |||
| c5a7386465 | |||
| 926fe9ae0f | |||
| e7967cf67b | |||
| 5aeaf38112 | |||
| 6f5ca47385 | |||
| 3e3c3df41f | |||
| 051c21d97f | |||
| 21382926dc | |||
| 62f1ec83bf | |||
| 263588af6a | |||
| 50291a5e77 | |||
| 8aa737e032 | |||
| 9243bb42a1 | |||
| 67252d36b2 | |||
| 97fc1918c4 | |||
| 034c369b88 | |||
| 884aabe6d7 | |||
| f58249a75e | |||
| 38fb9aa7ac | |||
| b5e10f3851 | |||
| c4e2793db1 | |||
| 79cb510891 | |||
| 3160131889 | |||
| c46687f0ef | |||
| 137533484b | |||
| 00063ba882 | |||
| cb3d452a44 | |||
| 257e407df7 | |||
| 0d3ab25499 | |||
| 2a8063a83a | |||
| 37c7477068 | |||
| e259106267 | |||
| 7a89a119d9 | |||
| 57b28ce5cd | |||
| 876529e668 | |||
| 99be1d76fe | |||
| 38ad2ce256 | |||
| 65f8e44a88 | |||
| ade1b7803e | |||
| 71000b6df1 | |||
| c047543825 | |||
| 34b3af1d2a | |||
| f7cba30b6b | |||
| 273f12633c | |||
| 5f5aa17152 | |||
| 3a57d9fbdf | |||
| 382b925ab9 | |||
| c1482012c6 | |||
| c3a17a6efc | |||
| 1bfdc3a760 | |||
| 1e27cd229d | |||
| 774710452f | |||
| aaecb82622 | |||
| 64ad24ac77 | |||
| 75ec3361dc | |||
| bc1dc868a1 | |||
| 50204ff7aa | |||
| 70fa160819 | |||
| 2e78cbf152 | |||
| 840baf208f | |||
| d8af0fa882 | |||
| faed0ec251 | |||
| 6e63a118e0 | |||
| 4ae5ab14fa | |||
| b73a329b2c | |||
| 06004bfeea | |||
| ecea7b4ad3 | |||
| b1e82d64cb | |||
| b7622f4a24 | |||
| cb840c6e8c | |||
| 0a9cdf6395 | |||
| e382d23892 | |||
| 37ae7508b8 | |||
| 1ae6c6cd06 | |||
| 459f8a60e5 | |||
| 63c495dab0 | |||
| b37d91882c | |||
| dd339638cc | |||
| 0caf998227 | |||
| 6eed6bd13f | |||
| b543a060b7 | |||
| 9993b2feb5 | |||
| d348cf9064 | |||
| e640219243 | |||
| da2ad83c3b | |||
| ad19c9475e | |||
| 975a2e577c | |||
| 1b40f030e7 | |||
| 098693636a | |||
| e17af3008e | |||
| 64bd006981 | |||
| 6a4cb2bc18 | |||
| 1b3a3b003f | |||
| ff8e517c8e | |||
| d874a13a92 | |||
| 5eba53a5cc | |||
| 33ddb5de27 | |||
| bf8f96c115 | |||
| e469831543 | |||
| 869abad879 | |||
| 280a2dc5d1 | |||
| 3c6e084a41 | |||
| ed3369a494 | |||
| fdc48fe725 | |||
| 2748468d12 | |||
| 09ad0073fe | |||
| 42b702d362 | |||
| edfd1f9143 | |||
| 423f2d7184 | |||
| 656c89e4b2 | |||
| aa9076c95c | |||
| 0ec1179bf1 | |||
| 2938a49cac | |||
| ae0f9376c9 | |||
| 29d0cbb4df | |||
| 41366dd71d | |||
| 721e45f01b | |||
| f4b496caca | |||
| fc0fd846f9 | |||
| fa99d9ad62 | |||
| 64a52005f8 | |||
| 645d806fed | |||
| 0b9d16e8f1 | |||
| d5c4e0e780 | |||
| 30e27abc60 | |||
| b607501916 | |||
| 46e3cd9744 | |||
| 5fb3db662b | |||
| 87b798322e | |||
| ec286b3480 | |||
| 35733f0e33 | |||
| 3c7b02b9f8 | |||
| f2030a616f | |||
| b3b5d77990 | |||
| 1da106af7d | |||
| cd2edd6966 | |||
| 22e830ff93 | |||
| c266ae7b50 | |||
| a543b75cf7 | |||
| f213987f9a | |||
| 2c623bb577 | |||
| 3353906e6f | |||
| 17716347e2 | |||
| 9ad66a67a3 | |||
| ba3aa3ccdb | |||
| 2b38eaafba | |||
| 7d5b9071e7 | |||
| 9e83f1d88c | |||
| 7e35d7c344 | |||
| 5ec40c8b83 | |||
| 948a80fc49 | |||
| dfd6fadedf |
@@ -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 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 preferredImageModel = '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`;
|
||||||
@@ -234,22 +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.providerBody = text;
|
error.vectorEngineBody = text;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
error.providerResponseParse = true;
|
error.vectorEngineResponseParse = true;
|
||||||
error.providerBody = text;
|
error.vectorEngineBody = text;
|
||||||
throw error;
|
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 {
|
||||||
@@ -257,35 +256,81 @@ async function fetchJson(url, options, timeoutMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestImagePayload(env, entry) {
|
function shouldFallbackImageModel(error) {
|
||||||
const model = preferredImageModel;
|
const raw =
|
||||||
const requestBody = {
|
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
||||||
model,
|
if (error?.vectorEngineResponseParse) {
|
||||||
prompt: buildPrompt(entry),
|
return !containsContentRejection(raw);
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildTiantokenImagesGenerationUrl(env.baseUrl),
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${env.apiKey}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
},
|
|
||||||
env.timeoutMs,
|
|
||||||
);
|
|
||||||
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
|
|
||||||
if (extractImageUrls(payload)[0] || base64Image) {
|
|
||||||
return payload;
|
|
||||||
}
|
}
|
||||||
const error = new Error(`Tiantoken returned no image for ${entry.id}`);
|
const status = Number(error?.vectorEngineStatus || 0);
|
||||||
error.providerResponseParse = true;
|
if (status === 408 || status >= 500) {
|
||||||
error.providerBody = JSON.stringify(payload).slice(0, 600);
|
return true;
|
||||||
throw error;
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
for (const model of [preferredImageModel, fallbackImageModel]) {
|
||||||
|
const requestBody = {
|
||||||
|
model,
|
||||||
|
prompt: buildPrompt(entry),
|
||||||
|
n: 1,
|
||||||
|
size: '1024x1024',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson(
|
||||||
|
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.apiKey}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
},
|
||||||
|
env.timeoutMs,
|
||||||
|
);
|
||||||
|
const base64Image = decodeStrictBase64Image(
|
||||||
|
extractBase64Images(payload)[0],
|
||||||
|
);
|
||||||
|
if (extractImageUrls(payload)[0] || base64Image) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
const error = new Error(`VectorEngine returned no image for ${entry.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 ${entry.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadUrl(url, timeoutMs) {
|
async function downloadUrl(url, timeoutMs) {
|
||||||
@@ -328,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 = {
|
||||||
@@ -336,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 });
|
||||||
@@ -363,6 +408,7 @@ 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: preferredImageModel,
|
model: preferredImageModel,
|
||||||
prompt: buildPrompt(entry),
|
prompt: buildPrompt(entry),
|
||||||
@@ -383,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 preferredImageModel = '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`;
|
||||||
@@ -204,22 +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.providerBody = text;
|
error.vectorEngineBody = text;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
error.providerResponseParse = true;
|
error.vectorEngineResponseParse = true;
|
||||||
error.providerBody = text;
|
error.vectorEngineBody = text;
|
||||||
throw error;
|
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 {
|
||||||
@@ -227,35 +226,83 @@ async function fetchJson(url, options, timeoutMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestImagePayload(env, template) {
|
function shouldFallbackImageModel(error) {
|
||||||
const model = preferredImageModel;
|
const raw =
|
||||||
const requestBody = {
|
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
||||||
model,
|
if (error?.vectorEngineResponseParse) {
|
||||||
prompt: buildPrompt(template),
|
return !containsContentRejection(raw);
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildTiantokenImagesGenerationUrl(env.baseUrl),
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${env.apiKey}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
},
|
|
||||||
env.timeoutMs,
|
|
||||||
);
|
|
||||||
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
|
|
||||||
if (extractImageUrls(payload)[0] || base64Image) {
|
|
||||||
return payload;
|
|
||||||
}
|
}
|
||||||
const error = new Error(`Tiantoken returned no image for ${template.id}`);
|
const status = Number(error?.vectorEngineStatus || 0);
|
||||||
error.providerResponseParse = true;
|
if (status === 408 || status >= 500) {
|
||||||
error.providerBody = JSON.stringify(payload).slice(0, 600);
|
return true;
|
||||||
throw error;
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
for (const model of [preferredImageModel, fallbackImageModel]) {
|
||||||
|
const requestBody = {
|
||||||
|
model,
|
||||||
|
prompt: buildPrompt(template),
|
||||||
|
n: 1,
|
||||||
|
size: '1024x1024',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson(
|
||||||
|
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.apiKey}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
},
|
||||||
|
env.timeoutMs,
|
||||||
|
);
|
||||||
|
const base64Image = decodeStrictBase64Image(
|
||||||
|
extractBase64Images(payload)[0],
|
||||||
|
);
|
||||||
|
if (extractImageUrls(payload)[0] || base64Image) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
const error = new Error(
|
||||||
|
`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) {
|
||||||
@@ -298,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 = {
|
||||||
@@ -306,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 });
|
||||||
@@ -337,6 +384,7 @@ 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: preferredImageModel,
|
model: preferredImageModel,
|
||||||
prompt: buildPrompt(template),
|
prompt: buildPrompt(template),
|
||||||
@@ -357,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),
|
||||||
}),
|
}),
|
||||||
|
|||||||
+8
-29
@@ -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,6 +174,14 @@ _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 需要另存的对话副本
|
||||||
|
|||||||
@@ -81,14 +81,12 @@ test('模板批量导入走 multipart,不预设 JSON Content-Type', async () =
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const fetchMock = vi
|
const fetchMock = vi.fn().mockImplementation(
|
||||||
.fn()
|
async () =>
|
||||||
.mockImplementation(
|
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||||
async () =>
|
status: 200,
|
||||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
}),
|
||||||
status: 200,
|
);
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
|||||||
@@ -107,10 +107,6 @@ const appInvokeSources = readSourceFiles(
|
|||||||
new URL('../src/', import.meta.url),
|
new URL('../src/', import.meta.url),
|
||||||
new Set(['.ts', '.tsx']),
|
new Set(['.ts', '.tsx']),
|
||||||
);
|
);
|
||||||
const appEntrypointSource = fs.readFileSync(
|
|
||||||
new URL('../src/main.tsx', import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
const tauriHandlerSource = fs.readFileSync(
|
const tauriHandlerSource = fs.readFileSync(
|
||||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -135,6 +131,45 @@ const rustSharedContractSource = fs.readFileSync(
|
|||||||
);
|
);
|
||||||
const allowedUncalledTauriCommands = [
|
const allowedUncalledTauriCommands = [
|
||||||
'append_direct_project_conversation_message',
|
'append_direct_project_conversation_message',
|
||||||
|
// Supervisor 调试窗口、开发者面板、专业 Agent 对话与旧命令聊天的前端调用方已随
|
||||||
|
// Supervisor 前端链路整体删除;命令本身仍注册在 Rust 侧并由 native Runtime、CLI
|
||||||
|
// swarm 与 Rust 测试使用,保留 present,仅不再出现在 App 前端源码里。
|
||||||
|
'answer_game_creator_agent_runtime_user_input',
|
||||||
|
'cancel_game_creator_agent_runtime_task',
|
||||||
|
'chat_with_game_creator_role_agent',
|
||||||
|
'chat_with_game_creator_role_agent_stream',
|
||||||
|
'check_game_creator_llm_config',
|
||||||
|
'confirm_game_creator_agent_runtime_task',
|
||||||
|
'diff_local_project_checkpoint',
|
||||||
|
'get_game_creation_agent_capabilities',
|
||||||
|
'get_limited_local_commands',
|
||||||
|
'list_local_project_export_packages',
|
||||||
|
'read_game_creator_agent_runtime',
|
||||||
|
'read_local_agent_memory',
|
||||||
|
'read_local_game_memory',
|
||||||
|
'reject_game_creator_agent_runtime_task',
|
||||||
|
'retry_game_creator_agent_runtime_task',
|
||||||
|
'schedule_game_creator_agent_ready_tasks',
|
||||||
|
'start_game_creator_agent_runtime_task',
|
||||||
|
'steer_game_creator_agent_runtime_task',
|
||||||
|
'write_local_agent_memory',
|
||||||
|
'write_local_game_memory',
|
||||||
|
'write_local_project_file',
|
||||||
|
// Agent 运行时会话 / 目标 / 协作命令由 native 侧与 CLI swarm 驱动,前端没有调用方。
|
||||||
|
'archive_game_creator_agent_session',
|
||||||
|
'clear_game_creator_agent_goal',
|
||||||
|
'compact_game_creator_agent_runtime_context',
|
||||||
|
'confirm_retry_game_creator_agent_runtime_task',
|
||||||
|
'create_game_creator_agent_session',
|
||||||
|
'edit_game_creator_agent_goal',
|
||||||
|
'fork_game_creator_agent_session',
|
||||||
|
'list_game_creator_agent_sessions',
|
||||||
|
'pause_game_creator_agent_goal',
|
||||||
|
'read_game_creator_agent_goal',
|
||||||
|
'resume_game_creator_agent_goal',
|
||||||
|
'set_active_game_creator_agent_session',
|
||||||
|
'start_game_creator_agent_goal',
|
||||||
|
'start_game_creator_supervisor_runtime_task',
|
||||||
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||||||
'bind_components',
|
'bind_components',
|
||||||
'chat_with_game_creator_agent',
|
'chat_with_game_creator_agent',
|
||||||
@@ -164,6 +199,26 @@ const allowedUncalledTauriCommands = [
|
|||||||
'call_agc_plugin',
|
'call_agc_plugin',
|
||||||
'read_agc_plugin_panel',
|
'read_agc_plugin_panel',
|
||||||
'set_agc_plugin_enabled',
|
'set_agc_plugin_enabled',
|
||||||
|
// 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口;
|
||||||
|
// 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 /
|
||||||
|
// 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现,
|
||||||
|
// `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按
|
||||||
|
// native-only 登记,避免孤儿检查一直报错。
|
||||||
|
// 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。
|
||||||
|
'build_local_project_index',
|
||||||
|
'control_agent_run',
|
||||||
|
'create_local_project_checkpoint',
|
||||||
|
'export_local_project_package',
|
||||||
|
'generate_local_game_draft',
|
||||||
|
'generate_platform_art_asset',
|
||||||
|
'import_canvas_asset',
|
||||||
|
'import_canvas_export',
|
||||||
|
'open_canvas_project',
|
||||||
|
'register_local_asset',
|
||||||
|
'restore_local_project_checkpoint',
|
||||||
|
'run_limited_local_command',
|
||||||
|
'sync_canvas_project_assets',
|
||||||
|
'write_project_permission_policy',
|
||||||
];
|
];
|
||||||
const sourceExtensions = new Set([
|
const sourceExtensions = new Set([
|
||||||
'.json',
|
'.json',
|
||||||
@@ -1470,13 +1525,7 @@ const eventCapability = JSON.parse(
|
|||||||
);
|
);
|
||||||
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
||||||
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
||||||
for (const windowLabel of [
|
for (const windowLabel of ['client', 'main', 'launcher']) {
|
||||||
'client',
|
|
||||||
'developer',
|
|
||||||
'main',
|
|
||||||
'launcher',
|
|
||||||
'supervisor-chat',
|
|
||||||
]) {
|
|
||||||
if (!eventCapabilityWindows.has(windowLabel)) {
|
if (!eventCapabilityWindows.has(windowLabel)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`AI game creator shell event capability missing window: ${windowLabel}`,
|
`AI game creator shell event capability missing window: ${windowLabel}`,
|
||||||
@@ -1859,20 +1908,6 @@ if (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const snippet of [
|
|
||||||
'import.meta.env.DEV',
|
|
||||||
'supervisorChatMode',
|
|
||||||
'supervisorChatOnly',
|
|
||||||
'open_project_supervisor_chat_window',
|
|
||||||
'index.html?supervisor-chat&projectPath=',
|
|
||||||
]) {
|
|
||||||
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
|
|
||||||
throw new Error(
|
|
||||||
`AI game creator shell developer window guardrail drifted: ${snippet}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
|
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AI game creator normal startup must not automatically open the developer window',
|
'AI game creator normal startup must not automatically open the developer window',
|
||||||
@@ -1905,31 +1940,17 @@ for (const snippet of [
|
|||||||
'官方账号服务(固定)',
|
'官方账号服务(固定)',
|
||||||
'runtime_config.save',
|
'runtime_config.save',
|
||||||
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
||||||
"'activate_local_game_preview'",
|
|
||||||
'已切换到客户端运行视图',
|
|
||||||
'async function executeRunLocal',
|
'async function executeRunLocal',
|
||||||
'function needsInitializedChatProject',
|
'function needsInitializedChatProject',
|
||||||
'function resolvePendingCommandProjectPath',
|
|
||||||
'resolveChatProjectPath(localProject) ?? draftProjectPath',
|
|
||||||
'`permission.cancel ${command.id} missing-project`',
|
|
||||||
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
||||||
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
||||||
'function parseRememberInput',
|
'function parseRememberInput',
|
||||||
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
||||||
'async function executeAgentTraceChat',
|
|
||||||
"relativePath: '.agent/logs/command.log'",
|
|
||||||
"'permission.pending'",
|
"'permission.pending'",
|
||||||
"'permission.confirm'",
|
"'permission.confirm'",
|
||||||
"'permission.cancel'",
|
"'permission.cancel'",
|
||||||
"'command.auto'",
|
"'command.auto'",
|
||||||
"'agent.run_status'",
|
|
||||||
'function summarizeAgentRunTrace',
|
'function summarizeAgentRunTrace',
|
||||||
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
|
|
||||||
'agentRunTrace.error ?',
|
|
||||||
'className="trace-error"',
|
|
||||||
'agentRunTrace.taskGraph.repairRoutes.map',
|
|
||||||
"in: ${step.inputPaths.join(', ') || 'none'}",
|
|
||||||
"out: ${step.outputPaths.join(', ') || 'none'}",
|
|
||||||
]) {
|
]) {
|
||||||
if (!appSource.includes(snippet)) {
|
if (!appSource.includes(snippet)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
|
||||||
"identifier": "developer",
|
|
||||||
"description": "开发窗口允许打开本地素材选择对话框。",
|
|
||||||
"windows": ["developer"],
|
|
||||||
"permissions": ["dialog:allow-open"]
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "events",
|
"identifier": "events",
|
||||||
"description": "允许客户端窗口订阅并取消订阅 Rust Runtime 事件。",
|
"description": "允许客户端窗口订阅并取消订阅 Rust Runtime 事件。",
|
||||||
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
|
"windows": ["client", "main", "launcher"],
|
||||||
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
|
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "window-chrome",
|
"identifier": "window-chrome",
|
||||||
"description": "自绘标题栏允许执行当前窗口的基础控制和拖拽。",
|
"description": "自绘标题栏允许执行当前窗口的基础控制和拖拽。",
|
||||||
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
|
"windows": ["client", "main", "launcher"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ mod codex_app_server;
|
|||||||
mod codex_cli;
|
mod codex_cli;
|
||||||
mod codex_provider_proxy;
|
mod codex_provider_proxy;
|
||||||
mod design_runtime;
|
mod design_runtime;
|
||||||
mod design_tools;
|
pub(crate) mod design_tools;
|
||||||
mod direct_codex_attachments;
|
mod direct_codex_attachments;
|
||||||
mod direct_codex_audit;
|
mod direct_codex_audit;
|
||||||
mod direct_codex_user_item;
|
mod direct_codex_user_item;
|
||||||
|
|||||||
@@ -1180,7 +1180,6 @@ fn direct_codex_thread_delta_event(
|
|||||||
) -> DirectThreadEvent {
|
) -> DirectThreadEvent {
|
||||||
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
|
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
|
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
|
||||||
///
|
///
|
||||||
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
|
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
|
||||||
@@ -3358,8 +3357,22 @@ impl CodexAppServerConnection {
|
|||||||
codex_app_server_text_prompt(&request)
|
codex_app_server_text_prompt(&request)
|
||||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||||
};
|
};
|
||||||
let mut input =
|
let mut input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?;
|
if let Some(item) = direct_user_item {
|
||||||
|
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
|
||||||
|
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
|
||||||
|
direct_codex_user_item_to_codex_turn_input(
|
||||||
|
&self.inner.workspace_path,
|
||||||
|
&canonical,
|
||||||
|
self.inner._skill_roots.as_deref().unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||||
|
} else {
|
||||||
|
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
||||||
|
};
|
||||||
if thread_created && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
|
if thread_created && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
|
||||||
{
|
{
|
||||||
if let Some(client_turn_id) = direct_client_turn_id {
|
if let Some(client_turn_id) = direct_client_turn_id {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
|
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
|
||||||
|
const DESIGN_REFERENCES_ROOT: &str = "references";
|
||||||
const SEARCH_HIT_LIMIT: usize = 200;
|
const SEARCH_HIT_LIMIT: usize = 200;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -547,6 +549,123 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result<S
|
|||||||
fs::read_to_string(&target).map_err(|error| format!("读取失败:{error}"))
|
fs::read_to_string(&target).map_err(|error| format!("读取失败:{error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn import_design_workspace_file(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
project_path: String,
|
||||||
|
file_name: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let root = PathBuf::from(project_path.trim());
|
||||||
|
let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?;
|
||||||
|
let _ = app.emit(
|
||||||
|
"design-agent-update",
|
||||||
|
serde_json::json!({
|
||||||
|
"projectPath": root.to_string_lossy(),
|
||||||
|
"clientTurnId": "",
|
||||||
|
"kind": "workspace",
|
||||||
|
"messageId": null,
|
||||||
|
"text": null,
|
||||||
|
"view": null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
Ok(relative_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_design_workspace_file_at(
|
||||||
|
root: &Path,
|
||||||
|
file_name: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
enforce_project_permission_policy(root, "conversation.write")?;
|
||||||
|
read_existing_manifest_for_project(root)?;
|
||||||
|
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||||
|
root,
|
||||||
|
"conversation.write",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if file_name.contains(['/', '\\']) {
|
||||||
|
return Err("附件文件名必须是单个文件名,不能包含目录".to_string());
|
||||||
|
}
|
||||||
|
let normalized_name =
|
||||||
|
normalize_relative_path(file_name).map_err(|error| format!("附件文件名无效:{error}"))?;
|
||||||
|
if normalized_name != file_name {
|
||||||
|
return Err("附件文件名无效".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, references) = resolve_design_workspace_path(root, DESIGN_REFERENCES_ROOT)?;
|
||||||
|
crate::ensure_game_creator_private_directory_tree(&references, "策划参考附件目录")?;
|
||||||
|
crate::prepare_game_creator_private_path_for_read(&references, true, "策划参考附件目录")?;
|
||||||
|
|
||||||
|
let mut sequence = 1_u64;
|
||||||
|
loop {
|
||||||
|
let candidate_name = design_reference_file_name(&normalized_name, sequence);
|
||||||
|
let relative_path = format!("{DESIGN_REFERENCES_ROOT}/{candidate_name}");
|
||||||
|
let (_, target) = resolve_design_workspace_path(root, &relative_path)?;
|
||||||
|
let mut options = fs::OpenOptions::new();
|
||||||
|
options.write(true).create_new(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||||||
|
}
|
||||||
|
let mut file = match options.open(&target) {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||||
|
sequence = sequence
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or_else(|| "无法为同名附件分配新序号".to_string())?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(format!(
|
||||||
|
"创建策划参考附件失败:{}: {error}",
|
||||||
|
target.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) =
|
||||||
|
crate::harden_new_game_creator_private_path(&target, false, "策划参考附件")
|
||||||
|
{
|
||||||
|
drop(file);
|
||||||
|
let _ = fs::remove_file(&target);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
let write_result = file.write_all(bytes).and_then(|_| file.sync_all());
|
||||||
|
drop(file);
|
||||||
|
if let Err(error) = write_result {
|
||||||
|
let _ = fs::remove_file(&target);
|
||||||
|
return Err(format!(
|
||||||
|
"写入策划参考附件失败:{}: {error}",
|
||||||
|
target.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(relative_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn design_reference_file_name(file_name: &str, sequence: u64) -> String {
|
||||||
|
if sequence == 1 {
|
||||||
|
return file_name.to_string();
|
||||||
|
}
|
||||||
|
let path = Path::new(file_name);
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or(file_name);
|
||||||
|
match path.extension().and_then(|value| value.to_str()) {
|
||||||
|
Some(extension) if !extension.is_empty() => {
|
||||||
|
format!("{stem} ({sequence}).{extension}")
|
||||||
|
}
|
||||||
|
_ => format!("{stem} ({sequence})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn load_design_catalog(root: &Path) -> Result<Vec<DesignCatalogItem>, String> {
|
fn load_design_catalog(root: &Path) -> Result<Vec<DesignCatalogItem>, String> {
|
||||||
let catalog_path = root.join("resources/catalog.json");
|
let catalog_path = root.join("resources/catalog.json");
|
||||||
let data: DesignCatalogFile = serde_json::from_str(
|
let data: DesignCatalogFile = serde_json::from_str(
|
||||||
@@ -756,6 +875,13 @@ mod tests {
|
|||||||
tempfile::tempdir().expect("tempdir")
|
tempfile::tempdir().expect("tempdir")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn initialized_test_root() -> tempfile::TempDir {
|
||||||
|
let temp = test_root();
|
||||||
|
init_local_game_project_at(temp.path(), "design-import-test", "策划附件导入测试")
|
||||||
|
.expect("init project");
|
||||||
|
temp
|
||||||
|
}
|
||||||
|
|
||||||
fn pack_root() -> PathBuf {
|
fn pack_root() -> PathBuf {
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")
|
||||||
}
|
}
|
||||||
@@ -868,6 +994,138 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imported_reference_is_visible_to_workspace_list_and_read() {
|
||||||
|
let temp = initialized_test_root();
|
||||||
|
let root = temp.path();
|
||||||
|
let manifest_before = read_existing_manifest_for_project(root).expect("read manifest");
|
||||||
|
let revision_before =
|
||||||
|
read_game_creator_agent_runtime_project_revision(root).expect("read revision");
|
||||||
|
|
||||||
|
let relative =
|
||||||
|
import_design_workspace_file_at(root, "玩法构想.txt", "横版解谜\n".as_bytes())
|
||||||
|
.expect("import text reference");
|
||||||
|
|
||||||
|
assert_eq!(relative, "references/玩法构想.txt");
|
||||||
|
assert!(list_design_workspace_files(root)
|
||||||
|
.expect("list workspace")
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.path == relative && entry.kind == "file"));
|
||||||
|
assert_eq!(
|
||||||
|
read_design_workspace_file_at(root, &relative).expect("read imported reference"),
|
||||||
|
"横版解谜\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_existing_manifest_for_project(root).expect("read manifest after import"),
|
||||||
|
manifest_before
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_game_creator_agent_runtime_project_revision(root)
|
||||||
|
.expect("read revision after import"),
|
||||||
|
revision_before
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_keeps_existing_names_and_accepts_empty_and_binary_bytes() {
|
||||||
|
let temp = initialized_test_root();
|
||||||
|
let root = temp.path();
|
||||||
|
|
||||||
|
let first = import_design_workspace_file_at(root, "brief.md", b"first")
|
||||||
|
.expect("import first reference");
|
||||||
|
let second = import_design_workspace_file_at(root, "brief.md", b"second")
|
||||||
|
.expect("import repeated reference");
|
||||||
|
let empty = import_design_workspace_file_at(root, "empty.bin", b"")
|
||||||
|
.expect("import empty reference");
|
||||||
|
let binary_bytes = [0_u8, 0xff, 0x10, 0x80];
|
||||||
|
let binary = import_design_workspace_file_at(root, "bytes.bin", &binary_bytes)
|
||||||
|
.expect("import binary reference");
|
||||||
|
|
||||||
|
assert_eq!(first, "references/brief.md");
|
||||||
|
assert_eq!(second, "references/brief (2).md");
|
||||||
|
assert_eq!(empty, "references/empty.bin");
|
||||||
|
assert_eq!(binary, "references/bytes.bin");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(root.join("design_artifacts").join(&first)).expect("read first"),
|
||||||
|
b"first"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(root.join("design_artifacts").join(&second)).expect("read second"),
|
||||||
|
b"second"
|
||||||
|
);
|
||||||
|
assert!(fs::read(root.join("design_artifacts").join(&empty))
|
||||||
|
.expect("read empty")
|
||||||
|
.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(root.join("design_artifacts").join(&binary)).expect("read binary"),
|
||||||
|
binary_bytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_rejects_unsafe_names_and_denied_project_permission() {
|
||||||
|
let temp = initialized_test_root();
|
||||||
|
let root = temp.path();
|
||||||
|
for file_name in [
|
||||||
|
"../outside.txt",
|
||||||
|
"nested/file.txt",
|
||||||
|
r"nested\file.txt",
|
||||||
|
"C:stream",
|
||||||
|
] {
|
||||||
|
let error = import_design_workspace_file_at(root, file_name, b"blocked")
|
||||||
|
.expect_err("reject unsafe file name");
|
||||||
|
assert!(error.contains("文件名"), "unexpected error: {error}");
|
||||||
|
}
|
||||||
|
assert!(!root.join("outside.txt").exists());
|
||||||
|
|
||||||
|
let mut policy = ProjectPermissionPolicy::default();
|
||||||
|
policy
|
||||||
|
.denied_commands
|
||||||
|
.push("conversation.write".to_string());
|
||||||
|
write_project_permission_policy_at(root, policy).expect("deny conversation write");
|
||||||
|
let error = import_design_workspace_file_at(root, "denied.txt", b"blocked")
|
||||||
|
.expect_err("respect project permission policy");
|
||||||
|
assert!(error.contains("conversation.write"));
|
||||||
|
assert!(!root.join("design_artifacts/references/denied.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn import_rejects_linked_references_directory() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let temp = initialized_test_root();
|
||||||
|
let root = temp.path();
|
||||||
|
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||||
|
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
|
||||||
|
symlink(outside.path(), root.join("design_artifacts/references"))
|
||||||
|
.expect("link references directory");
|
||||||
|
|
||||||
|
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
|
||||||
|
.expect_err("reject linked references directory");
|
||||||
|
assert!(error.contains("符号链接") || error.contains("reparse point"));
|
||||||
|
assert!(!outside.path().join("escape.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn import_rejects_windows_linked_references_directory_when_supported() {
|
||||||
|
use std::os::windows::fs::symlink_dir;
|
||||||
|
|
||||||
|
let temp = initialized_test_root();
|
||||||
|
let root = temp.path();
|
||||||
|
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||||
|
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
|
||||||
|
if symlink_dir(outside.path(), root.join("design_artifacts/references")).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
|
||||||
|
.expect_err("reject linked references directory");
|
||||||
|
assert!(error.contains("符号链接") || error.contains("reparse point"));
|
||||||
|
assert!(!outside.path().join("escape.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn phase_context_injects_current_skill_only() {
|
fn phase_context_injects_current_skill_only() {
|
||||||
let resources = DesignResources::new(pack_root()).expect("pack");
|
let resources = DesignResources::new(pack_root()).expect("pack");
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
||||||
|
|
||||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
||||||
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||||
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||||
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
||||||
|
|
||||||
const HOME_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.homeHeader");
|
const HOME_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.homeHeader");
|
||||||
const PROJECT_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.projectHeader");
|
const PROJECT_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.projectHeader");
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ mod validation;
|
|||||||
mod wire;
|
mod wire;
|
||||||
|
|
||||||
pub(crate) use model::{
|
pub(crate) use model::{
|
||||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
|
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
||||||
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||||
};
|
};
|
||||||
pub(crate) use validation::validate_direct_codex_user_item;
|
pub(crate) use validation::validate_direct_codex_user_item;
|
||||||
pub(crate) use wire::{
|
pub(crate) use wire::{
|
||||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
|
||||||
direct_codex_user_item_to_wire_input,
|
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use ts_rs::TS;
|
|||||||
/// DirectProject 本轮 user input 的唯一结构化入口。
|
/// DirectProject 本轮 user input 的唯一结构化入口。
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(tag = "type", deny_unknown_fields)]
|
#[serde(tag = "type", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) enum DirectCodexUserItem {
|
pub(crate) enum DirectCodexUserItem {
|
||||||
#[serde(rename = "message")]
|
#[serde(rename = "message")]
|
||||||
Message(DirectCodexUserMessageItem),
|
Message(DirectCodexUserMessageItem),
|
||||||
@@ -12,7 +12,7 @@ pub(crate) enum DirectCodexUserItem {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) struct DirectCodexUserMessageItem {
|
pub(crate) struct DirectCodexUserMessageItem {
|
||||||
pub(crate) role: DirectCodexUserRole,
|
pub(crate) role: DirectCodexUserRole,
|
||||||
pub(crate) content: Vec<DirectCodexUserContentPart>,
|
pub(crate) content: Vec<DirectCodexUserContentPart>,
|
||||||
@@ -21,26 +21,43 @@ pub(crate) struct DirectCodexUserMessageItem {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) enum DirectCodexUserRole {
|
pub(crate) enum DirectCodexUserRole {
|
||||||
User,
|
User,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) enum DirectCodexUserContentPart {
|
pub(crate) enum DirectCodexUserContentPart {
|
||||||
#[serde(rename = "input_text")]
|
#[serde(rename = "input_text")]
|
||||||
InputText { text: String },
|
InputText { text: String },
|
||||||
#[serde(rename = "agc_resource_reference")]
|
#[serde(rename = "agc_resource_reference")]
|
||||||
AgcResourceReference { resource_id: String },
|
AgcResourceReference { resource_id: String },
|
||||||
|
#[serde(rename = "agc_skill_reference")]
|
||||||
|
AgcSkillReference { name: String },
|
||||||
#[serde(rename = "agc_runtime_region_reference")]
|
#[serde(rename = "agc_runtime_region_reference")]
|
||||||
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
||||||
|
/// Uploaded project attachment kept inline in canonical content.
|
||||||
|
#[serde(rename = "agc_attachment_reference")]
|
||||||
|
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
|
pub(crate) struct DirectCodexUserAttachmentReferencePart {
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) media_type: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub(crate) size: u64,
|
||||||
|
pub(crate) local_path: String,
|
||||||
|
pub(crate) status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) struct DirectCodexUserRuntimeRegionPart {
|
pub(crate) struct DirectCodexUserRuntimeRegionPart {
|
||||||
pub(crate) label: String,
|
pub(crate) label: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+249
-14
@@ -4,15 +4,20 @@ use super::model::{
|
|||||||
};
|
};
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
||||||
|
MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS,
|
||||||
|
MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS,
|
||||||
};
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||||
|
// Skill 引用也是非文本 part,但没有走 reference_count:它每一条都会触发一次
|
||||||
|
// `root/<name>/SKILL.md` 文件探测并往 turn input 里加一项,所以单独设上限。
|
||||||
|
pub(crate) const MAX_DIRECT_CODEX_SKILL_REFERENCES: usize = 32;
|
||||||
|
|
||||||
pub(crate) fn validate_direct_codex_user_item(
|
pub(crate) fn validate_direct_codex_user_item(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
) -> Result<(), String> {
|
) -> Result<GameCreationAppManifest, String> {
|
||||||
let DirectCodexUserItem::Message(message) = item;
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
if !matches!(message.role, DirectCodexUserRole::User) {
|
if !matches!(message.role, DirectCodexUserRole::User) {
|
||||||
return Err("DirectProject 只接受 user message item".to_string());
|
return Err("DirectProject 只接受 user message item".to_string());
|
||||||
@@ -20,38 +25,98 @@ pub(crate) fn validate_direct_codex_user_item(
|
|||||||
if message.id.trim().is_empty() {
|
if message.id.trim().is_empty() {
|
||||||
return Err("DirectProject user item 缺少稳定 id".to_string());
|
return Err("DirectProject user item 缺少稳定 id".to_string());
|
||||||
}
|
}
|
||||||
if message.content.is_empty() {
|
// 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落
|
||||||
|
// 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。
|
||||||
|
if !content_has_meaningful_input(&message.content) {
|
||||||
return Err("聊天内容不能为空".to_string());
|
return Err("聊天内容不能为空".to_string());
|
||||||
}
|
}
|
||||||
let manifest = read_manifest_for_project(root)?;
|
let manifest = read_manifest_for_project(root)?;
|
||||||
let mut reference_count = 0usize;
|
let mut reference_count = 0usize;
|
||||||
let mut has_effective_content = false;
|
let mut attachment_count = 0usize;
|
||||||
|
let mut skill_count = 0usize;
|
||||||
for part in &message.content {
|
for part in &message.content {
|
||||||
match part {
|
match part {
|
||||||
DirectCodexUserContentPart::InputText { text } => {
|
DirectCodexUserContentPart::InputText { .. } => {}
|
||||||
if !text.trim().is_empty() {
|
|
||||||
has_effective_content = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||||
reference_count = reference_count.saturating_add(1);
|
reference_count = reference_count.saturating_add(1);
|
||||||
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
||||||
has_effective_content = true;
|
}
|
||||||
|
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||||
|
skill_count = skill_count.saturating_add(1);
|
||||||
|
if skill_count > MAX_DIRECT_CODEX_SKILL_REFERENCES {
|
||||||
|
return Err(format!(
|
||||||
|
"一次最多引用 {MAX_DIRECT_CODEX_SKILL_REFERENCES} 个 Skill"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty()
|
||||||
|
|| name.chars().count() > 120
|
||||||
|
|| matches!(name, "." | "..")
|
||||||
|
|| name.chars().any(|character| {
|
||||||
|
character.is_control()
|
||||||
|
|| character.is_whitespace()
|
||||||
|
|| matches!(character, '/' | '\\' | ':' | '$')
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err("引用的 Skill 名称无效,请移除后重新选择".to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||||
reference_count = reference_count.saturating_add(1);
|
reference_count = reference_count.saturating_add(1);
|
||||||
validate_runtime_region_reference(&manifest, reference)?;
|
validate_runtime_region_reference(&manifest, reference)?;
|
||||||
has_effective_content = true;
|
}
|
||||||
|
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||||
|
attachment_count = attachment_count.saturating_add(1);
|
||||||
|
if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS {
|
||||||
|
return Err(format!(
|
||||||
|
"一次最多携带 {MAX_DIRECT_CODEX_ATTACHMENTS} 个附件"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if reference.name.trim().is_empty() {
|
||||||
|
return Err("附件缺少文件名".to_string());
|
||||||
|
}
|
||||||
|
let name = reference.name.trim();
|
||||||
|
if name.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS
|
||||||
|
|| name.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err("附件文件名无效或过长".to_string());
|
||||||
|
}
|
||||||
|
let media_type = reference.media_type.trim();
|
||||||
|
if media_type.is_empty()
|
||||||
|
|| media_type.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS
|
||||||
|
|| media_type.chars().any(|character| {
|
||||||
|
!(character.is_ascii_alphanumeric()
|
||||||
|
|| matches!(character, '/' | '+' | '-' | '.' | '_'))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err("附件媒体类型无效或过长".to_string());
|
||||||
|
}
|
||||||
|
let status = reference.status.trim();
|
||||||
|
if status == "imported" && reference.local_path.trim().is_empty() {
|
||||||
|
return Err("已导入附件缺少项目路径".to_string());
|
||||||
|
}
|
||||||
|
if !reference.local_path.trim().is_empty() {
|
||||||
|
sanitize_attachment_local_path(&reference.local_path)
|
||||||
|
.ok_or_else(|| "附件项目路径无效".to_string())?;
|
||||||
|
}
|
||||||
|
if !matches!(status, "imported" | "failed") {
|
||||||
|
return Err("附件状态无效".to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||||
}
|
}
|
||||||
if !has_effective_content {
|
Ok(manifest)
|
||||||
return Err("聊天内容不能为空".to_string());
|
}
|
||||||
}
|
|
||||||
Ok(())
|
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
|
||||||
|
pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool {
|
||||||
|
content.iter().any(|part| match part {
|
||||||
|
DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(),
|
||||||
|
_ => true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_resource_id_and_manifest(
|
pub(crate) fn validate_resource_id_and_manifest(
|
||||||
@@ -92,3 +157,173 @@ fn validate_runtime_region_reference(
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
content_has_meaningful_input, validate_direct_codex_user_item,
|
||||||
|
MAX_DIRECT_CODEX_SKILL_REFERENCES,
|
||||||
|
};
|
||||||
|
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn input_text(text: &str) -> DirectCodexUserContentPart {
|
||||||
|
DirectCodexUserContentPart::InputText {
|
||||||
|
text: text.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_all_blank_content_counts_as_empty_input() {
|
||||||
|
// 空数组与「整条只有空白」是同一种空输入。
|
||||||
|
assert!(!content_has_meaningful_input(&[]));
|
||||||
|
assert!(!content_has_meaningful_input(&[input_text(" \n ")]));
|
||||||
|
assert!(!content_has_meaningful_input(&[
|
||||||
|
input_text("\n"),
|
||||||
|
input_text(" "),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whitespace_parts_are_valid_next_to_meaningful_input() {
|
||||||
|
// 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。
|
||||||
|
assert!(content_has_meaningful_input(&[
|
||||||
|
input_text("\n"),
|
||||||
|
input_text("看"),
|
||||||
|
]));
|
||||||
|
assert!(content_has_meaningful_input(&[
|
||||||
|
input_text("看"),
|
||||||
|
input_text("\n\n"),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_text_parts_always_count_as_input() {
|
||||||
|
assert!(content_has_meaningful_input(&[
|
||||||
|
DirectCodexUserContentPart::AgcResourceReference {
|
||||||
|
resource_id: "asset-hero".to_string(),
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_attachment_count_is_bounded_independently() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||||
|
.expect("init project");
|
||||||
|
let content = (0..=crate::agent::MAX_DIRECT_CODEX_ATTACHMENTS)
|
||||||
|
.map(|index| {
|
||||||
|
json!({
|
||||||
|
"type": "agc_attachment_reference",
|
||||||
|
"name": format!("attachment-{index}.txt"),
|
||||||
|
"mediaType": "text/plain",
|
||||||
|
"size": 1,
|
||||||
|
"localPath": "",
|
||||||
|
"status": "failed"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let item = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
"id": "turn-1:user"
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||||
|
.expect_err("too many inline attachments must be rejected");
|
||||||
|
assert!(error.contains("最多携带"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imported_attachment_requires_a_project_path() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||||
|
.expect("init project");
|
||||||
|
let item = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "agc_attachment_reference",
|
||||||
|
"name": "attachment.txt",
|
||||||
|
"mediaType": "text/plain",
|
||||||
|
"size": 1,
|
||||||
|
"localPath": "",
|
||||||
|
"status": "imported"
|
||||||
|
}],
|
||||||
|
"id": "turn-1:user"
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||||
|
.expect_err("imported attachment without a project path must fail");
|
||||||
|
assert!(error.contains("缺少项目路径"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_skill_reference_count_is_bounded_independently() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||||
|
.expect("init project");
|
||||||
|
let content = (0..=MAX_DIRECT_CODEX_SKILL_REFERENCES)
|
||||||
|
.map(|index| json!({ "type": "agc_skill_reference", "name": format!("skill-{index}") }))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let item = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
"id": "turn-1:user"
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||||
|
.expect_err("too many skill references must be rejected");
|
||||||
|
assert!(error.contains("最多引用"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_name_and_media_type_are_bounded_and_well_formed() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||||
|
.expect("init project");
|
||||||
|
let long_name = "a".repeat(crate::agent::MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + 1);
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"name": "bad\nname.txt",
|
||||||
|
"mediaType": "text/plain"
|
||||||
|
}),
|
||||||
|
"文件名",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"name": "ok.txt",
|
||||||
|
"mediaType": "text/plain\nsecret"
|
||||||
|
}),
|
||||||
|
"媒体类型",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"name": long_name,
|
||||||
|
"mediaType": "text/plain"
|
||||||
|
}),
|
||||||
|
"文件名",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (metadata, expected) in cases {
|
||||||
|
let mut value = metadata;
|
||||||
|
value["type"] = json!("agc_attachment_reference");
|
||||||
|
value["size"] = json!(1);
|
||||||
|
value["localPath"] = json!("");
|
||||||
|
value["status"] = json!("failed");
|
||||||
|
let item = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [value],
|
||||||
|
"id": "turn-1:user"
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||||
|
.expect_err("invalid attachment metadata must fail");
|
||||||
|
assert!(error.contains(expected), "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem};
|
use super::model::{
|
||||||
|
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
||||||
|
DirectCodexUserMessageItem, DirectCodexUserRuntimeRegionPart,
|
||||||
|
};
|
||||||
use super::validation::validate_direct_codex_user_item;
|
use super::validation::validate_direct_codex_user_item;
|
||||||
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
use crate::agent::{
|
||||||
|
read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type,
|
||||||
|
sanitize_attachment_name, GameCreationAppManifest,
|
||||||
|
};
|
||||||
use crate::ui_editor::persistence::{
|
use crate::ui_editor::persistence::{
|
||||||
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
||||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||||
@@ -55,54 +61,90 @@ fn direct_codex_user_item_to_response_content(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resource_reference_summary(
|
||||||
|
manifest: &GameCreationAppManifest,
|
||||||
|
resource_id: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let resource_id = resource_id.trim();
|
||||||
|
let asset = manifest
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|asset| asset.id == resource_id)
|
||||||
|
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||||
|
let path = sanitize_attachment_local_path(&asset.local_path)
|
||||||
|
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||||
|
Ok(format!(
|
||||||
|
"[素材引用 resourceId={resource_id};项目路径={path}]"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String {
|
||||||
|
let resources = reference
|
||||||
|
.resource_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.trim())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
|
||||||
|
if let Some(run_id) = reference.run_id.as_deref() {
|
||||||
|
summary.push_str(&format!("运行标识={} ", run_id.trim()));
|
||||||
|
}
|
||||||
|
if let Some(role) = reference.element_role.as_deref() {
|
||||||
|
summary.push_str(&format!("角色={} ", role.trim()));
|
||||||
|
}
|
||||||
|
if let Some(text) = reference.text.as_deref() {
|
||||||
|
summary.push_str(&format!("文本={} ", text.trim()));
|
||||||
|
}
|
||||||
|
if !resources.is_empty() {
|
||||||
|
summary.push_str(&format!("关联素材={resources}"));
|
||||||
|
}
|
||||||
|
summary.push(']');
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件引用的安全摘要。
|
||||||
|
///
|
||||||
|
/// turn 输入与 history/prompt 投影共用这一份清洗:文件名取 basename 并去控制字符、
|
||||||
|
/// media type 与项目路径同样过白名单,避免两条路径对同一个引用给出不同摘要。
|
||||||
|
fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePart) -> String {
|
||||||
|
let name = sanitize_attachment_name(&reference.name);
|
||||||
|
let media_type = sanitize_attachment_media_type(&reference.media_type);
|
||||||
|
let mut summary = format!(
|
||||||
|
"[附件:名称={name};类型={media_type};大小={} 字节",
|
||||||
|
reference.size
|
||||||
|
);
|
||||||
|
if let Some(local_path) = sanitize_attachment_local_path(&reference.local_path) {
|
||||||
|
summary.push_str(&format!(";项目路径={local_path}"));
|
||||||
|
}
|
||||||
|
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
||||||
|
summary.push(']');
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
||||||
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
||||||
pub(crate) fn direct_codex_user_item_to_wire_input(
|
pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
validate_direct_codex_user_item(root, item)?;
|
// validate 已经读过清单并返回它,不要再读一次(seed task 变更也会被重复触发)。
|
||||||
let manifest = read_manifest_for_project(root)?;
|
let manifest = validate_direct_codex_user_item(root, item)?;
|
||||||
let DirectCodexUserItem::Message(message) = item;
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
let mut input = Vec::with_capacity(message.content.len());
|
let mut input = Vec::with_capacity(message.content.len());
|
||||||
for part in &message.content {
|
for part in &message.content {
|
||||||
let text = match part {
|
let text = match part {
|
||||||
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
||||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||||
let asset = manifest
|
resource_reference_summary(&manifest, resource_id)?
|
||||||
.assets
|
}
|
||||||
.iter()
|
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||||
.find(|asset| asset.id == resource_id.trim())
|
format!("${}", name.trim())
|
||||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
|
||||||
let path = sanitize_attachment_local_path(&asset.local_path)
|
|
||||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
|
||||||
format!(
|
|
||||||
"[素材引用 resourceId={};项目路径={path}]",
|
|
||||||
resource_id.trim()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||||
let resources = reference
|
runtime_region_summary(reference)
|
||||||
.resource_ids
|
}
|
||||||
.iter()
|
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||||
.map(|id| id.trim())
|
attachment_reference_summary(reference)
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",");
|
|
||||||
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
|
|
||||||
if let Some(run_id) = reference.run_id.as_deref() {
|
|
||||||
summary.push_str(&format!("运行标识={} ", run_id.trim()));
|
|
||||||
}
|
|
||||||
if let Some(role) = reference.element_role.as_deref() {
|
|
||||||
summary.push_str(&format!("角色={} ", role.trim()));
|
|
||||||
}
|
|
||||||
if let Some(text) = reference.text.as_deref() {
|
|
||||||
summary.push_str(&format!("文本={} ", text.trim()));
|
|
||||||
}
|
|
||||||
if !resources.is_empty() {
|
|
||||||
summary.push_str(&format!("关联素材={resources}"));
|
|
||||||
}
|
|
||||||
summary.push(']');
|
|
||||||
summary
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||||
@@ -110,6 +152,55 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
|||||||
Ok(Value::Array(input))
|
Ok(Value::Array(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn direct_codex_user_item_to_codex_turn_input(
|
||||||
|
root: &Path,
|
||||||
|
item: &DirectCodexUserItem,
|
||||||
|
skill_roots: &[std::path::PathBuf],
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let manifest = validate_direct_codex_user_item(root, item)?;
|
||||||
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
|
let mut input = Vec::with_capacity(message.content.len());
|
||||||
|
for part in &message.content {
|
||||||
|
match part {
|
||||||
|
DirectCodexUserContentPart::InputText { text } => {
|
||||||
|
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||||
|
}
|
||||||
|
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||||
|
input.push(serde_json::json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": resource_reference_summary(&manifest, resource_id)?,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||||
|
let name = name.trim();
|
||||||
|
let path = skill_roots
|
||||||
|
.iter()
|
||||||
|
.map(|root| root.join(name).join("SKILL.md"))
|
||||||
|
.find(|path| path.is_file())
|
||||||
|
.ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?;
|
||||||
|
input.push(serde_json::json!({
|
||||||
|
"type": "skill",
|
||||||
|
"name": name,
|
||||||
|
"path": path,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||||
|
input.push(serde_json::json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": runtime_region_summary(reference),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||||
|
input.push(serde_json::json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": attachment_reference_summary(reference),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Value::Array(input))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn direct_codex_user_item_to_prompt(
|
pub(crate) fn direct_codex_user_item_to_prompt(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
@@ -118,17 +209,10 @@ pub(crate) fn direct_codex_user_item_to_prompt(
|
|||||||
let DirectCodexUserItem::Message(message) = item;
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
let mut prompt = wire
|
let mut prompt = wire
|
||||||
.as_array()
|
.as_array()
|
||||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
|
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())?
|
||||||
.and_then(|parts| {
|
.iter()
|
||||||
parts
|
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||||
.iter()
|
.collect::<String>();
|
||||||
.map(|part| {
|
|
||||||
part.get("text")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string())
|
|
||||||
})
|
|
||||||
.collect::<Result<String, String>>()
|
|
||||||
})?;
|
|
||||||
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
||||||
prompt.push('\n');
|
prompt.push('\n');
|
||||||
prompt.push_str(&code_context);
|
prompt.push_str(&code_context);
|
||||||
@@ -199,8 +283,9 @@ fn render_ui_design_code_context(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||||
validate_direct_codex_user_item,
|
direct_codex_user_item_to_wire_input, validate_direct_codex_user_item,
|
||||||
};
|
};
|
||||||
|
use crate::agent::direct_codex_user_item::model::DirectCodexUserItem;
|
||||||
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use shared_contracts::game_creation_app::{
|
use shared_contracts::game_creation_app::{
|
||||||
@@ -283,6 +368,83 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_design_doc_reference_appends_generated_code_context() {
|
||||||
|
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||||
|
let item: super::DirectCodexUserItem =
|
||||||
|
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||||
|
.expect("canonical user item");
|
||||||
|
let prompt =
|
||||||
|
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||||
|
assert!(
|
||||||
|
prompt.contains(&format!(
|
||||||
|
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
|
||||||
|
)),
|
||||||
|
"{prompt}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
|
||||||
|
"{prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_projection_never_writes_generated_ui_design_code() {
|
||||||
|
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||||
|
let item = user_item_with_resource_reference(&asset_id);
|
||||||
|
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
|
||||||
|
let generated_root =
|
||||||
|
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
|
||||||
|
let generated_files = std::fs::read_dir(generated_root)
|
||||||
|
.expect("read ui directory")
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with("generated-")
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
generated_files, 0,
|
||||||
|
"历史回读只做纯投影,不得生成 UI 设计代码"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
|
||||||
|
let (project, asset_id) = ui_design_doc_fixture(false);
|
||||||
|
let item: super::DirectCodexUserItem =
|
||||||
|
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||||
|
.expect("canonical user item");
|
||||||
|
let prompt =
|
||||||
|
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||||
|
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||||
|
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
|
||||||
|
let project = prompt_context_project();
|
||||||
|
let asset_id = register_fixture_asset(
|
||||||
|
project.path(),
|
||||||
|
"assets/hero.png",
|
||||||
|
GameCreationAppAssetKind::Character,
|
||||||
|
"image/png",
|
||||||
|
);
|
||||||
|
let item: super::DirectCodexUserItem =
|
||||||
|
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||||
|
.expect("canonical user item");
|
||||||
|
let prompt =
|
||||||
|
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||||
|
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||||
|
assert!(
|
||||||
|
!prompt.contains("请先阅读生成的带有文档的代码片段"),
|
||||||
|
"{prompt}"
|
||||||
|
);
|
||||||
|
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn standard_response_item_passes_through_without_agc_private_parts() {
|
fn standard_response_item_passes_through_without_agc_private_parts() {
|
||||||
let item = json!({
|
let item = json!({
|
||||||
@@ -297,6 +459,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_item_projection_uses_input_text_not_turn_input_text() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
|
.expect("init project");
|
||||||
|
let item = json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"id": "turn-1:user",
|
||||||
|
"content": [{"type": "input_text", "text": "你好"}]
|
||||||
|
});
|
||||||
|
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||||
|
.expect("user response item should project");
|
||||||
|
assert_eq!(projected["content"][0]["type"], "input_text");
|
||||||
|
assert_ne!(projected["content"][0]["type"], "text");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() {
|
fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() {
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
@@ -445,79 +624,97 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ui_design_doc_reference_appends_generated_code_context() {
|
fn attachment_parts_remain_in_canonical_order_when_projected() {
|
||||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
let item: super::DirectCodexUserItem =
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
.expect("init project");
|
||||||
.expect("canonical user item");
|
let item = json!({
|
||||||
let prompt =
|
"type": "message",
|
||||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
"role": "user",
|
||||||
assert!(
|
"id": "turn-1:user",
|
||||||
prompt.contains(&format!(
|
"content": [
|
||||||
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
|
{"type": "input_text", "text": "先看"},
|
||||||
)),
|
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
|
||||||
"{prompt}"
|
]
|
||||||
);
|
});
|
||||||
assert!(
|
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||||
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
|
.expect("user response item should project");
|
||||||
"{prompt}"
|
let content = projected["content"].as_array().expect("content array");
|
||||||
);
|
assert_eq!(content.len(), 2);
|
||||||
|
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
|
||||||
|
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn history_projection_never_writes_generated_ui_design_code() {
|
fn attachment_metadata_is_sanitized_before_prompt_projection() {
|
||||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
let item = user_item_with_resource_reference(&asset_id);
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
|
.expect("init project");
|
||||||
let generated_root =
|
let item = json!({
|
||||||
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
|
"type": "message",
|
||||||
let generated_files = std::fs::read_dir(generated_root)
|
"role": "user",
|
||||||
.expect("read ui directory")
|
"id": "turn-1:user",
|
||||||
.filter_map(|entry| entry.ok())
|
"content": [{
|
||||||
.filter(|entry| {
|
"type": "agc_attachment_reference",
|
||||||
entry
|
"name": "C:\\tmp\\notes.md",
|
||||||
.file_name()
|
"mediaType": "text/plain",
|
||||||
.to_string_lossy()
|
"size": 4,
|
||||||
.starts_with("generated-")
|
"localPath": "assets\\.\\notes.txt",
|
||||||
})
|
"status": "imported"
|
||||||
.count();
|
}]
|
||||||
assert_eq!(
|
});
|
||||||
generated_files, 0,
|
let wire = super::direct_codex_user_item_to_wire_input(
|
||||||
"历史回读只做纯投影,不得生成 UI 设计代码"
|
root.path(),
|
||||||
);
|
&serde_json::from_value(item).expect("deserialize user item"),
|
||||||
|
)
|
||||||
|
.expect("attachment metadata should project");
|
||||||
|
let text = wire[0]["text"].as_str().expect("wire text");
|
||||||
|
assert!(text.contains("名称=notes.md"), "{text}");
|
||||||
|
assert!(text.contains("类型=text/plain"), "{text}");
|
||||||
|
assert!(text.contains("项目路径=assets/notes.txt"), "{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
|
fn whitespace_only_text_parts_survive_validation() {
|
||||||
let (project, asset_id) = ui_design_doc_fixture(false);
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
let item: super::DirectCodexUserItem =
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
.expect("init project");
|
||||||
.expect("canonical user item");
|
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
||||||
let prompt =
|
"type": "message",
|
||||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
"role": "user",
|
||||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
"id": "turn-1:user",
|
||||||
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
"content": [
|
||||||
|
{"type": "input_text", "text": "先看"},
|
||||||
|
{"type": "input_text", "text": "\n"},
|
||||||
|
{"type": "input_text", "text": " "}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let wire = direct_codex_user_item_to_wire_input(root.path(), &item)
|
||||||
|
.expect("whitespace-only part next to real text must pass");
|
||||||
|
let parts = wire.as_array().expect("wire input array");
|
||||||
|
assert_eq!(parts.len(), 3);
|
||||||
|
assert_eq!(parts[1]["text"].as_str(), Some("\n"));
|
||||||
|
assert_eq!(parts[2]["text"].as_str(), Some(" "));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
|
fn all_blank_content_is_rejected() {
|
||||||
let project = prompt_context_project();
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
let asset_id = register_fixture_asset(
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
project.path(),
|
.expect("init project");
|
||||||
"assets/hero.png",
|
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
||||||
GameCreationAppAssetKind::Character,
|
"type": "message",
|
||||||
"image/png",
|
"role": "user",
|
||||||
);
|
"id": "turn-1:user",
|
||||||
let item: super::DirectCodexUserItem =
|
"content": [
|
||||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
{"type": "input_text", "text": "\n"},
|
||||||
.expect("canonical user item");
|
{"type": "input_text", "text": " "}
|
||||||
let prompt =
|
]
|
||||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
}))
|
||||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
.expect("deserialize user item");
|
||||||
assert!(
|
let error = direct_codex_user_item_to_wire_input(root.path(), &item)
|
||||||
!prompt.contains("请先阅读生成的带有文档的代码片段"),
|
.expect_err("all-blank content must fail closed");
|
||||||
"{prompt}"
|
assert!(error.contains("不能为空"), "{error}");
|
||||||
);
|
|
||||||
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,9 @@ pub(crate) fn normalize_direct_client_turn_id(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||||
project_path: String,
|
project_path: String,
|
||||||
prompt: String,
|
user_item: DirectCodexUserItem,
|
||||||
mut user_item: DirectCodexUserItem,
|
|
||||||
creation_type: Option<String>,
|
creation_type: Option<String>,
|
||||||
client_turn_id: Option<String>,
|
client_turn_id: Option<String>,
|
||||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let root = Path::new(project_path.trim());
|
let root = Path::new(project_path.trim());
|
||||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||||
@@ -43,74 +41,33 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
|||||||
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
||||||
})?;
|
})?;
|
||||||
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
||||||
let mut audit = DirectCodexTurnAudit::start(
|
validate_direct_codex_user_item(root, &user_item)?;
|
||||||
root,
|
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
|
||||||
&turn_id,
|
|
||||||
&prompt,
|
|
||||||
attachments.as_deref().unwrap_or_default(),
|
|
||||||
);
|
|
||||||
let attachments = attachments.unwrap_or_default();
|
|
||||||
if !attachments.is_empty() {
|
|
||||||
let attachment_context = match render_direct_codex_user_prompt("", &attachments) {
|
|
||||||
Ok(context) => context,
|
|
||||||
Err(error) => {
|
|
||||||
audit.finish(false);
|
|
||||||
audit.flush().await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let DirectCodexUserItem::Message(message) = &mut user_item;
|
|
||||||
message.content.push(DirectCodexUserContentPart::InputText {
|
|
||||||
text: attachment_context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Err(error) = validate_direct_codex_user_item(root, &user_item) {
|
|
||||||
audit.finish(false);
|
|
||||||
audit.flush().await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
let user_prompt = match direct_codex_user_item_to_prompt(root, &user_item) {
|
|
||||||
Ok(prompt) => prompt,
|
|
||||||
Err(error) => {
|
|
||||||
audit.finish(false);
|
|
||||||
audit.flush().await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if user_prompt.trim().is_empty() {
|
if user_prompt.trim().is_empty() {
|
||||||
audit.finish(false);
|
|
||||||
audit.flush().await;
|
|
||||||
return Err("聊天内容不能为空".to_string());
|
return Err("聊天内容不能为空".to_string());
|
||||||
}
|
}
|
||||||
let canonical_user_item =
|
let canonical_user_item =
|
||||||
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
|
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
|
||||||
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()).await {
|
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?),
|
Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?),
|
||||||
Err(error) => {
|
Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)),
|
||||||
audit.finish(false);
|
|
||||||
audit.flush().await;
|
|
||||||
return Err(redact_agent_runtime_error(root, &error, 1800));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||||
root,
|
root,
|
||||||
&user_prompt,
|
&user_prompt,
|
||||||
creation_type.as_deref(),
|
creation_type.as_deref(),
|
||||||
Some(&turn_emitter),
|
Some(&turn_emitter),
|
||||||
Some(&mut audit),
|
// DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。
|
||||||
|
None,
|
||||||
canonical_user_item,
|
canonical_user_item,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(reply) => reply,
|
Ok(reply) => reply,
|
||||||
Err(error) => {
|
Err(error) => return Err(error),
|
||||||
audit.finish(false);
|
|
||||||
audit.flush().await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
audit.finish(true);
|
|
||||||
audit.flush().await;
|
|
||||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
||||||
Ok(reply)
|
Ok(reply)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! DirectProject 聊天事件的线上模型与投影。
|
//! DirectProject 聊天事件的线上模型与投影。
|
||||||
//!
|
//!
|
||||||
//! 前端消费的类型由 ts-rs 导出到 `src/features/project-workspace/generated/`,
|
//! 前端消费的类型由 ts-rs 导出到 `src/view/project-development/chat/generated/`,
|
||||||
//! 与 Rust 定义同源:加一个字段不会只改一边。
|
//! 与 Rust 定义同源:加一个字段不会只改一边。
|
||||||
//!
|
//!
|
||||||
//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与
|
//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与
|
||||||
@@ -29,7 +29,7 @@ const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300;
|
|||||||
/// 一条文件变更。
|
/// 一条文件变更。
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) struct DirectThreadFileChange {
|
pub(crate) struct DirectThreadFileChange {
|
||||||
pub(crate) path: String,
|
pub(crate) path: String,
|
||||||
/// `add` | `update` | `delete`
|
/// `add` | `update` | `delete`
|
||||||
@@ -45,7 +45,7 @@ pub(crate) struct DirectThreadFileChange {
|
|||||||
/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。
|
/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)]
|
#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) enum DirectThreadItem {
|
pub(crate) enum DirectThreadItem {
|
||||||
#[serde(rename = "message")]
|
#[serde(rename = "message")]
|
||||||
Message {
|
Message {
|
||||||
@@ -179,7 +179,7 @@ impl DirectThreadItem {
|
|||||||
/// 增量正文属于哪类条目。
|
/// 增量正文属于哪类条目。
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) enum DirectThreadDeltaKind {
|
pub(crate) enum DirectThreadDeltaKind {
|
||||||
/// assistant 正文。
|
/// assistant 正文。
|
||||||
Message,
|
Message,
|
||||||
@@ -190,7 +190,7 @@ pub(crate) enum DirectThreadDeltaKind {
|
|||||||
/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。
|
/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) enum DirectThreadRequestKind {
|
pub(crate) enum DirectThreadRequestKind {
|
||||||
#[serde(rename = "approval.requested")]
|
#[serde(rename = "approval.requested")]
|
||||||
ApprovalRequested,
|
ApprovalRequested,
|
||||||
@@ -235,7 +235,7 @@ impl DirectThreadRequestKind {
|
|||||||
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
|
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) enum DirectThreadEvent {
|
pub(crate) enum DirectThreadEvent {
|
||||||
#[serde(rename = "turn.started")]
|
#[serde(rename = "turn.started")]
|
||||||
TurnStarted {
|
TurnStarted {
|
||||||
@@ -398,7 +398,7 @@ impl DirectThreadEvent {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) struct DirectThreadSubscriptionBootstrap {
|
pub(crate) struct DirectThreadSubscriptionBootstrap {
|
||||||
pub(crate) subscription_id: String,
|
pub(crate) subscription_id: String,
|
||||||
/// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。
|
/// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。
|
||||||
@@ -410,14 +410,14 @@ pub(crate) struct DirectThreadSubscriptionBootstrap {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) struct DirectThreadConsumeResult {
|
pub(crate) struct DirectThreadConsumeResult {
|
||||||
pub(crate) events: Vec<DirectThreadEvent>,
|
pub(crate) events: Vec<DirectThreadEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||||
pub(crate) struct DirectThreadHistorySlice {
|
pub(crate) struct DirectThreadHistorySlice {
|
||||||
/// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。
|
/// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。
|
||||||
pub(crate) items: Vec<DirectThreadItem>,
|
pub(crate) items: Vec<DirectThreadItem>,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
@@ -143,6 +143,13 @@ struct AgcSkillManifestEntry {
|
|||||||
sha256: String,
|
sha256: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct AgcSkillCatalogEntry {
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) description: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn is_safe_skill_relative_path(value: &str) -> bool {
|
fn is_safe_skill_relative_path(value: &str) -> bool {
|
||||||
let path = Path::new(value);
|
let path = Path::new(value);
|
||||||
!value.is_empty()
|
!value.is_empty()
|
||||||
@@ -256,6 +263,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
|
|||||||
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
|
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。
|
||||||
|
///
|
||||||
|
/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn list_agc_skill_catalog() -> Result<Vec<AgcSkillCatalogEntry>, String> {
|
||||||
|
Ok(validated_skill_pack_manifest()?
|
||||||
|
.skills
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| AgcSkillCatalogEntry {
|
||||||
|
name: entry.name,
|
||||||
|
description: entry.purpose,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
|
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
|
||||||
let manifest = validated_skill_pack_manifest()?;
|
let manifest = validated_skill_pack_manifest()?;
|
||||||
let mut lines = vec![format!(
|
let mut lines = vec![format!(
|
||||||
@@ -350,6 +372,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_catalog_is_derived_from_the_validated_manifest() {
|
||||||
|
let catalog = list_agc_skill_catalog().expect("skill catalog");
|
||||||
|
assert_eq!(catalog.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len());
|
||||||
|
for expected_name in AGC_SKILL_PACK_EXPECTED_NAMES {
|
||||||
|
let entry = catalog
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == expected_name)
|
||||||
|
.expect("expected bundled skill");
|
||||||
|
assert!(!entry.description.trim().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skill_content_digest_is_stable_across_lf_and_crlf() {
|
fn skill_content_digest_is_stable_across_lf_and_crlf() {
|
||||||
fn digest(bytes: &[u8]) -> String {
|
fn digest(bytes: &[u8]) -> String {
|
||||||
|
|||||||
@@ -887,37 +887,6 @@ pub(crate) async fn pick_local_project_directory(
|
|||||||
Ok(Some(path.to_string_lossy().into_owned()))
|
Ok(Some(path.to_string_lossy().into_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result<Option<String>, String> {
|
|
||||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
|
||||||
let mut dialog = app.dialog().file().set_title("选择本地文件");
|
|
||||||
if let Some(window) = app.get_webview_window("client") {
|
|
||||||
dialog = dialog.set_parent(&window);
|
|
||||||
}
|
|
||||||
dialog.pick_file(move |path| {
|
|
||||||
let _ = sender.send(path);
|
|
||||||
});
|
|
||||||
let Some(path) = receiver
|
|
||||||
.await
|
|
||||||
.map_err(|_| "本地文件选择器意外关闭".to_string())?
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let path = path
|
|
||||||
.into_path()
|
|
||||||
.map_err(|error| format!("读取本地文件失败:{error}"))?;
|
|
||||||
#[cfg(windows)]
|
|
||||||
crate::register_game_creator_user_selected_path(&path, false);
|
|
||||||
if let Err(error) =
|
|
||||||
crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择文件")
|
|
||||||
{
|
|
||||||
#[cfg(windows)]
|
|
||||||
crate::revoke_game_creator_user_selected_path(&path);
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
Ok(Some(path.to_string_lossy().into_owned()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn open_local_project_directory(
|
pub(crate) fn open_local_project_directory(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
@@ -5688,18 +5657,6 @@ pub(crate) fn write_local_project_file(
|
|||||||
write_local_project_file_at(root, relative_path.trim(), &content)
|
write_local_project_file_at(root, relative_path.trim(), &content)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) fn delete_local_project_file(
|
|
||||||
project_path: String,
|
|
||||||
relative_path: String,
|
|
||||||
) -> Result<LocalProjectFileMutationResult, String> {
|
|
||||||
let root = Path::new(project_path.trim());
|
|
||||||
enforce_project_permission_policy(root, "file.delete")?;
|
|
||||||
let _lock = acquire_project_write_lock(root, "file.delete")?;
|
|
||||||
advance_agent_runtime_project_revision_locked(root)?;
|
|
||||||
delete_local_project_file_at(root, relative_path.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn read_local_game_memory(
|
pub(crate) fn read_local_game_memory(
|
||||||
project_path: String,
|
project_path: String,
|
||||||
@@ -5748,18 +5705,6 @@ pub(crate) fn write_local_game_memory(
|
|||||||
write_local_game_memory_at(root, scope.trim(), &content)
|
write_local_game_memory_at(root, scope.trim(), &content)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) fn delete_local_game_memory(
|
|
||||||
project_path: String,
|
|
||||||
scope: String,
|
|
||||||
) -> Result<LocalGameMemoryResult, String> {
|
|
||||||
let root = Path::new(project_path.trim());
|
|
||||||
enforce_project_permission_policy(root, "memory.delete")?;
|
|
||||||
let _lock = acquire_project_write_lock(root, "memory.delete")?;
|
|
||||||
advance_agent_runtime_project_revision_locked(root)?;
|
|
||||||
delete_local_game_memory_at(root, scope.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn list_game_creator_agent_sessions(
|
pub(crate) fn list_game_creator_agent_sessions(
|
||||||
project_path: String,
|
project_path: String,
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ mod tool_plan_handoff;
|
|||||||
mod user_input;
|
mod user_input;
|
||||||
mod windows;
|
mod windows;
|
||||||
|
|
||||||
|
use agent::design_tools::*;
|
||||||
use agent::*;
|
use agent::*;
|
||||||
use agent_native_tools::*;
|
use agent_native_tools::*;
|
||||||
use asset_generation_tasks::*;
|
use asset_generation_tasks::*;
|
||||||
@@ -2577,10 +2578,10 @@ fn main() {
|
|||||||
rename_local_game_project,
|
rename_local_game_project,
|
||||||
suggest_automatic_project_name,
|
suggest_automatic_project_name,
|
||||||
polish_local_project_prompt,
|
polish_local_project_prompt,
|
||||||
pick_local_file,
|
|
||||||
pick_client_extension_file,
|
pick_client_extension_file,
|
||||||
pick_client_extension_directory,
|
pick_client_extension_directory,
|
||||||
list_client_extensions,
|
list_client_extensions,
|
||||||
|
list_agc_skill_catalog,
|
||||||
import_client_extension,
|
import_client_extension,
|
||||||
set_client_extension_enabled,
|
set_client_extension_enabled,
|
||||||
rename_client_extension,
|
rename_client_extension,
|
||||||
@@ -2613,6 +2614,7 @@ fn main() {
|
|||||||
debug_fast_forward_design_session,
|
debug_fast_forward_design_session,
|
||||||
continue_design_agent_session,
|
continue_design_agent_session,
|
||||||
decide_design_phase,
|
decide_design_phase,
|
||||||
|
import_design_workspace_file,
|
||||||
list_design_workspace,
|
list_design_workspace,
|
||||||
read_design_workspace_file,
|
read_design_workspace_file,
|
||||||
start_game_creator_agent_runtime_task,
|
start_game_creator_agent_runtime_task,
|
||||||
@@ -2694,12 +2696,10 @@ fn main() {
|
|||||||
read_local_project_media_preview,
|
read_local_project_media_preview,
|
||||||
cancel_local_project_resource_preview_scope,
|
cancel_local_project_resource_preview_scope,
|
||||||
write_local_project_file,
|
write_local_project_file,
|
||||||
delete_local_project_file,
|
|
||||||
read_local_game_memory,
|
read_local_game_memory,
|
||||||
read_local_agent_memory,
|
read_local_agent_memory,
|
||||||
write_local_agent_memory,
|
write_local_agent_memory,
|
||||||
write_local_game_memory,
|
write_local_game_memory,
|
||||||
delete_local_game_memory,
|
|
||||||
list_game_creator_agent_sessions,
|
list_game_creator_agent_sessions,
|
||||||
create_game_creator_agent_session,
|
create_game_creator_agent_session,
|
||||||
fork_game_creator_agent_session,
|
fork_game_creator_agent_session,
|
||||||
@@ -2724,7 +2724,6 @@ fn main() {
|
|||||||
write_project_permission_policy,
|
write_project_permission_policy,
|
||||||
open_game_creator_workspace_window,
|
open_game_creator_workspace_window,
|
||||||
open_game_creator_launcher_window,
|
open_game_creator_launcher_window,
|
||||||
open_project_supervisor_chat_window,
|
|
||||||
start_local_game_preview,
|
start_local_game_preview,
|
||||||
activate_local_game_preview,
|
activate_local_game_preview,
|
||||||
stop_local_game_preview,
|
stop_local_game_preview,
|
||||||
|
|||||||
@@ -451,18 +451,6 @@ fn dispatch_static_delegate_plain_repair(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn supervisor_chat_window_carries_encoded_project_path() {
|
|
||||||
assert_eq!(
|
|
||||||
supervisor_chat_window_url("/tmp/AI Game 项目").to_string(),
|
|
||||||
"index.html?supervisor-chat&projectPath=%2Ftmp%2FAI%20Game%20%E9%A1%B9%E7%9B%AE"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
supervisor_chat_window_url("/tmp/a&b?#%+c").to_string(),
|
|
||||||
"index.html?supervisor-chat&projectPath=%2Ftmp%2Fa%26b%3F%23%25%2Bc"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_supervisor_runtime_id_is_normalized_and_collected() {
|
fn project_supervisor_runtime_id_is_normalized_and_collected() {
|
||||||
let root = unique_project_path();
|
let root = unique_project_path();
|
||||||
|
|||||||
@@ -2777,7 +2777,7 @@ fn agent_run_history_prunes_to_latest_hundred_traces() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn developer_project_file_and_memory_mutations_advance_project_revision() {
|
fn developer_project_file_and_memory_writes_advance_project_revision() {
|
||||||
let root = unique_project_path();
|
let root = unique_project_path();
|
||||||
init_local_game_project_at(&root, "project-1", "开发面板 revision").expect("project init");
|
init_local_game_project_at(&root, "project-1", "开发面板 revision").expect("project init");
|
||||||
let project_path = root.to_string_lossy().into_owned();
|
let project_path = root.to_string_lossy().into_owned();
|
||||||
@@ -2800,16 +2800,11 @@ fn developer_project_file_and_memory_mutations_advance_project_revision() {
|
|||||||
"项目记忆".to_string(),
|
"项目记忆".to_string(),
|
||||||
)
|
)
|
||||||
.expect("write project memory through command");
|
.expect("write project memory through command");
|
||||||
delete_local_game_memory(project_path.clone(), "long".to_string())
|
|
||||||
.expect("delete project memory through command");
|
|
||||||
delete_local_project_file(project_path, "game/revision.txt".to_string())
|
|
||||||
.expect("delete project file through command");
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
read_game_creator_agent_runtime_project_revision(&root)
|
read_game_creator_agent_runtime_project_revision(&root)
|
||||||
.expect("read developer mutation revision")
|
.expect("read developer mutation revision")
|
||||||
.revision,
|
.revision,
|
||||||
5
|
3
|
||||||
);
|
);
|
||||||
fs::remove_dir_all(root).ok();
|
fs::remove_dir_all(root).ok();
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user