Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5315b233c8 | |||
| a11cdf13d7 | |||
| 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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -2577,10 +2577,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,
|
||||||
@@ -2694,12 +2694,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 +2722,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,13 +119,6 @@ pub(crate) fn launcher_window_url() -> tauri::WebviewUrl {
|
|||||||
tauri::WebviewUrl::App(PathBuf::from("index.html?launcher"))
|
tauri::WebviewUrl::App(PathBuf::from("index.html?launcher"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUrl {
|
|
||||||
tauri::WebviewUrl::App(PathBuf::from(format!(
|
|
||||||
"index.html?supervisor-chat&projectPath={}",
|
|
||||||
percent_encode_query_value(project_path)
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> {
|
pub(crate) fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> {
|
||||||
let project_path = project_path.trim();
|
let project_path = project_path.trim();
|
||||||
if project_path.is_empty() {
|
if project_path.is_empty() {
|
||||||
@@ -193,52 +186,3 @@ pub(crate) fn open_game_creator_launcher_window(
|
|||||||
window.close().map_err(|error| error.to_string())?;
|
window.close().map_err(|error| error.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) fn open_project_supervisor_chat_window(
|
|
||||||
app: tauri::AppHandle,
|
|
||||||
project_path: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let project_path = validate_workspace_window_project_path(&project_path)?;
|
|
||||||
#[cfg(not(debug_assertions))]
|
|
||||||
{
|
|
||||||
let _ = app;
|
|
||||||
let _ = project_path;
|
|
||||||
return Err("项目总控对话窗口仅在开发构建中可用".to_string());
|
|
||||||
}
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
{
|
|
||||||
if let Some(existing) = app.get_webview_window("supervisor-chat") {
|
|
||||||
let mut current_url = existing.url().map_err(|error| error.to_string())?;
|
|
||||||
let current_project_path = current_url
|
|
||||||
.query_pairs()
|
|
||||||
.find_map(|(key, value)| (key == "projectPath").then(|| value.into_owned()));
|
|
||||||
if current_project_path.as_deref() != Some(project_path) {
|
|
||||||
current_url.set_query(Some(&format!(
|
|
||||||
"supervisor-chat&projectPath={}",
|
|
||||||
percent_encode_query_value(project_path)
|
|
||||||
)));
|
|
||||||
current_url.set_fragment(None);
|
|
||||||
existing
|
|
||||||
.navigate(current_url)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
}
|
|
||||||
existing.show().map_err(|error| error.to_string())?;
|
|
||||||
existing.unminimize().map_err(|error| error.to_string())?;
|
|
||||||
existing.set_focus().map_err(|error| error.to_string())?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
tauri::WebviewWindowBuilder::new(
|
|
||||||
&app,
|
|
||||||
"supervisor-chat",
|
|
||||||
supervisor_chat_window_url(project_path),
|
|
||||||
)
|
|
||||||
.title("项目总控 Agent 对话")
|
|
||||||
.decorations(false)
|
|
||||||
.inner_size(820.0, 720.0)
|
|
||||||
.min_inner_size(560.0, 480.0)
|
|
||||||
.build()
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+451
-10339
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user