Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8d5df4ccb | |||
| 965e80f374 | |||
| 9b6ed8a4cf | |||
| 16deb0ad61 | |||
| 13f56e644d | |||
| e08b031761 | |||
| 70ad794b28 | |||
| ca761e093c | |||
| a17367112f | |||
| d7a0c3cffd | |||
| fa64819b6e | |||
| f748d36d21 | |||
| cd75a9e565 | |||
| 0388fa0912 | |||
| 79b153e3fa | |||
| 362edcc49d | |||
| 1e434e0cb5 | |||
| cec971c438 | |||
| 70157673b6 | |||
| 492e9e63af | |||
| 57fae7037a | |||
| 2bcfa10647 | |||
| e777236817 | |||
| d11c74212d | |||
| 82b2f853e7 | |||
| 31f83a751a | |||
| a1cafde7e9 | |||
| 0594a90bdd | |||
| aee862532c | |||
| 42be8ea060 | |||
| b48293fb1f | |||
| e08171fd2e | |||
| 479120d368 | |||
| 4182979a19 | |||
| 737a2266b9 | |||
| 262deaf9b7 | |||
| c36a5170f8 | |||
| 9663bbf911 |
@@ -7,6 +7,10 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: project-ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
|
||||
interface MockIntersectionObserverController {
|
||||
enter: (target: Element) => void;
|
||||
enterAll: (targets: Element[]) => void;
|
||||
isObserved: (target: Element) => boolean;
|
||||
}
|
||||
|
||||
@@ -106,6 +107,25 @@ function installIntersectionObserverMock(): MockIntersectionObserverController {
|
||||
);
|
||||
});
|
||||
},
|
||||
enterAll(targets) {
|
||||
act(() => {
|
||||
for (const target of targets) {
|
||||
const record = observed.get(target);
|
||||
if (!record) {
|
||||
throw new Error('目标缩略图尚未进入 IntersectionObserver');
|
||||
}
|
||||
record.callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
target,
|
||||
} as IntersectionObserverEntry,
|
||||
],
|
||||
record.observer,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
isObserved(target) {
|
||||
return observed.has(target);
|
||||
},
|
||||
@@ -753,10 +773,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
||||
const thumbnails = entries.map((entry) =>
|
||||
thumbnailElementForLabel(entry.label),
|
||||
);
|
||||
thumbnails.forEach((thumbnail) => {
|
||||
for (const thumbnail of thumbnails) {
|
||||
expect(observer.isObserved(thumbnail)).toBe(true);
|
||||
observer.enter(thumbnail);
|
||||
});
|
||||
}
|
||||
observer.enterAll(thumbnails);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
@@ -776,7 +796,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
||||
const observer = installIntersectionObserverMock();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"schemaVersion": "game-creator-config.v2",
|
||||
"agentMode": "codex_app_server",
|
||||
"llm": {
|
||||
"customEnabled": false,
|
||||
"visibleModels": [],
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||
"model": "gpt-6-astra",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.47",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
|
||||
+1
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.45"
|
||||
version = "0.1.47"
|
||||
dependencies = [
|
||||
"agent-runtime-core",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.45"
|
||||
version = "0.1.47"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{"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":"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 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。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 必须非空且在原文件中唯一。所有 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":"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":"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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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_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.
|
||||
`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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.16",
|
||||
"version": "2026-08-26.17",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -123,7 +123,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/projection-contract.md"
|
||||
],
|
||||
"sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6"
|
||||
"sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -131,7 +131,6 @@ impl CodexAppServerCredential {
|
||||
) -> Option<(&'a str, &'a str)> {
|
||||
match self {
|
||||
Self::PlatformSession { .. } => None,
|
||||
#[cfg(test)]
|
||||
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
|
||||
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
|
||||
#[cfg(test)]
|
||||
@@ -139,7 +138,7 @@ impl CodexAppServerCredential {
|
||||
.as_deref()
|
||||
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
|
||||
#[cfg(not(test))]
|
||||
Self::AppDataKey { .. } | Self::AuthBridge { .. } => None,
|
||||
Self::AuthBridge { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +195,20 @@ 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 {
|
||||
inner: Weak<CodexAppServerInner>,
|
||||
thread_id: String,
|
||||
@@ -257,7 +270,7 @@ impl CodexTurnStartCancellation {
|
||||
};
|
||||
let connection = CodexAppServerConnection { inner };
|
||||
let thread_id = self.thread_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
let _ = connection
|
||||
.request(
|
||||
"turn/interrupt",
|
||||
@@ -2018,7 +2031,13 @@ impl CodexAppServerConnection {
|
||||
let codex_cli_version = game_creator_codex_cli_version_identity()
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
let mut effective_llm = llm.clone();
|
||||
let credential = if game_creator_official_llm_route_locked() {
|
||||
let credential = if llm.custom_enabled {
|
||||
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(|| {
|
||||
platform_llm::LlmError::InvalidConfig(
|
||||
"authentication-required: 请先登录陶泥儿账号".to_string(),
|
||||
@@ -2186,7 +2205,8 @@ impl CodexAppServerConnection {
|
||||
true,
|
||||
),
|
||||
_ => (
|
||||
(workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
(llm.custom_enabled
|
||||
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
.then(|| credential.direct_provider_route(llm))
|
||||
.flatten()
|
||||
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
|
||||
@@ -3560,7 +3580,7 @@ impl Drop for CodexThreadLease {
|
||||
let connection = self.connection.clone();
|
||||
let key = self.key.clone();
|
||||
let thread_id = self.thread_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
let mut threads = connection.inner.threads.lock().await;
|
||||
if let Some(entry) = threads.get_mut(&key) {
|
||||
if entry.thread_id == thread_id {
|
||||
@@ -3587,7 +3607,7 @@ impl Drop for CodexTurnGuard {
|
||||
let connection = self.connection.clone();
|
||||
let thread_id = self.thread_id.clone();
|
||||
let turn_id = self.turn_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
connection.inner.turns.lock().await.remove(&turn_id);
|
||||
connection.inner.turn_backlog.lock().await.remove(&turn_id);
|
||||
let _ = connection
|
||||
@@ -4483,6 +4503,19 @@ mod tests {
|
||||
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 上
|
||||
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
||||
#[test]
|
||||
@@ -4743,6 +4776,8 @@ mod tests {
|
||||
|
||||
fn test_llm() -> GameCreatorLlmConfig {
|
||||
GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "fixture-secret".to_string(),
|
||||
base_url: "https://example.invalid/v1".to_string(),
|
||||
model: "fixture-model".to_string(),
|
||||
@@ -5528,6 +5563,59 @@ mod tests {
|
||||
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)]
|
||||
#[tokio::test]
|
||||
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
|
||||
|
||||
@@ -527,10 +527,6 @@ fn process_design_batch(
|
||||
let result = if uncertain {
|
||||
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
|
||||
} else {
|
||||
let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"design.tool",
|
||||
)?;
|
||||
execute_design_tool(root, resources, session, &call)
|
||||
};
|
||||
let error = result
|
||||
@@ -1026,6 +1022,15 @@ pub(crate) async fn continue_design_agent_at(
|
||||
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(
|
||||
root: &Path,
|
||||
resources: &DesignResources,
|
||||
@@ -1058,7 +1063,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn hydrate_design_agent_session(
|
||||
pub(crate) async fn hydrate_design_agent_session(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
) -> Result<Option<DesignView>, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
@@ -1084,8 +1090,33 @@ pub(crate) fn hydrate_design_agent_session(
|
||||
if session.project_id != project_id {
|
||||
return Err("策划会话与当前项目不匹配".into());
|
||||
}
|
||||
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
|
||||
Ok(Some(design_view(&session, active.is_none())))
|
||||
let Some(active) =
|
||||
try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
|
||||
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 {
|
||||
@@ -1958,4 +1989,94 @@ mod tests {
|
||||
.any(|message| message.text.contains("重试后继续")));
|
||||
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,15 +321,31 @@ pub(crate) fn execute_design_file_tool(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut matches = Vec::new();
|
||||
let mut edit_errors = Vec::new();
|
||||
let mut valid_edits = 0;
|
||||
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();
|
||||
if count == 0 {
|
||||
return Err(format!("edits[{index}] 原文未找到:{display}"));
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 原文未找到:{}{}",
|
||||
display,
|
||||
design_patch_location_hint(&content, old)
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if count != 1 {
|
||||
return Err(format!(
|
||||
"edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
|
||||
let start = content.find(old).expect("count checked");
|
||||
let line = design_patch_line_number(&content, start);
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let start = content.find(old).expect("count checked");
|
||||
let end = start + old.len();
|
||||
@@ -337,13 +353,33 @@ pub(crate) fn execute_design_file_tool(
|
||||
.iter()
|
||||
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
||||
{
|
||||
return Err(format!(
|
||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}"
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
matches.push((index, start, end));
|
||||
valid_edits += 1;
|
||||
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();
|
||||
for (index, start, end) in matches.into_iter().rev() {
|
||||
let (_, new) = &normalized[index];
|
||||
@@ -396,6 +432,60 @@ 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(
|
||||
root: &Path,
|
||||
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
||||
@@ -693,6 +783,22 @@ mod tests {
|
||||
)
|
||||
.expect_err("escape");
|
||||
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(
|
||||
root,
|
||||
"patch_file",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1887,7 +1887,7 @@ async fn bridge_create_or_derive_resource(
|
||||
|
||||
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||
let result = async {
|
||||
bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
|
||||
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
|
||||
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
|
||||
enforce_project_permission_policy(&state.root, "asset.register")?;
|
||||
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
|
||||
@@ -1896,6 +1896,8 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
||||
"assetName",
|
||||
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 source_asset = manifest
|
||||
.assets
|
||||
@@ -1920,22 +1922,34 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
||||
.map_err(|_| "创建抠图服务连接失败".to_string())?;
|
||||
let context =
|
||||
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
|
||||
let fingerprint = format!("{}\0{}", source_asset_id, asset_name);
|
||||
let fingerprint = background_removal_request_fingerprint(
|
||||
&source_asset_id,
|
||||
&asset_name,
|
||||
background_mode,
|
||||
screen_color,
|
||||
);
|
||||
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
||||
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(
|
||||
client
|
||||
.post(format!("{}{}", api_base_url, route))
|
||||
.bearer_auth(api_key)
|
||||
.header("Idempotency-Key", idempotency_key)
|
||||
.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,
|
||||
})),
|
||||
.json(&request_body),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
@@ -1972,6 +1986,20 @@ 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 {
|
||||
let object = value.as_object();
|
||||
json!({
|
||||
@@ -2703,6 +2731,29 @@ pub(crate) async fn start_direct_tool_bridge(
|
||||
|
||||
#[cfg(test)]
|
||||
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 std::io::{Cursor, Read, Write};
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ struct ExternalMcpHttpState {
|
||||
root: PathBuf,
|
||||
token: String,
|
||||
session_user_id: String,
|
||||
session_generation: u64,
|
||||
session_identity_generation: u64,
|
||||
}
|
||||
|
||||
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!({
|
||||
"name": "agc_remove_background",
|
||||
"description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。",
|
||||
"description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -449,6 +449,16 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"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"],
|
||||
@@ -881,14 +891,46 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
|
||||
validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
|
||||
pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
|
||||
validate_tool_object_fields(
|
||||
arguments,
|
||||
&[
|
||||
"sourceLocalAssetId",
|
||||
"assetName",
|
||||
"backgroundMode",
|
||||
"screenColor",
|
||||
],
|
||||
)?;
|
||||
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
|
||||
bounded_tool_string(
|
||||
arguments,
|
||||
"assetName",
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1241,7 +1283,8 @@ fn external_mcp_session_id(root: &Path) -> String {
|
||||
material.push('\0');
|
||||
material.push_str(&session.user_id);
|
||||
material.push('\0');
|
||||
material.push_str(&session.generation.to_string());
|
||||
// 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。
|
||||
material.push_str(&session.identity_generation.to_string());
|
||||
}
|
||||
format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
|
||||
}
|
||||
@@ -1759,7 +1802,9 @@ async fn handle_external_mcp_http_request(
|
||||
let Some(session) = current_platform_session() else {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
};
|
||||
if session.user_id != state.session_user_id || session.generation != state.session_generation {
|
||||
if session.user_id != state.session_user_id
|
||||
|| session.identity_generation != state.session_identity_generation
|
||||
{
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
let response = EXTERNAL_MCP_BRIDGE_URL
|
||||
@@ -1794,7 +1839,7 @@ pub(crate) async fn start_external_mcp_loopback(
|
||||
root,
|
||||
token: token.clone(),
|
||||
session_user_id: session.user_id,
|
||||
session_generation: session.generation,
|
||||
session_identity_generation: session.identity_generation,
|
||||
};
|
||||
let app = Router::new()
|
||||
.route(&route, post(handle_external_mcp_http_request))
|
||||
@@ -1846,6 +1891,51 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
|
||||
mod tests {
|
||||
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"))]
|
||||
#[test]
|
||||
fn builtin_mcp_process_probe() {
|
||||
|
||||
@@ -11,6 +11,10 @@ use super::external_generation_state::{
|
||||
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
|
||||
};
|
||||
use super::*;
|
||||
use crate::platform_session::{
|
||||
acquire_platform_session_identity_lease, validate_platform_session_identity,
|
||||
PlatformSessionIdentity,
|
||||
};
|
||||
use reqwest::multipart::{Form, Part};
|
||||
|
||||
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
|
||||
@@ -1510,10 +1514,7 @@ struct PreparedPlatformArtAssetSlice {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PreparedPlatformSessionFence {
|
||||
user_id: String,
|
||||
api_base_url: String,
|
||||
generation: u64,
|
||||
access_token_sha256: String,
|
||||
identity: PlatformSessionIdentity,
|
||||
}
|
||||
|
||||
impl PreparedPlatformSessionFence {
|
||||
@@ -1521,41 +1522,17 @@ impl PreparedPlatformSessionFence {
|
||||
access
|
||||
.frozen_platform_session()
|
||||
.map(|session| PreparedPlatformSessionFence {
|
||||
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())
|
||||
),
|
||||
identity: session.identity(),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
let matches = current_platform_session().is_some_and(|session| {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
// 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。
|
||||
validate_platform_session_identity(&self.identity)
|
||||
}
|
||||
|
||||
fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> {
|
||||
acquire_validated_platform_session_fingerprint(
|
||||
&self.user_id,
|
||||
&self.api_base_url,
|
||||
self.generation,
|
||||
&self.access_token_sha256,
|
||||
)
|
||||
acquire_platform_session_identity_lease(&self.identity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10636,7 +10613,7 @@ mod canvas_generation_tests {
|
||||
}
|
||||
drop(owner_a_access);
|
||||
drop(frozen_owner_a);
|
||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2)
|
||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2)
|
||||
.expect("switch to owner B");
|
||||
|
||||
let error = match request_platform_art_asset_with_runtime_options_at(
|
||||
@@ -10761,8 +10738,14 @@ mod canvas_generation_tests {
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.expect("wait for accepted response");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2)
|
||||
.expect("switch platform account after accepted response");
|
||||
install_platform_session(
|
||||
"post-202-user-b",
|
||||
"post-202-token-b",
|
||||
&switch_base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch platform account after accepted response");
|
||||
});
|
||||
let runtime_context = PlatformArtGenerationRuntimeContext {
|
||||
agent_id: "art-director".to_string(),
|
||||
|
||||
+2
-1
@@ -1431,12 +1431,13 @@ mod external_generation_state_tests {
|
||||
base_url,
|
||||
);
|
||||
let frozen_a = current_platform_session().expect("freeze owner A");
|
||||
validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch");
|
||||
validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch");
|
||||
replace_platform_session_for_gui_owner(
|
||||
"fingerprint-owner-b",
|
||||
"fingerprint-token-b",
|
||||
base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch global session to owner B");
|
||||
let current_b = current_platform_session().expect("owner B is current after switch");
|
||||
|
||||
+18
@@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
|
||||
response_stream_fixture("finalization-tool-plan-repair-chain-run");
|
||||
let root = project.path();
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "finalization-tool-plan-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "finalization-tool-plan-model".to_string(),
|
||||
@@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
||||
let root = project.path();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-provider-model".to_string(),
|
||||
@@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
||||
LlmMessage::user("修复格式"),
|
||||
]);
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-tool-plan-provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-tool-plan-model".to_string(),
|
||||
@@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
|
||||
response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
|
||||
let root = project.path();
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-generic-retry-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-generic-retry-model".to_string(),
|
||||
@@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
||||
response_stream_fixture("tool-plan-capacity-preflight-run");
|
||||
let root = project.path();
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "tool-plan-capacity-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "tool-plan-capacity-model".to_string(),
|
||||
@@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
||||
LlmMessage::user("修复格式"),
|
||||
]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "durable-control-tool-plan-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "durable-control-tool-plan-model".to_string(),
|
||||
@@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "tool-plan-cleanup-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "tool-plan-cleanup-model".to_string(),
|
||||
@@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "terminal-handoff-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "terminal-handoff-model".to_string(),
|
||||
@@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
||||
let root = project.path();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "provider-model".to_string(),
|
||||
|
||||
@@ -2,8 +2,9 @@ use super::*;
|
||||
|
||||
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
|
||||
OnceLock::new();
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
|
||||
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
|
||||
/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock<
|
||||
std::sync::Mutex<Vec<GameCreatorManifestInvalidationEventSink>>,
|
||||
> = OnceLock::new();
|
||||
#[cfg(test)]
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> =
|
||||
@@ -293,8 +294,8 @@ pub(crate) use entrypoints::{
|
||||
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
|
||||
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
|
||||
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
|
||||
install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at,
|
||||
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
|
||||
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
|
||||
read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink,
|
||||
set_game_creator_agent_runtime_update_app_handle,
|
||||
start_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use super::*;
|
||||
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16;
|
||||
|
||||
fn lock_game_creator_manifest_invalidation_event_sink(
|
||||
) -> std::sync::MutexGuard<'static, Option<GameCreatorManifestInvalidationEventSink>> {
|
||||
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
fn lock_game_creator_manifest_invalidation_event_sinks(
|
||||
) -> std::sync::MutexGuard<'static, Vec<GameCreatorManifestInvalidationEventSink>> {
|
||||
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS
|
||||
.get_or_init(|| Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
@@ -219,7 +220,7 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
|
||||
token: &str,
|
||||
) -> Result<(), String> {
|
||||
let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?;
|
||||
install_game_creator_manifest_invalidation_event_sink(sink);
|
||||
register_game_creator_manifest_invalidation_event_sink(sink);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -240,10 +241,29 @@ pub(crate) fn validate_game_creator_manifest_invalidation_event_sink(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn install_game_creator_manifest_invalidation_event_sink(
|
||||
/// 登记一个界面窗口的事件接收端。
|
||||
///
|
||||
/// 同一窗口重复 attach 用同一个 token,按 token 覆盖旧登记;不同窗口各自持有
|
||||
/// 自己的 token,注册表按登记顺序保留,最多 `GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX` 个。
|
||||
pub(crate) fn register_game_creator_manifest_invalidation_event_sink(
|
||||
sink: GameCreatorManifestInvalidationEventSink,
|
||||
) {
|
||||
*lock_game_creator_manifest_invalidation_event_sink() = Some(sink);
|
||||
let mut sinks = lock_game_creator_manifest_invalidation_event_sinks();
|
||||
if let Some(existing) = sinks
|
||||
.iter_mut()
|
||||
.find(|existing| existing.token == sink.token)
|
||||
{
|
||||
*existing = sink;
|
||||
return;
|
||||
}
|
||||
if sinks.len() >= GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX {
|
||||
sinks.remove(0);
|
||||
}
|
||||
sinks.push(sink);
|
||||
}
|
||||
|
||||
fn remove_game_creator_manifest_invalidation_event_sink(token: &str) {
|
||||
lock_game_creator_manifest_invalidation_event_sinks().retain(|sink| sink.token != token);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -258,14 +278,20 @@ impl GameCreatorManifestInvalidationEventSinkTestGuard {
|
||||
}
|
||||
|
||||
pub(crate) fn configured_sink(&self) -> Option<GameCreatorManifestInvalidationEventSink> {
|
||||
lock_game_creator_manifest_invalidation_event_sink().clone()
|
||||
lock_game_creator_manifest_invalidation_event_sinks()
|
||||
.first()
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn configured_sinks(&self) -> Vec<GameCreatorManifestInvalidationEventSink> {
|
||||
lock_game_creator_manifest_invalidation_event_sinks().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard {
|
||||
fn drop(&mut self) {
|
||||
*lock_game_creator_manifest_invalidation_event_sink() = None;
|
||||
lock_game_creator_manifest_invalidation_event_sinks().clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,16 +307,43 @@ pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard(
|
||||
}
|
||||
|
||||
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
|
||||
let sink = lock_game_creator_manifest_invalidation_event_sink().clone();
|
||||
let Some(sink) = sink else {
|
||||
let sinks = lock_game_creator_manifest_invalidation_event_sinks().clone();
|
||||
if sinks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let event = GameCreatorManifestInvalidatedEvent {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
agent_id: agent_id.to_string(),
|
||||
};
|
||||
let mut failed_tokens = Vec::new();
|
||||
let mut last_error = None;
|
||||
for sink in &sinks {
|
||||
match relay_game_creator_manifest_invalidation_to_sink(sink, &event) {
|
||||
Ok(()) => {}
|
||||
Err(error) => {
|
||||
// 窗口已退出或接收端已释放时只淘汰该接收端,不能影响其它窗口。
|
||||
failed_tokens.push(sink.token.clone());
|
||||
last_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !failed_tokens.is_empty() {
|
||||
lock_game_creator_manifest_invalidation_event_sinks()
|
||||
.retain(|sink| !failed_tokens.contains(&sink.token));
|
||||
}
|
||||
match last_error {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn relay_game_creator_manifest_invalidation_to_sink(
|
||||
sink: &GameCreatorManifestInvalidationEventSink,
|
||||
event: &GameCreatorManifestInvalidatedEvent,
|
||||
) -> Result<(), String> {
|
||||
let envelope = GameCreatorManifestInvalidationRelayEnvelope {
|
||||
token: sink.token,
|
||||
event: GameCreatorManifestInvalidatedEvent {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
agent_id: agent_id.to_string(),
|
||||
},
|
||||
token: sink.token.clone(),
|
||||
event: event.clone(),
|
||||
};
|
||||
let payload = serde_json::to_vec(&envelope)
|
||||
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
|
||||
|
||||
@@ -1939,8 +1939,9 @@ pub(crate) async fn polish_local_project_prompt(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_platform_account_session_generation() -> u64 {
|
||||
current_platform_session_generation()
|
||||
pub(crate) fn read_platform_account_session_state(
|
||||
) -> crate::platform_session::PlatformSessionWriteState {
|
||||
crate::platform_session::current_platform_session_write_state()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1948,28 +1949,45 @@ pub(crate) async fn install_platform_account_session(
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
api_base_url: String,
|
||||
generation: u64,
|
||||
identity_generation: u64,
|
||||
revision: u64,
|
||||
) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
|
||||
validate_platform_session_input(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
identity_generation,
|
||||
revision,
|
||||
)?;
|
||||
install_external_agent_runner_platform_session(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
generation,
|
||||
identity_generation,
|
||||
revision,
|
||||
)?;
|
||||
install_platform_session(&user_id, &access_token, &api_base_url, generation)
|
||||
install_platform_session(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
identity_generation,
|
||||
revision,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> {
|
||||
pub(crate) async fn clear_platform_account_session(
|
||||
identity_generation: u64,
|
||||
revision: u64,
|
||||
) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
shutdown_game_creator_codex_app_servers()?;
|
||||
clear_external_agent_runner_platform_session(generation)?;
|
||||
clear_platform_session(generation);
|
||||
clear_external_agent_runner_platform_session(identity_generation, revision)?;
|
||||
clear_platform_session(identity_generation, revision);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
@@ -1991,6 +2009,8 @@ pub(crate) fn write_game_creator_app_config(
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
let (current, overlays) = load_game_creator_app_config_for_write()?;
|
||||
// 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。
|
||||
config.llm.custom_enabled = current.llm.custom_enabled;
|
||||
config.selected_model_id = current.selected_model_id;
|
||||
config.selected_model_is_default = current.selected_model_is_default;
|
||||
persist_game_creator_app_config(config, overlays, false)
|
||||
@@ -2027,7 +2047,12 @@ pub(crate) fn select_game_creator_model(
|
||||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
if model_id.is_empty()
|
||||
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
|
||||
if config.llm.custom_enabled {
|
||||
if !config.llm.visible_models.contains(&model_id) {
|
||||
return Err("所选模型未勾选或已移除,请刷新模型列表".into());
|
||||
}
|
||||
} else if model_id.is_empty()
|
||||
|| model_id.len() > 64
|
||||
|| !model_id
|
||||
.bytes()
|
||||
@@ -2035,12 +2060,21 @@ pub(crate) fn select_game_creator_model(
|
||||
{
|
||||
return Err("模型标识无效".into());
|
||||
}
|
||||
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
|
||||
config.selected_model_id = model_id;
|
||||
config.selected_model_is_default = is_default;
|
||||
persist_game_creator_app_config(config, overlays, true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn discover_game_creator_llm_models(
|
||||
llm: GameCreatorLlmConfig,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if !load_game_creator_app_config()?.llm.custom_enabled {
|
||||
return Err("请先在本地配置中开启 llm.customEnabled".to_string());
|
||||
}
|
||||
fetch_custom_llm_models(&llm).await
|
||||
}
|
||||
|
||||
fn persist_game_creator_app_config(
|
||||
config: GameCreatorAppConfig,
|
||||
overlays: Vec<(PathBuf, serde_json::Value)>,
|
||||
@@ -2056,8 +2090,8 @@ fn persist_game_creator_app_config(
|
||||
let previous = overlay.clone();
|
||||
if let Some(fields) = overlay.as_object_mut() {
|
||||
for (key, value) in fields.iter_mut() {
|
||||
if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
|
||||
== model_only
|
||||
if !model_only
|
||||
|| matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
|
||||
{
|
||||
if let Some(saved_value) = saved.get(key) {
|
||||
// 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。
|
||||
@@ -4231,14 +4265,7 @@ pub(crate) async fn import_account_editor_assets_for_agent(
|
||||
access.validate_frozen_session()?;
|
||||
let _platform_session_lease = frozen_session
|
||||
.as_ref()
|
||||
.map(|session| {
|
||||
acquire_validated_platform_session_fingerprint(
|
||||
&session.user_id,
|
||||
&session.api_base_url,
|
||||
session.generation,
|
||||
&format!("{:x}", Sha256::digest(session.access_token.as_bytes())),
|
||||
)
|
||||
})
|
||||
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
|
||||
.transpose()?;
|
||||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||||
access.validate_frozen_session()?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1084,6 +1084,10 @@ struct GameCreatorAppConfigFile {
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorLlmConfigFile {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
custom_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
visible_models: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
api_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1139,6 +1143,10 @@ struct GameCreatorAppConfig {
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorLlmConfig {
|
||||
#[serde(default)]
|
||||
custom_enabled: bool,
|
||||
#[serde(default)]
|
||||
visible_models: Vec<String>,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
model: String,
|
||||
@@ -1655,6 +1663,8 @@ impl Default for GameCreatorAppConfig {
|
||||
impl Default for GameCreatorLlmConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: String::new(),
|
||||
base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(),
|
||||
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
|
||||
@@ -2209,6 +2219,8 @@ fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent)
|
||||
enum GameCreatorGuiRunnerShutdownOutcome {
|
||||
NotRequested,
|
||||
Requested,
|
||||
/// 仍有其它界面窗口持有参与锁,Runner 必须保留给它们。
|
||||
Retained,
|
||||
Failed(GameCreatorGuiRunnerShutdownFailure),
|
||||
}
|
||||
|
||||
@@ -2246,7 +2258,8 @@ fn classify_game_creator_gui_runner_shutdown_error(
|
||||
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
|
||||
} else if error.contains("当前平台不支持") || error.contains("macOS 不提供") {
|
||||
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
|
||||
} else if error.contains("实例锁") || error.contains("owner 锁") {
|
||||
} else if error.contains("实例锁") || error.contains("参与锁") || error.contains("owner 锁")
|
||||
{
|
||||
GameCreatorGuiRunnerShutdownFailure::LockTimeout
|
||||
} else if error.contains("endpoint") {
|
||||
GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable
|
||||
@@ -2262,13 +2275,14 @@ fn resolve_game_creator_gui_runner_shutdown<F>(
|
||||
shutdown: F,
|
||||
) -> GameCreatorGuiRunnerShutdownOutcome
|
||||
where
|
||||
F: FnOnce() -> Result<(), String>,
|
||||
F: FnOnce() -> Result<bool, String>,
|
||||
{
|
||||
if !game_creator_gui_run_event_requests_runner_shutdown(event) {
|
||||
return GameCreatorGuiRunnerShutdownOutcome::NotRequested;
|
||||
}
|
||||
match shutdown() {
|
||||
Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested,
|
||||
Ok(true) => GameCreatorGuiRunnerShutdownOutcome::Requested,
|
||||
Ok(false) => GameCreatorGuiRunnerShutdownOutcome::Retained,
|
||||
Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed(
|
||||
classify_game_creator_gui_runner_shutdown_error(&error),
|
||||
),
|
||||
@@ -2288,11 +2302,17 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
|
||||
app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
|
||||
}
|
||||
}
|
||||
match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) {
|
||||
match resolve_game_creator_gui_runner_shutdown(
|
||||
event,
|
||||
shutdown_external_agent_runner_for_gui_exit,
|
||||
) {
|
||||
GameCreatorGuiRunnerShutdownOutcome::NotRequested => {}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Requested => {
|
||||
app_log!("agent.runner.gui_exit.shutdown_requested")
|
||||
}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Retained => {
|
||||
app_log!("agent.runner.gui_exit.retained_for_other_windows")
|
||||
}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => {
|
||||
app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
|
||||
}
|
||||
@@ -2601,27 +2621,25 @@ fn main() {
|
||||
)
|
||||
})?;
|
||||
setup_log.append("startup.runner.configure.complete");
|
||||
let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
||||
hold_external_agent_runner_gui_participant_lock(&config_dir)
|
||||
.inspect_err(|error| {
|
||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
setup_log.fail(&format!(
|
||||
"startup.runner.owner-lock.failed details={details}"
|
||||
"startup.runner.participant-lock.failed details={details}"
|
||||
));
|
||||
})
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
format!("获取 GUI owner 锁失败:{error}"),
|
||||
format!("建立 AGC 界面参与锁失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string();
|
||||
app.manage(gui_owner_lock);
|
||||
setup_log.append("startup.runner.start.begin");
|
||||
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
|
||||
set_direct_thread_manager_app_handle(app.handle().clone());
|
||||
let manifest_event_sink =
|
||||
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch)
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink)
|
||||
.inspect_err(|error| {
|
||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
setup_log.fail(&format!(
|
||||
@@ -2715,12 +2733,13 @@ fn main() {
|
||||
confirm_resume_game_creator_agent_runtime_tasks,
|
||||
schedule_game_creator_agent_ready_tasks,
|
||||
check_game_creator_llm_config,
|
||||
read_platform_account_session_generation,
|
||||
read_platform_account_session_state,
|
||||
install_platform_account_session,
|
||||
clear_platform_account_session,
|
||||
read_game_creator_app_config,
|
||||
write_game_creator_app_config,
|
||||
select_game_creator_model,
|
||||
discover_game_creator_llm_models,
|
||||
upload_local_asset,
|
||||
register_local_asset,
|
||||
create_ui_design_resource,
|
||||
|
||||
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