Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edfd1f9143 | |||
| 423f2d7184 | |||
| aa9076c95c | |||
| 41366dd71d | |||
| 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 |
@@ -7,10 +7,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: project-ci-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
|
|
||||||
# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
|
|
||||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
|
|
||||||
npm run format:staged
|
npm run format:staged
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
|
|
||||||
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
|
|
||||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
|
|
||||||
npm run check:pre-push-master -- "$@"
|
npm run check:pre-push-master -- "$@"
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ vi.mock('../api/adminApiClient', () => ({
|
|||||||
|
|
||||||
interface MockIntersectionObserverController {
|
interface MockIntersectionObserverController {
|
||||||
enter: (target: Element) => void;
|
enter: (target: Element) => void;
|
||||||
enterAll: (targets: Element[]) => void;
|
|
||||||
isObserved: (target: Element) => boolean;
|
isObserved: (target: Element) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,25 +106,6 @@ function installIntersectionObserverMock(): MockIntersectionObserverController {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enterAll(targets) {
|
|
||||||
act(() => {
|
|
||||||
for (const target of targets) {
|
|
||||||
const record = observed.get(target);
|
|
||||||
if (!record) {
|
|
||||||
throw new Error('目标缩略图尚未进入 IntersectionObserver');
|
|
||||||
}
|
|
||||||
record.callback(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
isIntersecting: true,
|
|
||||||
target,
|
|
||||||
} as IntersectionObserverEntry,
|
|
||||||
],
|
|
||||||
record.observer,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
isObserved(target) {
|
isObserved(target) {
|
||||||
return observed.has(target);
|
return observed.has(target);
|
||||||
},
|
},
|
||||||
@@ -773,10 +753,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
|||||||
const thumbnails = entries.map((entry) =>
|
const thumbnails = entries.map((entry) =>
|
||||||
thumbnailElementForLabel(entry.label),
|
thumbnailElementForLabel(entry.label),
|
||||||
);
|
);
|
||||||
for (const thumbnail of thumbnails) {
|
thumbnails.forEach((thumbnail) => {
|
||||||
expect(observer.isObserved(thumbnail)).toBe(true);
|
expect(observer.isObserved(thumbnail)).toBe(true);
|
||||||
}
|
observer.enter(thumbnail);
|
||||||
observer.enterAll(thumbnails);
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
@@ -796,7 +776,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
|||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
});
|
});
|
||||||
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
||||||
}, 10_000);
|
});
|
||||||
|
|
||||||
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
||||||
const observer = installIntersectionObserverMock();
|
const observer = installIntersectionObserverMock();
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
"schemaVersion": "game-creator-config.v2",
|
"schemaVersion": "game-creator-config.v2",
|
||||||
"agentMode": "codex_app_server",
|
"agentMode": "codex_app_server",
|
||||||
"llm": {
|
"llm": {
|
||||||
"customEnabled": false,
|
|
||||||
"visibleModels": [],
|
|
||||||
"apiKey": "",
|
"apiKey": "",
|
||||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||||
"model": "gpt-6-astra",
|
"model": "gpt-6-astra",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@genarrative/ai-game-creator-shell",
|
"name": "@genarrative/ai-game-creator-shell",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.47",
|
"version": "0.1.29",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/start-tauri-dev.mjs",
|
"dev": "node scripts/start-tauri-dev.mjs",
|
||||||
|
|||||||
+1
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "genarrative-ai-game-creator-shell"
|
name = "genarrative-ai-game-creator-shell"
|
||||||
version = "0.1.47"
|
version = "0.1.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-runtime-core",
|
"agent-runtime-core",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "genarrative-ai-game-creator-shell"
|
name = "genarrative-ai-game-creator-shell"
|
||||||
version = "0.1.47"
|
version = "0.1.29"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
{"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
|
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
{"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
{"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result
|
|||||||
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
|
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
|
||||||
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
|
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
|
||||||
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image.
|
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image.
|
||||||
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
|
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
|
||||||
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
|
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
|
||||||
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
|
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
|
||||||
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
|
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
|
|||||||
|
|
||||||
`agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity.
|
`agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity.
|
||||||
|
|
||||||
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
|
`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": "agc-skill-pack.v1",
|
"schemaVersion": "agc-skill-pack.v1",
|
||||||
"version": "2026-08-26.17",
|
"version": "2026-08-26.16",
|
||||||
"skills": [
|
"skills": [
|
||||||
{
|
{
|
||||||
"name": "agc-game-production-workflow",
|
"name": "agc-game-production-workflow",
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
"agents/openai.yaml",
|
"agents/openai.yaml",
|
||||||
"references/projection-contract.md"
|
"references/projection-contract.md"
|
||||||
],
|
],
|
||||||
"sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1"
|
"sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ impl CodexAppServerCredential {
|
|||||||
) -> Option<(&'a str, &'a str)> {
|
) -> Option<(&'a str, &'a str)> {
|
||||||
match self {
|
match self {
|
||||||
Self::PlatformSession { .. } => None,
|
Self::PlatformSession { .. } => None,
|
||||||
|
#[cfg(test)]
|
||||||
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
|
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
|
||||||
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
|
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -138,7 +139,7 @@ impl CodexAppServerCredential {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
|
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
Self::AuthBridge { .. } => None,
|
Self::AppDataKey { .. } | Self::AuthBridge { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,20 +196,6 @@ impl CodexAppServerStderrSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 派发 app-server 的收尾与中断任务。
|
|
||||||
///
|
|
||||||
/// 这个入口会被**没有 tokio runtime 上下文的线程**调用:`cancel_direct_codex_turn`
|
|
||||||
/// 是同步 Tauri 命令,直接跑在 IPC 回调线程(Windows 上是 WebView2 的 UI 线程);
|
|
||||||
/// [`CodexThreadLease`] 与 [`CodexTurnGuard`] 的 `Drop` 也在调用方线程上执行。
|
|
||||||
/// `tokio::spawn` 在那样的线程上会经 `Handle::current()` panic("there is no reactor
|
|
||||||
/// running"),而 panic 跨不过 Tauri 的 IPC 回调边界,整个进程会以 `0xC0000409`
|
|
||||||
/// (FAST_FAIL_FATAL_APP_EXIT)abort——现场就是"点终止,App 闪退"(2026-09-16 的 WER
|
|
||||||
/// 记录:`genarrative-ai-game-creator-shell.exe`,异常代码 `0xc0000409`,fail-fast
|
|
||||||
/// 参数 `7`)。一律走 Tauri 的全局异步 runtime:`main` 已把深栈 runtime 装进去。
|
|
||||||
fn spawn_codex_app_server_task(task: impl std::future::Future<Output = ()> + Send + 'static) {
|
|
||||||
tauri::async_runtime::spawn(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CodexTurnStartCancellation {
|
struct CodexTurnStartCancellation {
|
||||||
inner: Weak<CodexAppServerInner>,
|
inner: Weak<CodexAppServerInner>,
|
||||||
thread_id: String,
|
thread_id: String,
|
||||||
@@ -270,7 +257,7 @@ impl CodexTurnStartCancellation {
|
|||||||
};
|
};
|
||||||
let connection = CodexAppServerConnection { inner };
|
let connection = CodexAppServerConnection { inner };
|
||||||
let thread_id = self.thread_id.clone();
|
let thread_id = self.thread_id.clone();
|
||||||
spawn_codex_app_server_task(async move {
|
tokio::spawn(async move {
|
||||||
let _ = connection
|
let _ = connection
|
||||||
.request(
|
.request(
|
||||||
"turn/interrupt",
|
"turn/interrupt",
|
||||||
@@ -2031,13 +2018,7 @@ impl CodexAppServerConnection {
|
|||||||
let codex_cli_version = game_creator_codex_cli_version_identity()
|
let codex_cli_version = game_creator_codex_cli_version_identity()
|
||||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||||
let mut effective_llm = llm.clone();
|
let mut effective_llm = llm.clone();
|
||||||
let credential = if llm.custom_enabled {
|
let credential = if game_creator_official_llm_route_locked() {
|
||||||
crate::config::validate_custom_llm_connection(llm)
|
|
||||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
|
||||||
CodexAppServerCredential::AppDataKey {
|
|
||||||
fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())),
|
|
||||||
}
|
|
||||||
} else if game_creator_official_llm_route_locked() {
|
|
||||||
let session = current_platform_session().ok_or_else(|| {
|
let session = current_platform_session().ok_or_else(|| {
|
||||||
platform_llm::LlmError::InvalidConfig(
|
platform_llm::LlmError::InvalidConfig(
|
||||||
"authentication-required: 请先登录陶泥儿账号".to_string(),
|
"authentication-required: 请先登录陶泥儿账号".to_string(),
|
||||||
@@ -2205,8 +2186,7 @@ impl CodexAppServerConnection {
|
|||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
_ => (
|
_ => (
|
||||||
(llm.custom_enabled
|
(workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||||
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
|
||||||
.then(|| credential.direct_provider_route(llm))
|
.then(|| credential.direct_provider_route(llm))
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
|
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
|
||||||
@@ -3580,7 +3560,7 @@ impl Drop for CodexThreadLease {
|
|||||||
let connection = self.connection.clone();
|
let connection = self.connection.clone();
|
||||||
let key = self.key.clone();
|
let key = self.key.clone();
|
||||||
let thread_id = self.thread_id.clone();
|
let thread_id = self.thread_id.clone();
|
||||||
spawn_codex_app_server_task(async move {
|
tokio::spawn(async move {
|
||||||
let mut threads = connection.inner.threads.lock().await;
|
let mut threads = connection.inner.threads.lock().await;
|
||||||
if let Some(entry) = threads.get_mut(&key) {
|
if let Some(entry) = threads.get_mut(&key) {
|
||||||
if entry.thread_id == thread_id {
|
if entry.thread_id == thread_id {
|
||||||
@@ -3607,7 +3587,7 @@ impl Drop for CodexTurnGuard {
|
|||||||
let connection = self.connection.clone();
|
let connection = self.connection.clone();
|
||||||
let thread_id = self.thread_id.clone();
|
let thread_id = self.thread_id.clone();
|
||||||
let turn_id = self.turn_id.clone();
|
let turn_id = self.turn_id.clone();
|
||||||
spawn_codex_app_server_task(async move {
|
tokio::spawn(async move {
|
||||||
connection.inner.turns.lock().await.remove(&turn_id);
|
connection.inner.turns.lock().await.remove(&turn_id);
|
||||||
connection.inner.turn_backlog.lock().await.remove(&turn_id);
|
connection.inner.turn_backlog.lock().await.remove(&turn_id);
|
||||||
let _ = connection
|
let _ = connection
|
||||||
@@ -4503,19 +4483,6 @@ mod tests {
|
|||||||
assert!(table.select(&key, None).is_err());
|
assert!(table.select(&key, None).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio
|
|
||||||
/// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个
|
|
||||||
/// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime
|
|
||||||
/// 上下文的线程上,回退到 `tokio::spawn` 时它会失败。
|
|
||||||
#[test]
|
|
||||||
fn codex_app_server_task_dispatch_needs_no_tokio_runtime_context() {
|
|
||||||
let joined = std::thread::spawn(|| spawn_codex_app_server_task(async {}));
|
|
||||||
assert!(
|
|
||||||
joined.join().is_ok(),
|
|
||||||
"没有 tokio runtime 上下文的线程也必须能派发 app-server 收尾任务"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
|
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
|
||||||
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4776,8 +4743,6 @@ mod tests {
|
|||||||
|
|
||||||
fn test_llm() -> GameCreatorLlmConfig {
|
fn test_llm() -> GameCreatorLlmConfig {
|
||||||
GameCreatorLlmConfig {
|
GameCreatorLlmConfig {
|
||||||
custom_enabled: false,
|
|
||||||
visible_models: Vec::new(),
|
|
||||||
api_key: "fixture-secret".to_string(),
|
api_key: "fixture-secret".to_string(),
|
||||||
base_url: "https://example.invalid/v1".to_string(),
|
base_url: "https://example.invalid/v1".to_string(),
|
||||||
model: "fixture-model".to_string(),
|
model: "fixture-model".to_string(),
|
||||||
@@ -5563,59 +5528,6 @@ mod tests {
|
|||||||
assert_ne!(command_token, provider_key);
|
assert_ne!(command_token, provider_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() {
|
|
||||||
let mut llm = test_llm();
|
|
||||||
llm.custom_enabled = true;
|
|
||||||
llm.api_key = "custom-upstream-fixture-secret".into();
|
|
||||||
llm.base_url = "http://127.0.0.1:9/v1".into();
|
|
||||||
llm.model = "vendor/model.v1:latest".into();
|
|
||||||
llm.visible_models = vec![llm.model.clone()];
|
|
||||||
let credential = CodexAppServerCredential::AppDataKey {
|
|
||||||
fingerprint: "custom-fixture".into(),
|
|
||||||
};
|
|
||||||
let (base, key) = credential
|
|
||||||
.direct_provider_route(&llm)
|
|
||||||
.expect("custom route");
|
|
||||||
assert_eq!(base, llm.base_url);
|
|
||||||
assert_eq!(key, llm.api_key);
|
|
||||||
let proxy = start_codex_provider_proxy(base, key, false).await.unwrap();
|
|
||||||
for mode in [
|
|
||||||
CodexAppServerWorkspaceMode::DirectProject,
|
|
||||||
CodexAppServerWorkspaceMode::ToolHost,
|
|
||||||
] {
|
|
||||||
let mut command = tokio::process::Command::new("fixture");
|
|
||||||
configure_game_creator_codex_app_server_command_for_mode(
|
|
||||||
&mut command,
|
|
||||||
&llm,
|
|
||||||
mode,
|
|
||||||
Some(&proxy),
|
|
||||||
None,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let arguments = command
|
|
||||||
.as_std()
|
|
||||||
.get_args()
|
|
||||||
.map(|arg| arg.to_string_lossy())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
let params = codex_app_server_thread_start_params(
|
|
||||||
&llm.model,
|
|
||||||
std::path::Path::new("fixture-workspace"),
|
|
||||||
mode,
|
|
||||||
String::new(),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert_eq!(params["model"], "vendor/model.v1:latest");
|
|
||||||
assert!(!arguments.contains(&llm.api_key));
|
|
||||||
assert!(!arguments.contains("/api/llm"));
|
|
||||||
for (_, value) in command.as_std().get_envs() {
|
|
||||||
assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
|
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
|
||||||
|
|||||||
@@ -527,6 +527,10 @@ fn process_design_batch(
|
|||||||
let result = if uncertain {
|
let result = if uncertain {
|
||||||
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
|
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
|
||||||
} else {
|
} else {
|
||||||
|
let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||||
|
root,
|
||||||
|
"design.tool",
|
||||||
|
)?;
|
||||||
execute_design_tool(root, resources, session, &call)
|
execute_design_tool(root, resources, session, &call)
|
||||||
};
|
};
|
||||||
let error = result
|
let error = result
|
||||||
@@ -1022,15 +1026,6 @@ pub(crate) async fn continue_design_agent_at(
|
|||||||
finish_design_command(root, resources, session, active, run, emit).await
|
finish_design_command(root, resources, session, active, run, emit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recover_uncertain_design_batch(
|
|
||||||
root: &Path,
|
|
||||||
resources: &DesignResources,
|
|
||||||
session: DesignSession,
|
|
||||||
active: File,
|
|
||||||
) -> Result<DesignView, String> {
|
|
||||||
finish_design_command(root, resources, session, active, true, |_| {}).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn decide_design_phase_at(
|
pub(crate) async fn decide_design_phase_at(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
resources: &DesignResources,
|
resources: &DesignResources,
|
||||||
@@ -1063,8 +1058,7 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn hydrate_design_agent_session(
|
pub(crate) fn hydrate_design_agent_session(
|
||||||
app: tauri::AppHandle,
|
|
||||||
project_path: String,
|
project_path: String,
|
||||||
) -> Result<Option<DesignView>, String> {
|
) -> Result<Option<DesignView>, String> {
|
||||||
let root = Path::new(project_path.trim());
|
let root = Path::new(project_path.trim());
|
||||||
@@ -1090,33 +1084,8 @@ pub(crate) async fn hydrate_design_agent_session(
|
|||||||
if session.project_id != project_id {
|
if session.project_id != project_id {
|
||||||
return Err("策划会话与当前项目不匹配".into());
|
return Err("策划会话与当前项目不匹配".into());
|
||||||
}
|
}
|
||||||
let Some(active) =
|
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
|
||||||
try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
|
Ok(Some(design_view(&session, active.is_none())))
|
||||||
else {
|
|
||||||
return Ok(Some(design_view(&session, true)));
|
|
||||||
};
|
|
||||||
if design_session_has_uncertain_batch(&session) {
|
|
||||||
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
|
|
||||||
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
|
|
||||||
return Ok(Some(view));
|
|
||||||
}
|
|
||||||
drop(active);
|
|
||||||
Ok(Some(design_view(&session, false)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn design_session_has_uncertain_batch(session: &DesignSession) -> bool {
|
|
||||||
let Some(batch) = session.pending_batch.as_ref() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if !batch.executing || batch.cursor >= batch.calls.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let call_id = batch.calls[batch.cursor].id.as_str();
|
|
||||||
session.turn.as_ref().is_some_and(|turn| turn.pending)
|
|
||||||
&& !session.history.iter().any(|item| {
|
|
||||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
|
||||||
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn design_session_error_is_recoverable(error: &str) -> bool {
|
fn design_session_error_is_recoverable(error: &str) -> bool {
|
||||||
@@ -1989,94 +1958,4 @@ mod tests {
|
|||||||
.any(|message| message.text.contains("重试后继续")));
|
.any(|message| message.text.contains("重试后继续")));
|
||||||
assert!(next.session.last_error.is_none());
|
assert!(next.session.last_error.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
|
|
||||||
let (_temp, root, resources) = init_design_project();
|
|
||||||
execute_design_file_tool(
|
|
||||||
&root,
|
|
||||||
"write_file",
|
|
||||||
&json!({"path":"project/00_concept/design.md","content":"概念"}),
|
|
||||||
)
|
|
||||||
.expect("write concept");
|
|
||||||
let mut session = new_design_session("design-fake", "quality");
|
|
||||||
let call = platform_llm::LlmToolCall {
|
|
||||||
id: "interrupted-call".into(),
|
|
||||||
name: "patch_file".into(),
|
|
||||||
arguments: json!({
|
|
||||||
"path":"project/00_concept/design.md",
|
|
||||||
"old_text":"概念",
|
|
||||||
"new_text":"概念设计"
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
};
|
|
||||||
session.history.push(json!({
|
|
||||||
"type":"function_call",
|
|
||||||
"call_id":call.id,
|
|
||||||
"name":call.name,
|
|
||||||
"arguments":call.arguments,
|
|
||||||
}));
|
|
||||||
session.messages = vec![DesignMessage {
|
|
||||||
id: "turn:user".into(),
|
|
||||||
role: "user".into(),
|
|
||||||
text: "继续".into(),
|
|
||||||
}];
|
|
||||||
session.turn = Some(DesignTurn {
|
|
||||||
id: "turn-recovery".into(),
|
|
||||||
pending: true,
|
|
||||||
request_index: 0,
|
|
||||||
attempt: 0,
|
|
||||||
});
|
|
||||||
session.pending_batch = Some(DesignToolBatch {
|
|
||||||
calls: vec![call],
|
|
||||||
cursor: 0,
|
|
||||||
executing: true,
|
|
||||||
});
|
|
||||||
assert!(design_session_has_uncertain_batch(&session));
|
|
||||||
write_design_session(&root, &session).expect("write interrupted session");
|
|
||||||
|
|
||||||
let _fake = fake_provider::install(
|
|
||||||
vec![Ok(fake_response(
|
|
||||||
"recovered-after-uncertain-tool",
|
|
||||||
"已读取文件并确认。",
|
|
||||||
Vec::new(),
|
|
||||||
))],
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
let view = recover_uncertain_design_batch(&root, &resources, session, {
|
|
||||||
try_open_game_creator_agent_runtime_task_lock_file(
|
|
||||||
&root,
|
|
||||||
".agent/design-agent/active.lock",
|
|
||||||
)
|
|
||||||
.expect("open active lock")
|
|
||||||
.expect("active lock is free")
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("recover uncertain batch");
|
|
||||||
|
|
||||||
assert!(!view.running);
|
|
||||||
assert!(view.session.last_error.is_none());
|
|
||||||
let restored = read_design_session(&root)
|
|
||||||
.expect("read restored")
|
|
||||||
.expect("session");
|
|
||||||
assert!(restored.pending_batch.is_none());
|
|
||||||
assert!(!restored.turn.expect("turn").pending);
|
|
||||||
assert!(restored.history.iter().any(|item| {
|
|
||||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
|
||||||
&& item.get("call_id").and_then(Value::as_str) == Some("interrupted-call")
|
|
||||||
&& item
|
|
||||||
.get("output")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|output| output.contains("执行结果未保存"))
|
|
||||||
}));
|
|
||||||
assert!(restored.history.iter().any(|item| {
|
|
||||||
item.get("role").and_then(Value::as_str) == Some("assistant")
|
|
||||||
&& item.get("content").is_some()
|
|
||||||
}));
|
|
||||||
assert!(
|
|
||||||
fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md"))
|
|
||||||
.expect("read target")
|
|
||||||
== "概念"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,31 +321,15 @@ pub(crate) fn execute_design_file_tool(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
let mut edit_errors = Vec::new();
|
|
||||||
let mut valid_edits = 0;
|
|
||||||
for (index, (old, new)) in normalized.iter().enumerate() {
|
for (index, (old, new)) in normalized.iter().enumerate() {
|
||||||
if old == new {
|
|
||||||
edit_errors.push(format!(
|
|
||||||
"edits[{index}] new_text 与 old_text 相同,不会产生修改"
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let count = content.matches(old).count();
|
let count = content.matches(old).count();
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
edit_errors.push(format!(
|
return Err(format!("edits[{index}] 原文未找到:{display}"));
|
||||||
"edits[{index}] 原文未找到:{}{}",
|
|
||||||
display,
|
|
||||||
design_patch_location_hint(&content, old)
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
let start = content.find(old).expect("count checked");
|
return Err(format!(
|
||||||
let line = design_patch_line_number(&content, start);
|
"edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
|
||||||
edit_errors.push(format!(
|
|
||||||
"edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行"
|
|
||||||
));
|
));
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
let start = content.find(old).expect("count checked");
|
let start = content.find(old).expect("count checked");
|
||||||
let end = start + old.len();
|
let end = start + old.len();
|
||||||
@@ -353,33 +337,13 @@ pub(crate) fn execute_design_file_tool(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
||||||
{
|
{
|
||||||
edit_errors.push(format!(
|
return Err(format!(
|
||||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text"
|
"edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}"
|
||||||
));
|
));
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
matches.push((index, start, end));
|
matches.push((index, start, end));
|
||||||
valid_edits += 1;
|
|
||||||
let _ = new;
|
let _ = new;
|
||||||
}
|
}
|
||||||
if !edit_errors.is_empty() {
|
|
||||||
let shown = edit_errors.len().min(4);
|
|
||||||
let mut details = edit_errors[..shown].to_vec();
|
|
||||||
if shown < edit_errors.len() {
|
|
||||||
details.push(format!(
|
|
||||||
"另有 {} 个 edit 校验失败(详情省略)",
|
|
||||||
edit_errors.len() - shown
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if valid_edits > 0 {
|
|
||||||
details.push(format!(
|
|
||||||
"其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件"
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
details.push("本次未写入文件".to_string());
|
|
||||||
}
|
|
||||||
return Err(details.join("\n"));
|
|
||||||
}
|
|
||||||
let mut updated = content.clone();
|
let mut updated = content.clone();
|
||||||
for (index, start, end) in matches.into_iter().rev() {
|
for (index, start, end) in matches.into_iter().rev() {
|
||||||
let (_, new) = &normalized[index];
|
let (_, new) = &normalized[index];
|
||||||
@@ -432,60 +396,6 @@ pub(crate) fn execute_design_file_tool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn design_patch_line_number(content: &str, start: usize) -> usize {
|
|
||||||
1 + content[..start]
|
|
||||||
.bytes()
|
|
||||||
.filter(|byte| *byte == b'\n')
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn design_patch_visible_line(line: &str) -> String {
|
|
||||||
line.replace('\t', "\\t").chars().take(180).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn design_patch_location_hint(content: &str, old: &str) -> String {
|
|
||||||
let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else {
|
|
||||||
return String::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut candidates = content
|
|
||||||
.lines()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, line)| line.trim() == anchor)
|
|
||||||
.map(|(index, line)| (index + 1, line))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if candidates.is_empty() {
|
|
||||||
let token = anchor.split_whitespace().find(|token| token.len() >= 3);
|
|
||||||
if let Some(token) = token {
|
|
||||||
candidates = content
|
|
||||||
.lines()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, line)| line.trim().contains(token))
|
|
||||||
.map(|(index, line)| (index + 1, line))
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if candidates.is_empty() {
|
|
||||||
return format!(
|
|
||||||
";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)",
|
|
||||||
content.lines().count()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let details = candidates
|
|
||||||
.iter()
|
|
||||||
.take(2)
|
|
||||||
.map(|(line, text)| format!("第 {line} 行:{}", design_patch_visible_line(text)))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(";");
|
|
||||||
let suffix = if candidates.len() > 2 {
|
|
||||||
format!("等 {} 处", candidates.len())
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
format!(";old_text 首个非空行可能对应 {details}{suffix}(tab 显示为 \\t)")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn list_design_workspace_files(
|
pub(crate) fn list_design_workspace_files(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
||||||
@@ -783,22 +693,6 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect_err("escape");
|
.expect_err("escape");
|
||||||
assert!(escaped.contains("路径"));
|
assert!(escaped.contains("路径"));
|
||||||
let mismatch = execute_design_file_tool(
|
|
||||||
root,
|
|
||||||
"patch_file",
|
|
||||||
&json!({
|
|
||||||
"path":"notes/design.md",
|
|
||||||
"edits":[
|
|
||||||
{"old_text":" 游戏设计","new_text":"游戏概念"},
|
|
||||||
{"old_text":"设计","new_text":"方案"}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.expect_err("report all patch failures");
|
|
||||||
assert!(mismatch.contains("edits[0] 原文未找到"));
|
|
||||||
assert!(mismatch.contains("第 1 行:游戏设计"));
|
|
||||||
assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配"));
|
|
||||||
assert!(mismatch.contains("本次未写入文件"));
|
|
||||||
let patched = execute_design_file_tool(
|
let patched = execute_design_file_tool(
|
||||||
root,
|
root,
|
||||||
"patch_file",
|
"patch_file",
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ mod validation;
|
|||||||
mod wire;
|
mod wire;
|
||||||
|
|
||||||
pub(crate) use model::{
|
pub(crate) use model::{
|
||||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope,
|
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
||||||
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
DirectCodexUserMessageEnvelope, 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::{
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ pub(crate) enum DirectCodexUserContentPart {
|
|||||||
AgcResourceReference { resource_id: String },
|
AgcResourceReference { resource_id: 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)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ pub(crate) fn validate_direct_codex_user_item(
|
|||||||
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)?;
|
||||||
}
|
}
|
||||||
|
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||||
|
if reference.name.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!(reference.status.trim(), "imported" | "failed") {
|
||||||
|
return Err("附件状态无效".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||||
|
|||||||
@@ -100,6 +100,20 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
|||||||
summary.push(']');
|
summary.push(']');
|
||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
|
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||||
|
let mut summary = format!(
|
||||||
|
"[附件:名称={};类型={};大小={} 字节",
|
||||||
|
reference.name.trim(),
|
||||||
|
reference.media_type.trim(),
|
||||||
|
reference.size
|
||||||
|
);
|
||||||
|
if !reference.local_path.trim().is_empty() {
|
||||||
|
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
|
||||||
|
}
|
||||||
|
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
||||||
|
summary.push(']');
|
||||||
|
summary
|
||||||
|
}
|
||||||
};
|
};
|
||||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||||
}
|
}
|
||||||
@@ -174,4 +188,26 @@ mod tests {
|
|||||||
.expect_err("history item without type must fail");
|
.expect_err("history item without type must fail");
|
||||||
assert!(error.contains("缺少 type"), "{error}");
|
assert!(error.contains("缺少 type"), "{error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_parts_remain_in_canonical_order_when_projected() {
|
||||||
|
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": "先看"},
|
||||||
|
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||||
|
.expect("user response item should project");
|
||||||
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -31,10 +31,9 @@ pub(crate) fn normalize_direct_client_turn_id(
|
|||||||
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,
|
prompt: String,
|
||||||
mut user_item: DirectCodexUserItem,
|
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,24 +42,7 @@ 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(
|
let mut audit = DirectCodexTurnAudit::start(root, &turn_id, &prompt, &[]);
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
&prompt,
|
|
||||||
attachments.as_deref().unwrap_or_default(),
|
|
||||||
);
|
|
||||||
let attachments = attachments.unwrap_or_default();
|
|
||||||
if !attachments.is_empty() {
|
|
||||||
let attachment_context =
|
|
||||||
render_direct_codex_user_prompt("", &attachments).map_err(|error| {
|
|
||||||
audit.finish(false);
|
|
||||||
error
|
|
||||||
})?;
|
|
||||||
let DirectCodexUserItem::Message(message) = &mut user_item;
|
|
||||||
message.content.push(DirectCodexUserContentPart::InputText {
|
|
||||||
text: attachment_context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
validate_direct_codex_user_item(root, &user_item).map_err(|error| {
|
validate_direct_codex_user_item(root, &user_item).map_err(|error| {
|
||||||
audit.finish(false);
|
audit.finish(false);
|
||||||
error
|
error
|
||||||
|
|||||||
@@ -1887,7 +1887,7 @@ async fn bridge_create_or_derive_resource(
|
|||||||
|
|
||||||
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||||
let result = async {
|
let result = async {
|
||||||
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
|
bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
|
||||||
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
|
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
|
||||||
enforce_project_permission_policy(&state.root, "asset.register")?;
|
enforce_project_permission_policy(&state.root, "asset.register")?;
|
||||||
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
|
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
|
||||||
@@ -1896,8 +1896,6 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
|||||||
"assetName",
|
"assetName",
|
||||||
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
|
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
|
||||||
)?;
|
)?;
|
||||||
let background_mode = arguments.get("backgroundMode").and_then(Value::as_str);
|
|
||||||
let screen_color = arguments.get("screenColor").and_then(Value::as_str);
|
|
||||||
let manifest = read_existing_manifest_for_project(&state.root)?;
|
let manifest = read_existing_manifest_for_project(&state.root)?;
|
||||||
let source_asset = manifest
|
let source_asset = manifest
|
||||||
.assets
|
.assets
|
||||||
@@ -1922,34 +1920,22 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
|||||||
.map_err(|_| "创建抠图服务连接失败".to_string())?;
|
.map_err(|_| "创建抠图服务连接失败".to_string())?;
|
||||||
let context =
|
let context =
|
||||||
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
|
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
|
||||||
let fingerprint = background_removal_request_fingerprint(
|
let fingerprint = format!("{}\0{}", source_asset_id, asset_name);
|
||||||
&source_asset_id,
|
|
||||||
&asset_name,
|
|
||||||
background_mode,
|
|
||||||
screen_color,
|
|
||||||
);
|
|
||||||
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
||||||
let route = "/api/external/v1/editor/images/background-removals";
|
let route = "/api/external/v1/editor/images/background-removals";
|
||||||
let mut request_body = json!({
|
|
||||||
"sourceImageSrc": source_resource_id,
|
|
||||||
"projectId": manifest.project_id,
|
|
||||||
"assetKind": source_asset.kind,
|
|
||||||
"assetFolderId": context.asset_folder_id,
|
|
||||||
"assetLabel": asset_name,
|
|
||||||
"sourceResourceId": source_resource_id,
|
|
||||||
});
|
|
||||||
if background_mode == Some("flat") {
|
|
||||||
request_body["backgroundMode"] = json!("flat");
|
|
||||||
}
|
|
||||||
if let Some(color) = screen_color {
|
|
||||||
request_body["screenColor"] = json!(color);
|
|
||||||
}
|
|
||||||
let response = crate::http_client::with_agc_main_site_marker(
|
let response = crate::http_client::with_agc_main_site_marker(
|
||||||
client
|
client
|
||||||
.post(format!("{}{}", api_base_url, route))
|
.post(format!("{}{}", api_base_url, route))
|
||||||
.bearer_auth(api_key)
|
.bearer_auth(api_key)
|
||||||
.header("Idempotency-Key", idempotency_key)
|
.header("Idempotency-Key", idempotency_key)
|
||||||
.json(&request_body),
|
.json(&json!({
|
||||||
|
"sourceImageSrc": source_resource_id,
|
||||||
|
"projectId": manifest.project_id,
|
||||||
|
"assetKind": source_asset.kind,
|
||||||
|
"assetFolderId": context.asset_folder_id,
|
||||||
|
"assetLabel": asset_name,
|
||||||
|
"sourceResourceId": source_resource_id,
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -1986,20 +1972,6 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn background_removal_request_fingerprint(
|
|
||||||
source: &str,
|
|
||||||
name: &str,
|
|
||||||
mode: Option<&str>,
|
|
||||||
color: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
let mode = mode.unwrap_or("complex");
|
|
||||||
if mode == "complex" && color.is_none() {
|
|
||||||
format!("{source}\0{name}")
|
|
||||||
} else {
|
|
||||||
format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or(""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bridge_safe_queue_state(value: Value) -> Value {
|
fn bridge_safe_queue_state(value: Value) -> Value {
|
||||||
let object = value.as_object();
|
let object = value.as_object();
|
||||||
json!({
|
json!({
|
||||||
@@ -2731,29 +2703,6 @@ pub(crate) async fn start_direct_tool_bridge(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
|
||||||
fn remove_background_identity_preserves_default_and_distinguishes_options() {
|
|
||||||
let legacy = "asset-1\0透明图";
|
|
||||||
assert_eq!(
|
|
||||||
background_removal_request_fingerprint("asset-1", "透明图", None, None),
|
|
||||||
legacy
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None),
|
|
||||||
legacy
|
|
||||||
);
|
|
||||||
let mut identities = std::collections::HashSet::new();
|
|
||||||
identities.insert(legacy.to_string());
|
|
||||||
for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] {
|
|
||||||
let id =
|
|
||||||
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color);
|
|
||||||
assert_eq!(
|
|
||||||
id,
|
|
||||||
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color)
|
|
||||||
);
|
|
||||||
assert!(identities.insert(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::io::{Cursor, Read, Write};
|
use std::io::{Cursor, Read, Write};
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ struct ExternalMcpHttpState {
|
|||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
token: String,
|
token: String,
|
||||||
session_user_id: String,
|
session_user_id: String,
|
||||||
session_identity_generation: u64,
|
session_generation: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
|
pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
|
||||||
@@ -435,7 +435,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
|||||||
}),
|
}),
|
||||||
json!({
|
json!({
|
||||||
"name": "agc_remove_background",
|
"name": "agc_remove_background",
|
||||||
"description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。",
|
"description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -449,16 +449,6 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
|
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
|
||||||
},
|
|
||||||
"backgroundMode": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["complex", "flat"],
|
|
||||||
"description": "可选抠图模式:complex 用语义分割识别前景,flat 用纯色背景抠图;确定背景为纯色时优先使用 flat。省略时使用 complex"
|
|
||||||
},
|
|
||||||
"screenColor": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^(auto|#[0-9A-Fa-f]{6})$",
|
|
||||||
"description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略时由服务自动检测"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sourceLocalAssetId", "assetName"],
|
"required": ["sourceLocalAssetId", "assetName"],
|
||||||
@@ -891,46 +881,14 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
|
fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
|
||||||
validate_tool_object_fields(
|
validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
|
||||||
arguments,
|
|
||||||
&[
|
|
||||||
"sourceLocalAssetId",
|
|
||||||
"assetName",
|
|
||||||
"backgroundMode",
|
|
||||||
"screenColor",
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
|
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
|
||||||
bounded_tool_string(
|
bounded_tool_string(
|
||||||
arguments,
|
arguments,
|
||||||
"assetName",
|
"assetName",
|
||||||
DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
|
DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
|
||||||
)?;
|
)?;
|
||||||
if let Some(mode) = arguments.get("backgroundMode") {
|
|
||||||
let mode = mode
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| "backgroundMode 必须是 complex 或 flat".to_string())?;
|
|
||||||
if mode != "complex" && mode != "flat" {
|
|
||||||
return Err("backgroundMode 必须是 complex 或 flat".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(color) = arguments.get("screenColor") {
|
|
||||||
let color = color
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| "screenColor 必须是 auto 或 #RRGGBB".to_string())?;
|
|
||||||
let valid_hex = color.len() == 7
|
|
||||||
&& color.starts_with('#')
|
|
||||||
&& color[1..]
|
|
||||||
.chars()
|
|
||||||
.all(|character| character.is_ascii_hexdigit());
|
|
||||||
if color != "auto" && !valid_hex {
|
|
||||||
return Err("screenColor 必须是 auto 或 #RRGGBB".to_string());
|
|
||||||
}
|
|
||||||
if arguments.get("backgroundMode").and_then(Value::as_str) != Some("flat") {
|
|
||||||
return Err("complex 模式不能传 screenColor".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1283,8 +1241,7 @@ fn external_mcp_session_id(root: &Path) -> String {
|
|||||||
material.push('\0');
|
material.push('\0');
|
||||||
material.push_str(&session.user_id);
|
material.push_str(&session.user_id);
|
||||||
material.push('\0');
|
material.push('\0');
|
||||||
// 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。
|
material.push_str(&session.generation.to_string());
|
||||||
material.push_str(&session.identity_generation.to_string());
|
|
||||||
}
|
}
|
||||||
format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
|
format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
|
||||||
}
|
}
|
||||||
@@ -1802,9 +1759,7 @@ async fn handle_external_mcp_http_request(
|
|||||||
let Some(session) = current_platform_session() else {
|
let Some(session) = current_platform_session() else {
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
};
|
};
|
||||||
if session.user_id != state.session_user_id
|
if session.user_id != state.session_user_id || session.generation != state.session_generation {
|
||||||
|| session.identity_generation != state.session_identity_generation
|
|
||||||
{
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
let response = EXTERNAL_MCP_BRIDGE_URL
|
let response = EXTERNAL_MCP_BRIDGE_URL
|
||||||
@@ -1839,7 +1794,7 @@ pub(crate) async fn start_external_mcp_loopback(
|
|||||||
root,
|
root,
|
||||||
token: token.clone(),
|
token: token.clone(),
|
||||||
session_user_id: session.user_id,
|
session_user_id: session.user_id,
|
||||||
session_identity_generation: session.identity_generation,
|
session_generation: session.generation,
|
||||||
};
|
};
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route(&route, post(handle_external_mcp_http_request))
|
.route(&route, post(handle_external_mcp_http_request))
|
||||||
@@ -1891,51 +1846,6 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_background_arguments_enforce_mode_color_contract() {
|
|
||||||
for fields in [
|
|
||||||
json!({}),
|
|
||||||
json!({"backgroundMode":"complex"}),
|
|
||||||
json!({"backgroundMode":"flat"}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":"auto"}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":"#Ab12EF"}),
|
|
||||||
] {
|
|
||||||
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
|
|
||||||
arguments
|
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.extend(fields.as_object().unwrap().clone());
|
|
||||||
assert!(
|
|
||||||
validate_remove_background_arguments(&arguments).is_ok(),
|
|
||||||
"{fields}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for fields in [
|
|
||||||
json!({"screenColor":"auto"}),
|
|
||||||
json!({"backgroundMode":"complex","screenColor":"auto"}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":""}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":" auto "}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":"AUTO"}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":"#GGGGGG"}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":null}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":12}),
|
|
||||||
json!({"backgroundMode":""}),
|
|
||||||
json!({"backgroundMode":"FLAT"}),
|
|
||||||
json!({"backgroundMode":" flat "}),
|
|
||||||
json!({"backgroundMode":null}),
|
|
||||||
] {
|
|
||||||
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
|
|
||||||
arguments
|
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.extend(fields.as_object().unwrap().clone());
|
|
||||||
assert!(
|
|
||||||
validate_remove_background_arguments(&arguments).is_err(),
|
|
||||||
"{fields}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(windows, feature = "cocos-editor-execute"))]
|
#[cfg(all(windows, feature = "cocos-editor-execute"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_mcp_process_probe() {
|
fn builtin_mcp_process_probe() {
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ use super::external_generation_state::{
|
|||||||
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
|
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
|
||||||
};
|
};
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::platform_session::{
|
|
||||||
acquire_platform_session_identity_lease, validate_platform_session_identity,
|
|
||||||
PlatformSessionIdentity,
|
|
||||||
};
|
|
||||||
use reqwest::multipart::{Form, Part};
|
use reqwest::multipart::{Form, Part};
|
||||||
|
|
||||||
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
|
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
|
||||||
@@ -1514,7 +1510,10 @@ struct PreparedPlatformArtAssetSlice {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct PreparedPlatformSessionFence {
|
struct PreparedPlatformSessionFence {
|
||||||
identity: PlatformSessionIdentity,
|
user_id: String,
|
||||||
|
api_base_url: String,
|
||||||
|
generation: u64,
|
||||||
|
access_token_sha256: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PreparedPlatformSessionFence {
|
impl PreparedPlatformSessionFence {
|
||||||
@@ -1522,17 +1521,41 @@ impl PreparedPlatformSessionFence {
|
|||||||
access
|
access
|
||||||
.frozen_platform_session()
|
.frozen_platform_session()
|
||||||
.map(|session| PreparedPlatformSessionFence {
|
.map(|session| PreparedPlatformSessionFence {
|
||||||
identity: session.identity(),
|
user_id: session.user_id.clone(),
|
||||||
|
api_base_url: session.api_base_url.clone(),
|
||||||
|
generation: session.generation,
|
||||||
|
access_token_sha256: format!(
|
||||||
|
"{:x}",
|
||||||
|
Sha256::digest(session.access_token.as_bytes())
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), String> {
|
fn validate(&self) -> Result<(), String> {
|
||||||
// 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。
|
let matches = current_platform_session().is_some_and(|session| {
|
||||||
validate_platform_session_identity(&self.identity)
|
session.user_id == self.user_id
|
||||||
|
&& session.api_base_url == self.api_base_url
|
||||||
|
&& session.generation == self.generation
|
||||||
|
&& format!("{:x}", Sha256::digest(session.access_token.as_bytes()))
|
||||||
|
== self.access_token_sha256
|
||||||
|
});
|
||||||
|
if matches {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(
|
||||||
|
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> {
|
fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> {
|
||||||
acquire_platform_session_identity_lease(&self.identity)
|
acquire_validated_platform_session_fingerprint(
|
||||||
|
&self.user_id,
|
||||||
|
&self.api_base_url,
|
||||||
|
self.generation,
|
||||||
|
&self.access_token_sha256,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10613,7 +10636,7 @@ mod canvas_generation_tests {
|
|||||||
}
|
}
|
||||||
drop(owner_a_access);
|
drop(owner_a_access);
|
||||||
drop(frozen_owner_a);
|
drop(frozen_owner_a);
|
||||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2)
|
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2)
|
||||||
.expect("switch to owner B");
|
.expect("switch to owner B");
|
||||||
|
|
||||||
let error = match request_platform_art_asset_with_runtime_options_at(
|
let error = match request_platform_art_asset_with_runtime_options_at(
|
||||||
@@ -10738,14 +10761,8 @@ mod canvas_generation_tests {
|
|||||||
.recv_timeout(Duration::from_secs(3))
|
.recv_timeout(Duration::from_secs(3))
|
||||||
.expect("wait for accepted response");
|
.expect("wait for accepted response");
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
install_platform_session(
|
install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2)
|
||||||
"post-202-user-b",
|
.expect("switch platform account after accepted response");
|
||||||
"post-202-token-b",
|
|
||||||
&switch_base_url,
|
|
||||||
2,
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
.expect("switch platform account after accepted response");
|
|
||||||
});
|
});
|
||||||
let runtime_context = PlatformArtGenerationRuntimeContext {
|
let runtime_context = PlatformArtGenerationRuntimeContext {
|
||||||
agent_id: "art-director".to_string(),
|
agent_id: "art-director".to_string(),
|
||||||
|
|||||||
+1
-2
@@ -1431,13 +1431,12 @@ mod external_generation_state_tests {
|
|||||||
base_url,
|
base_url,
|
||||||
);
|
);
|
||||||
let frozen_a = current_platform_session().expect("freeze owner A");
|
let frozen_a = current_platform_session().expect("freeze owner A");
|
||||||
validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch");
|
validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch");
|
||||||
replace_platform_session_for_gui_owner(
|
replace_platform_session_for_gui_owner(
|
||||||
"fingerprint-owner-b",
|
"fingerprint-owner-b",
|
||||||
"fingerprint-token-b",
|
"fingerprint-token-b",
|
||||||
base_url,
|
base_url,
|
||||||
2,
|
2,
|
||||||
2,
|
|
||||||
)
|
)
|
||||||
.expect("switch global session to owner B");
|
.expect("switch global session to owner B");
|
||||||
let current_b = current_platform_session().expect("owner B is current after switch");
|
let current_b = current_platform_session().expect("owner B is current after switch");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user