Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19e9dea45c | |||
| a9d5ac0931 | |||
| ff542085e1 | |||
| 46d395763d | |||
| 7eaaa1a499 | |||
| 8ee50e8b94 |
@@ -7,10 +7,6 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: project-ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
|
||||
interface MockIntersectionObserverController {
|
||||
enter: (target: Element) => void;
|
||||
enterAll: (targets: Element[]) => void;
|
||||
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) {
|
||||
return observed.has(target);
|
||||
},
|
||||
@@ -773,10 +753,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
||||
const thumbnails = entries.map((entry) =>
|
||||
thumbnailElementForLabel(entry.label),
|
||||
);
|
||||
for (const thumbnail of thumbnails) {
|
||||
thumbnails.forEach((thumbnail) => {
|
||||
expect(observer.isObserved(thumbnail)).toBe(true);
|
||||
}
|
||||
observer.enterAll(thumbnails);
|
||||
observer.enter(thumbnail);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
@@ -796,7 +776,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
||||
const observer = installIntersectionObserverMock();
|
||||
|
||||
@@ -6,6 +6,9 @@ publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# 模板库假数据注入(仅本地页面压测/演示用):只有显式开启该 feature 才会编译并在读取清单后
|
||||
# 把条目循环补齐成假数据;计数由 AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT 控制(默认 1000)。
|
||||
template-library-fixtures = []
|
||||
cocos-editor = ["cocos-editor-bridge/process-discovery"]
|
||||
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
|
||||
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
|
||||
|
||||
@@ -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 必须非空且在原文件中唯一。所有 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":"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}}},
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ Let the client derive projections from real disk changes and trusted tool result
|
||||
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
|
||||
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. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text.
|
||||
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.
|
||||
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.
|
||||
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
-3
@@ -14,6 +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.
|
||||
|
||||
`prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images.
|
||||
|
||||
`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",
|
||||
"version": "2026-08-26.18",
|
||||
"version": "2026-08-26.16",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -123,7 +123,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/projection-contract.md"
|
||||
],
|
||||
"sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1"
|
||||
"sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -527,6 +527,10 @@ 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
|
||||
@@ -1022,15 +1026,6 @@ 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,
|
||||
@@ -1063,8 +1058,7 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn hydrate_design_agent_session(
|
||||
app: tauri::AppHandle,
|
||||
pub(crate) fn hydrate_design_agent_session(
|
||||
project_path: String,
|
||||
) -> Result<Option<DesignView>, String> {
|
||||
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 {
|
||||
return Err("策划会话与当前项目不匹配".into());
|
||||
}
|
||||
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)
|
||||
})
|
||||
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
|
||||
Ok(Some(design_view(&session, active.is_none())))
|
||||
}
|
||||
|
||||
fn design_session_error_is_recoverable(error: &str) -> bool {
|
||||
@@ -1989,94 +1958,4 @@ 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,31 +321,15 @@ 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 {
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 原文未找到:{}{}",
|
||||
display,
|
||||
design_patch_location_hint(&content, old)
|
||||
));
|
||||
continue;
|
||||
return Err(format!("edits[{index}] 原文未找到:{display}"));
|
||||
}
|
||||
if count != 1 {
|
||||
let start = content.find(old).expect("count checked");
|
||||
let line = design_patch_line_number(&content, start);
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行"
|
||||
return Err(format!(
|
||||
"edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let start = content.find(old).expect("count checked");
|
||||
let end = start + old.len();
|
||||
@@ -353,43 +337,18 @@ pub(crate) fn execute_design_file_tool(
|
||||
.iter()
|
||||
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
||||
{
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text"
|
||||
return Err(format!(
|
||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}"
|
||||
));
|
||||
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"));
|
||||
}
|
||||
matches.sort_unstable_by_key(|(_, start, _)| *start);
|
||||
let mut updated = String::with_capacity(content.len());
|
||||
let mut cursor = 0;
|
||||
for (index, start, end) in matches {
|
||||
let mut updated = content.clone();
|
||||
for (index, start, end) in matches.into_iter().rev() {
|
||||
let (_, new) = &normalized[index];
|
||||
updated.push_str(&content[cursor..start]);
|
||||
updated.push_str(new);
|
||||
cursor = end;
|
||||
updated.replace_range(start..end, new);
|
||||
}
|
||||
updated.push_str(&content[cursor..]);
|
||||
if updated == content {
|
||||
return Err(format!("没有产生修改:{display}"));
|
||||
}
|
||||
@@ -437,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(
|
||||
root: &Path,
|
||||
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
||||
@@ -788,22 +693,6 @@ 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",
|
||||
@@ -839,35 +728,6 @@ mod tests {
|
||||
assert!(!root.join("design_artifacts/notes").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_file_applies_out_of_order_edits_with_changing_utf8_lengths() {
|
||||
let temp = test_root();
|
||||
let root = temp.path();
|
||||
execute_design_file_tool(
|
||||
root,
|
||||
"write_file",
|
||||
&json!({"path":"notes/design.md","content":"开头\n甲\n保留一\n乙乙\n保留二\n丙\n结尾"}),
|
||||
)
|
||||
.expect("write");
|
||||
execute_design_file_tool(
|
||||
root,
|
||||
"patch_file",
|
||||
&json!({
|
||||
"path":"notes/design.md",
|
||||
"edits":[
|
||||
{"old_text":"丙","new_text":"新的结论"},
|
||||
{"old_text":"甲","new_text":"扩展A"},
|
||||
{"old_text":"乙乙","new_text":"乙"}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.expect("patch out of order");
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("design_artifacts/notes/design.md")).expect("read disk"),
|
||||
"开头\n扩展A\n保留一\n乙\n保留二\n新的结论\n结尾"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_context_injects_current_skill_only() {
|
||||
let resources = DesignResources::new(pack_root()).expect("pack");
|
||||
|
||||
@@ -482,38 +482,9 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
|
||||
}
|
||||
|
||||
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
|
||||
read_direct_project_history_entries_filtered_at(root, None, false)
|
||||
}
|
||||
|
||||
fn is_direct_project_chat_message(item: &Value) -> bool {
|
||||
matches!(
|
||||
item.get("role").and_then(Value::as_str),
|
||||
Some("user" | "assistant")
|
||||
) && item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| {
|
||||
parts.iter().any(|part| {
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| !text.is_empty())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 消息模式逐行丢弃工具输出,只保留聊天正文,避免 40 MiB 工具日志被整表积累或发给 UI。
|
||||
fn read_direct_project_history_entries_filtered_at(
|
||||
root: &Path,
|
||||
before_item_id: Option<&str>,
|
||||
messages_only: bool,
|
||||
) -> Result<Vec<(Value, u64)>, String> {
|
||||
let path = history_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
|
||||
return if before_item_id.is_some() {
|
||||
Err("DirectProject 历史游标对应的文件已不存在".to_string())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
};
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let file = File::open(&path)
|
||||
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
@@ -548,12 +519,6 @@ fn read_direct_project_history_entries_filtered_at(
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
continue;
|
||||
}
|
||||
if before_item_id.is_some_and(|id| item.get("id").and_then(Value::as_str) == Some(id)) {
|
||||
return Ok(items);
|
||||
}
|
||||
if messages_only && !is_direct_project_chat_message(&item) {
|
||||
continue;
|
||||
}
|
||||
items.push((
|
||||
item,
|
||||
parsed
|
||||
@@ -562,45 +527,7 @@ fn read_direct_project_history_entries_filtered_at(
|
||||
.unwrap_or(0),
|
||||
));
|
||||
}
|
||||
match before_item_id {
|
||||
Some(item_id) => Err(format!("DirectProject 历史中不存在 item:{item_id}")),
|
||||
None => Ok(items),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_chat_items_slice_at(
|
||||
root: &Path,
|
||||
before_item_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
|
||||
let entries = read_direct_project_history_entries_filtered_at(root, before_item_id, true)?;
|
||||
let mut start = entries.len().saturating_sub(limit.clamp(1, 200));
|
||||
// 旧消息可能没有 ID:保留原文,并向前扩到可寻址的已有 ID,不能制造原始消息身份。
|
||||
while start > 0
|
||||
&& entries[start]
|
||||
.0
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(str::is_empty)
|
||||
{
|
||||
start -= 1;
|
||||
}
|
||||
let timestamps = entries[start..]
|
||||
.iter()
|
||||
.filter_map(|(item, at)| {
|
||||
let id = item.get("id").and_then(Value::as_str)?;
|
||||
(*at > 0).then(|| (id.to_string(), *at))
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
entries
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.map(|(item, _)| item)
|
||||
.collect(),
|
||||
start > 0,
|
||||
timestamps,
|
||||
))
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_history_items_slice_at(
|
||||
@@ -713,165 +640,6 @@ mod tests {
|
||||
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
|
||||
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
|
||||
|
||||
fn write_items(root: &std::path::Path, items: &[Value]) {
|
||||
let lines = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
json!({"type": "response_item", "payload": item, "recordedAt": 1000 + index})
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
write_history_lines(root, &lines.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_pages_skip_tool_only_tail_and_gaps_without_losing_messages_or_times() {
|
||||
let root = init_history_project("message-pages");
|
||||
let mut raw = Vec::new();
|
||||
let mut expected = Vec::new();
|
||||
for n in 0..44 {
|
||||
let item = json!({
|
||||
"id": format!("message-{n}"), "type": "message",
|
||||
"role": if n == 0 || n == 38 { "user" } else { "assistant" },
|
||||
"content": [{"type": "output_text", "text": format!("消息 {n}")}],
|
||||
});
|
||||
expected.push(item.clone());
|
||||
raw.push(item);
|
||||
for tool in 0..25 {
|
||||
raw.push(json!({
|
||||
"id": format!("tool-{n}-{tool}"), "type": "function_call_output",
|
||||
"output": "工具结果不应占聊天页名额",
|
||||
}));
|
||||
}
|
||||
}
|
||||
write_items(root.path(), &raw);
|
||||
let path = history_path(root.path());
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let (old_page, _, _) =
|
||||
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert!(old_page
|
||||
.iter()
|
||||
.all(|item| item["type"] == "function_call_output"));
|
||||
let mut cursor = None;
|
||||
let mut all = Vec::new();
|
||||
let mut sizes = Vec::new();
|
||||
loop {
|
||||
let (mut page, more, timestamps) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
|
||||
.unwrap();
|
||||
sizes.push(page.len());
|
||||
for item in &page {
|
||||
let index = raw.iter().position(|raw| raw["id"] == item["id"]).unwrap();
|
||||
assert_eq!(
|
||||
timestamps[item["id"].as_str().unwrap()],
|
||||
1000 + index as u64
|
||||
);
|
||||
}
|
||||
let next = page
|
||||
.first()
|
||||
.and_then(|item| item["id"].as_str())
|
||||
.map(str::to_string);
|
||||
page.append(&mut all);
|
||||
all = page;
|
||||
if !more {
|
||||
break;
|
||||
}
|
||||
assert_ne!(next, cursor);
|
||||
cursor = next;
|
||||
assert!(sizes.len() < 10);
|
||||
}
|
||||
assert_eq!(sizes, vec![20, 20, 4]);
|
||||
assert_eq!(all, expected);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_pages_handle_empty_content_internal_context_and_missing_ids() {
|
||||
let root = init_history_project("message-page-boundary");
|
||||
write_items(
|
||||
root.path(),
|
||||
&[
|
||||
json!({"id":"u", "role":"user", "content":[{"text":"第一条"}]}),
|
||||
json!({"role":"assistant", "content":[{"text":"无ID的旧消息"}]}),
|
||||
json!({"id":"a", "role":"assistant", "content":[{"text":"最后一条"}]}),
|
||||
json!({"id":"empty", "role":"assistant", "content":[{"text":""}]}),
|
||||
json!({"id":"internal", "role":"user", "content":[{"text":"<environment_context>内部</environment_context>"}]}),
|
||||
json!({"id":"reason", "type":"reasoning", "content":[{"text":"推理"}]}),
|
||||
],
|
||||
);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), None, 1).unwrap();
|
||||
assert_eq!(page[0]["id"], "a");
|
||||
assert!(more);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), Some("a"), 1).unwrap();
|
||||
assert_eq!(page.len(), 2);
|
||||
assert_eq!(page[0]["id"], "u");
|
||||
assert!(page[1].get("id").is_none());
|
||||
assert!(!more);
|
||||
assert!(
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), Some("missing"), 20)
|
||||
.is_err()
|
||||
);
|
||||
write_items(
|
||||
root.path(),
|
||||
&[json!({"id":"tool", "type":"function_call", "arguments":"{}"})],
|
||||
);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert!(page.is_empty());
|
||||
assert!(!more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "人工只读诊断:通过 AGC_HISTORY_REPLAY_SOURCE 提供原始历史文件"]
|
||||
fn replay_external_chat_history_pages_without_mutating_source() {
|
||||
let source = std::env::var_os("AGC_HISTORY_REPLAY_SOURCE").expect("provide replay source");
|
||||
let before = std::fs::read(&source).expect("read source");
|
||||
let root = init_history_project("external-history-replay");
|
||||
let path = history_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, &before).unwrap();
|
||||
let expected =
|
||||
super::read_direct_project_history_entries_filtered_at(root.path(), None, true)
|
||||
.expect("read messages");
|
||||
let mut cursor = None;
|
||||
let mut all = Vec::new();
|
||||
let mut pages = 0;
|
||||
loop {
|
||||
let (mut items, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
|
||||
.expect("read page");
|
||||
let next = items
|
||||
.first()
|
||||
.and_then(|item| item["id"].as_str())
|
||||
.map(str::to_string);
|
||||
items.append(&mut all);
|
||||
all = items;
|
||||
pages += 1;
|
||||
if !more {
|
||||
break;
|
||||
}
|
||||
assert!(next.is_some() && next != cursor, "cursor must advance");
|
||||
assert!(pages <= expected.len() + 1, "pagination must terminate");
|
||||
cursor = next;
|
||||
}
|
||||
assert!(
|
||||
all.iter().eq(expected.iter().map(|(item, _)| item)),
|
||||
"message order and content must match"
|
||||
);
|
||||
assert!(
|
||||
std::fs::read(&source).unwrap() == before,
|
||||
"source must remain unchanged"
|
||||
);
|
||||
eprintln!(
|
||||
"history replay: messages={}, pages={pages}, users={}",
|
||||
all.len(),
|
||||
all.iter().filter(|item| item["role"] == "user").count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
|
||||
let root = init_history_project("history-time");
|
||||
|
||||
@@ -59,7 +59,6 @@ pub(crate) struct DirectThreadHistorySlice {
|
||||
pub(crate) items: Vec<Value>,
|
||||
pub(crate) has_more: bool,
|
||||
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
|
||||
pub(crate) oldest_item_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -24,6 +24,7 @@ const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 1024;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_QUERY_CHARS: usize = 400;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESULTS: usize = 5;
|
||||
const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format=rss";
|
||||
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS: usize = 120;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS: usize = 80;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PAGE_SIZE: usize = 100;
|
||||
@@ -88,7 +89,7 @@ struct DirectToolBridgeRequest {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum DirectResourceGenerationKind {
|
||||
enum DirectResourceGenerationKind {
|
||||
Image,
|
||||
Video,
|
||||
CharacterAnimation,
|
||||
@@ -97,7 +98,7 @@ pub(crate) enum DirectResourceGenerationKind {
|
||||
}
|
||||
|
||||
impl DirectResourceGenerationKind {
|
||||
pub(crate) fn parse(value: &str) -> Result<Self, String> {
|
||||
fn parse(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"image" => Ok(Self::Image),
|
||||
"video" => Ok(Self::Video),
|
||||
@@ -118,7 +119,7 @@ impl DirectResourceGenerationKind {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn edit_kind(self) -> LocalProjectResourceEditKind {
|
||||
fn edit_kind(self) -> LocalProjectResourceEditKind {
|
||||
match self {
|
||||
Self::Image => LocalProjectResourceEditKind::ImageReference,
|
||||
Self::Video => LocalProjectResourceEditKind::Video,
|
||||
@@ -127,11 +128,6 @@ impl DirectResourceGenerationKind {
|
||||
Self::BackgroundMusic => LocalProjectResourceEditKind::BackgroundMusic,
|
||||
}
|
||||
}
|
||||
|
||||
/// 提示词上限只从客户端权威口径取值,工具桥与 MCP 层共用同一份数字。
|
||||
pub(crate) fn prompt_max_chars(self) -> usize {
|
||||
resource_edit_prompt_max_chars(&self.edit_kind())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -1040,12 +1036,6 @@ fn bridge_account_asset_import_inputs(
|
||||
Ok((asset_ids, local_paths))
|
||||
}
|
||||
|
||||
/// 源资源身份不在当前项目 manifest 时的统一提示。
|
||||
///
|
||||
/// 只报「不属于已登记资源」会让模型原地重试;这里必须把下一步可执行动作写清楚:
|
||||
/// 已登记资源走 `agc_list_registered_assets`,只在项目里存在的文件先登记再重试。
|
||||
const DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE: &str = "sourceLocalAssetId 不是当前项目已登记资源:先调用 agc_list_registered_assets 选择已有 localAssetId;若目标图片只在项目里,先用 agc_list_project_files 确认它 assetImportable=true,再用 agc_import_account_assets.localPaths 登记后重试。";
|
||||
|
||||
fn bridge_resource_generation_input(
|
||||
arguments: &Value,
|
||||
) -> Result<DirectResourceGenerationInput, String> {
|
||||
@@ -1064,20 +1054,18 @@ fn bridge_resource_generation_input(
|
||||
"sourceLocalAssetId",
|
||||
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS,
|
||||
)?;
|
||||
// prompt 的形状校验只用信封级上限,真正生效的按 kind 上限由紧随其后的权威判定给出
|
||||
// 精确数字;否则通用 4000 会先于「图片编辑 32000 / 音效 1900」误报成安全边界错误。
|
||||
let prompt = bridge_bounded_string(arguments, "prompt", DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES)?;
|
||||
let prompt = bridge_bounded_string(
|
||||
arguments,
|
||||
"prompt",
|
||||
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS,
|
||||
)?;
|
||||
let asset_name = bridge_bounded_string(
|
||||
arguments,
|
||||
"assetName",
|
||||
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
|
||||
)?;
|
||||
let prompt_max_chars = kind.prompt_max_chars();
|
||||
if prompt.chars().count() > prompt_max_chars {
|
||||
return Err(resource_edit_prompt_limit_error(
|
||||
&kind.edit_kind(),
|
||||
prompt_max_chars,
|
||||
));
|
||||
if kind == DirectResourceGenerationKind::BackgroundMusic && prompt.chars().count() > 140 {
|
||||
return Err("背景音乐提示词必须在 1..=140 字符内".to_string());
|
||||
}
|
||||
match (kind, mode, source_local_asset_id.as_ref()) {
|
||||
(DirectResourceGenerationKind::Image, DirectResourceGenerationMode::Create, _) => {
|
||||
@@ -1818,7 +1806,7 @@ async fn bridge_create_or_derive_resource(
|
||||
.iter()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string())
|
||||
.ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string())
|
||||
})
|
||||
.transpose()?;
|
||||
let prompt_sha256 = format!("{:x}", Sha256::digest(input.prompt.as_bytes()));
|
||||
@@ -1899,7 +1887,7 @@ async fn bridge_create_or_derive_resource(
|
||||
|
||||
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||
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, "asset.register")?;
|
||||
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
|
||||
@@ -1908,14 +1896,12 @@ 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
|
||||
.iter()
|
||||
.find(|asset| asset.id == source_asset_id)
|
||||
.ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string())?;
|
||||
.ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string())?;
|
||||
if !source_asset.media_type.starts_with("image/") {
|
||||
return Err("抠图工具只接受当前项目已登记的图片资源".to_string());
|
||||
}
|
||||
@@ -1934,34 +1920,22 @@ 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 = background_removal_request_fingerprint(
|
||||
&source_asset_id,
|
||||
&asset_name,
|
||||
background_mode,
|
||||
screen_color,
|
||||
);
|
||||
let fingerprint = format!("{}\0{}", source_asset_id, asset_name);
|
||||
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(&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()
|
||||
.await
|
||||
@@ -1998,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 {
|
||||
let object = value.as_object();
|
||||
json!({
|
||||
@@ -2743,29 +2703,6 @@ 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};
|
||||
|
||||
@@ -2807,67 +2744,6 @@ mod tests {
|
||||
.contains("x-genarrative-client:"));
|
||||
}
|
||||
|
||||
/// 按 kind 的提示词上限只来自客户端权威口径;超限必须在构造工具输入时就被拒绝,
|
||||
/// 不能再出现写死的数字(2026-09-17 的背景音乐 140 就是写死在桥这一层的)。
|
||||
#[test]
|
||||
fn bridge_resource_prompt_limits_follow_the_client_authority() {
|
||||
for (kind, edit_kind) in [
|
||||
(
|
||||
"background-music",
|
||||
LocalProjectResourceEditKind::BackgroundMusic,
|
||||
),
|
||||
("sound-effect", LocalProjectResourceEditKind::SoundEffect),
|
||||
("video", LocalProjectResourceEditKind::Video),
|
||||
(
|
||||
"character-animation",
|
||||
LocalProjectResourceEditKind::CharacterAnimation,
|
||||
),
|
||||
("image", LocalProjectResourceEditKind::ImageReference),
|
||||
] {
|
||||
let authority = resource_edit_prompt_max_chars(&edit_kind);
|
||||
let mode = if matches!(
|
||||
edit_kind,
|
||||
LocalProjectResourceEditKind::ImageReference
|
||||
| LocalProjectResourceEditKind::CharacterAnimation
|
||||
) {
|
||||
"derive"
|
||||
} else {
|
||||
"create"
|
||||
};
|
||||
let mut arguments = json!({
|
||||
"kind": kind,
|
||||
"mode": mode,
|
||||
"prompt": "字".repeat(authority),
|
||||
"assetName": "边界名称"
|
||||
});
|
||||
if mode == "derive" {
|
||||
arguments["sourceLocalAssetId"] = json!("registered-source");
|
||||
}
|
||||
bridge_resource_generation_input(&arguments)
|
||||
.unwrap_or_else(|error| panic!("{kind} 恰好等于上限必须通过:{error}"));
|
||||
|
||||
arguments["prompt"] = json!("字".repeat(authority + 1));
|
||||
let error = match bridge_resource_generation_input(&arguments) {
|
||||
Ok(_) => panic!("{kind} 超过按 kind 上限的提示词必须被拒绝"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
error.contains(&authority.to_string()) && error.contains(kind_label(&edit_kind)),
|
||||
"{kind} 的拒绝文案必须带上真实上限与类型:{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_label(edit_kind: &LocalProjectResourceEditKind) -> &'static str {
|
||||
match edit_kind {
|
||||
LocalProjectResourceEditKind::BackgroundMusic => "背景音乐",
|
||||
LocalProjectResourceEditKind::SoundEffect => "音效",
|
||||
LocalProjectResourceEditKind::Video => "视频",
|
||||
LocalProjectResourceEditKind::CharacterAnimation => "角色动画",
|
||||
_ => "资源编辑",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_argument_bounds_are_deterministic() {
|
||||
assert_eq!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5035,21 +5035,7 @@ pub(crate) fn read_local_project_text_preview_at(
|
||||
return Err("只能读取当前项目已登记的文档资源".to_string());
|
||||
}
|
||||
cancellation.check()?;
|
||||
let mut preview =
|
||||
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)?;
|
||||
if normalized_path.to_ascii_lowercase().ends_with(".json") {
|
||||
preview.ui_design_asset_id = manifest.assets.iter().find_map(|asset| {
|
||||
(asset.local_path == normalized_path
|
||||
&& ui_editor::persistence::is_valid_ui_design_json(
|
||||
&preview.content,
|
||||
&manifest.project_id,
|
||||
&asset.id,
|
||||
))
|
||||
.then(|| asset.id.clone())
|
||||
});
|
||||
}
|
||||
cancellation.check()?;
|
||||
Ok(preview)
|
||||
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -5415,29 +5401,19 @@ pub(crate) async fn read_direct_project_history_slice(
|
||||
project_path: String,
|
||||
before_item_id: Option<String>,
|
||||
limit: Option<usize>,
|
||||
messages_only: Option<bool>,
|
||||
) -> Result<DirectThreadHistorySlice, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
let read_slice = if messages_only.unwrap_or(false) {
|
||||
read_direct_project_chat_items_slice_at
|
||||
} else {
|
||||
read_direct_project_history_items_slice_at
|
||||
};
|
||||
let (items, has_more, item_timestamps) =
|
||||
read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?;
|
||||
let oldest_item_id = items
|
||||
.first()
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string);
|
||||
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
|
||||
root,
|
||||
before_item_id.as_deref(),
|
||||
limit.unwrap_or(20),
|
||||
)?;
|
||||
Ok(DirectThreadHistorySlice {
|
||||
items,
|
||||
has_more,
|
||||
item_timestamps,
|
||||
oldest_item_id,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -284,6 +284,7 @@ mod resource_inspect;
|
||||
mod resource_preview_scheduler;
|
||||
mod runner;
|
||||
mod swarm_cli;
|
||||
mod template_library;
|
||||
mod tool_plan_handoff;
|
||||
mod user_input;
|
||||
mod windows;
|
||||
@@ -323,6 +324,7 @@ use resource_inspect::*;
|
||||
use resource_preview_scheduler::*;
|
||||
use runner::*;
|
||||
use swarm_cli::*;
|
||||
use template_library::*;
|
||||
use user_input::*;
|
||||
use windows::*;
|
||||
#[tauri::command]
|
||||
@@ -2660,7 +2662,10 @@ fn main() {
|
||||
start_game_creator_external_mcp,
|
||||
stop_game_creator_external_mcp,
|
||||
create_automatic_local_game_project,
|
||||
create_automatic_local_game_project_from_template,
|
||||
init_local_game_project,
|
||||
fetch_game_template_library,
|
||||
download_game_template,
|
||||
import_local_godot_project,
|
||||
import_local_cocos_project,
|
||||
is_local_project_directory_non_empty,
|
||||
|
||||
@@ -728,16 +728,7 @@ fn validate_resource_edit_uuid(value: &str, label: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 资源编辑提示词上限的**唯一口径**。
|
||||
///
|
||||
/// 三个调用方都必须从这里取数,禁止各自写死数字:
|
||||
/// 1. 本文件的提交校验(`normalize_resource_edit_prompt`);
|
||||
/// 2. `agc_tools` MCP 工具层(`direct_tools_mcp.rs` 的参数校验与工具 schema);
|
||||
/// 3. 客户端受控工具桥(`direct_tool_bridge.rs`)。
|
||||
///
|
||||
/// 客户端 UI 的 `resourceEditPromptMaxLength`(`resourceEditModel.ts`)是同一份口径的
|
||||
/// 前端镜像;改数字必须同时改这里、那里,以及工具 schema 里按 kind 声明 `maxLength`。
|
||||
pub(crate) fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize {
|
||||
fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize {
|
||||
match edit_kind {
|
||||
LocalProjectResourceEditKind::BackgroundMusic => 140,
|
||||
LocalProjectResourceEditKind::SoundEffect => 1_900,
|
||||
@@ -748,24 +739,6 @@ pub(crate) fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEdi
|
||||
}
|
||||
}
|
||||
|
||||
/// 提示词超限的拒绝文案:与上限同一个口径,MCP 层、工具桥和提交校验复用同一条字符串,
|
||||
/// 保证模型看到的数字就是真实生效的数字。
|
||||
pub(crate) fn resource_edit_prompt_limit_error(
|
||||
edit_kind: &LocalProjectResourceEditKind,
|
||||
max_chars: usize,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}资源编辑提示词必须在 1..={max_chars} 字符内",
|
||||
match edit_kind {
|
||||
LocalProjectResourceEditKind::BackgroundMusic => "背景音乐",
|
||||
LocalProjectResourceEditKind::SoundEffect => "音效",
|
||||
LocalProjectResourceEditKind::Video => "视频",
|
||||
LocalProjectResourceEditKind::CharacterAnimation => "角色动画",
|
||||
_ => "",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_resource_edit_prompt(
|
||||
edit_kind: &LocalProjectResourceEditKind,
|
||||
value: &str,
|
||||
@@ -773,7 +746,16 @@ fn normalize_resource_edit_prompt(
|
||||
let value = value.trim();
|
||||
let max_chars = resource_edit_prompt_max_chars(edit_kind);
|
||||
if value.is_empty() || value.chars().count() > max_chars {
|
||||
return Err(resource_edit_prompt_limit_error(edit_kind, max_chars));
|
||||
return Err(format!(
|
||||
"{}资源编辑提示词必须在 1..={max_chars} 字符内",
|
||||
match edit_kind {
|
||||
LocalProjectResourceEditKind::BackgroundMusic => "背景音乐",
|
||||
LocalProjectResourceEditKind::SoundEffect => "音效",
|
||||
LocalProjectResourceEditKind::Video => "视频",
|
||||
LocalProjectResourceEditKind::CharacterAnimation => "角色动画",
|
||||
_ => "",
|
||||
}
|
||||
));
|
||||
}
|
||||
if value
|
||||
.chars()
|
||||
|
||||
@@ -24,9 +24,6 @@ pub(crate) struct LocalProjectTextPreview {
|
||||
pub(crate) media_type: String,
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) content: String,
|
||||
/// 仅由已登记资源的原生 UI State 校验设置;前端不根据正文猜测编辑能力。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) ui_design_asset_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
@@ -166,7 +163,6 @@ pub(crate) fn load_local_project_text_preview_with_cancellation(
|
||||
media_type: media_type.to_string(),
|
||||
byte_len: content.len() as u64,
|
||||
content,
|
||||
ui_design_asset_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -158,7 +158,7 @@ pub(crate) fn load_ui_design_state_at(
|
||||
let root = Path::new(input.project_path.trim());
|
||||
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
||||
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
||||
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
||||
let document =
|
||||
read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?;
|
||||
validate_document(&document, &expected_project_id, &asset_id)?;
|
||||
@@ -175,7 +175,7 @@ pub(crate) fn generate_ui_design_code_at(
|
||||
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
||||
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
||||
let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?;
|
||||
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
||||
let document =
|
||||
read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?;
|
||||
let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?;
|
||||
@@ -225,9 +225,9 @@ pub(crate) fn save_ui_design_state_at(
|
||||
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
||||
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
||||
|
||||
let preflight_asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||
let preflight_asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
||||
let _lock = acquire_project_write_lock(root, "ui_design.state_save")?;
|
||||
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
||||
if asset.local_path != preflight_asset.local_path {
|
||||
return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string());
|
||||
}
|
||||
@@ -311,19 +311,6 @@ fn ui_design_asset(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
asset_id: &str,
|
||||
) -> Result<GameCreationAppAssetManifestEntry, String> {
|
||||
let asset = registered_json_asset(root, expected_project_id, asset_id)?;
|
||||
// 新状态初始化仍是显式 UI 创建动作,不能因放开已有设计的登记标签而覆盖普通 JSON。
|
||||
if asset.kind != "UI" || asset.media_type != "application/json" {
|
||||
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
|
||||
}
|
||||
Ok(asset)
|
||||
}
|
||||
|
||||
fn registered_json_asset(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
asset_id: &str,
|
||||
) -> Result<GameCreationAppAssetManifestEntry, String> {
|
||||
let manifest = read_existing_manifest_for_project(root)?;
|
||||
if manifest.project_id != expected_project_id {
|
||||
@@ -334,10 +321,8 @@ fn registered_json_asset(
|
||||
.into_iter()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.ok_or_else(|| "UI 设计资源不存在".to_string())?;
|
||||
if !asset.local_path.to_ascii_lowercase().ends_with(".json")
|
||||
|| !is_supported_project_text_resource(&asset.local_path, &asset.media_type)
|
||||
{
|
||||
return Err("目标资源不是已登记的 JSON 资产".to_string());
|
||||
if asset.kind != "UI" || asset.media_type != "application/json" {
|
||||
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
|
||||
}
|
||||
normalize_relative_path(&asset.local_path)?;
|
||||
Ok(asset)
|
||||
@@ -408,27 +393,10 @@ fn read_ui_design_document_path(path: &Path) -> Result<PersistedUiDesignState, S
|
||||
"UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"
|
||||
));
|
||||
}
|
||||
parse_ui_design_document(&bytes)
|
||||
.map_err(|error| format!("读取 UI 设计 State 失败:{}: {error}", path.display()))
|
||||
}
|
||||
|
||||
/// 复用编辑器完整契约识别已读取的 JSON;不重读文件,也不触发恢复或任何写入。
|
||||
pub(crate) fn is_valid_ui_design_json(content: &str, project_id: &str, asset_id: &str) -> bool {
|
||||
parse_ui_design_document(content.as_bytes())
|
||||
.and_then(|document| validate_document(&document, project_id, asset_id))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn parse_ui_design_document(bytes: &[u8]) -> Result<PersistedUiDesignState, String> {
|
||||
if bytes.len() > UI_DESIGN_STATE_MAX_BYTES {
|
||||
return Err(format!(
|
||||
"UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"
|
||||
));
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes)
|
||||
.map_err(|error| format!("解析 UI 设计 State 失败:{error}"))?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("解析 UI 设计 State 失败:{}: {error}", path.display()))?;
|
||||
let document: PersistedUiDesignState = serde_json::from_value(value.clone())
|
||||
.map_err(|error| format!("解析 UI 设计 State 契约失败:{error}"))?;
|
||||
.map_err(|error| format!("解析 UI 设计 State 契约失败:{}: {error}", path.display()))?;
|
||||
let canonical: serde_json::Value =
|
||||
serde_json::from_slice(&serialize_ui_design_document(&document)?)
|
||||
.map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?;
|
||||
@@ -879,170 +847,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_preview_recognition_requires_canonical_state_and_resource_identity() {
|
||||
let document = serde_json::to_value(empty_document(PROJECT_ID, "design")).unwrap();
|
||||
let content = document.to_string();
|
||||
assert!(is_valid_ui_design_json(&content, PROJECT_ID, "design"));
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&content,
|
||||
"other-project",
|
||||
"design"
|
||||
));
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&content,
|
||||
PROJECT_ID,
|
||||
"other-asset"
|
||||
));
|
||||
for content in ["{broken", "{}", "[]", r#"{"type":"UI"}"#] {
|
||||
assert!(!is_valid_ui_design_json(content, PROJECT_ID, "design"));
|
||||
}
|
||||
for (pointer, value) in [
|
||||
("/schemaVersion", serde_json::json!("unknown-schema")),
|
||||
("/revision", serde_json::json!(9_007_199_254_740_992u64)),
|
||||
("/state/ui_trees", serde_json::json!([{}])),
|
||||
] {
|
||||
let mut invalid = document.clone();
|
||||
*invalid.pointer_mut(pointer).unwrap() = value;
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&invalid.to_string(),
|
||||
PROJECT_ID,
|
||||
"design"
|
||||
));
|
||||
}
|
||||
let mut unknown = document.clone();
|
||||
unknown["state"]["unknown"] = serde_json::json!(true);
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&unknown.to_string(),
|
||||
PROJECT_ID,
|
||||
"design"
|
||||
));
|
||||
let invalid_state = PersistedUiDesignState {
|
||||
state: state_with_unavailable_image("../outside.png"),
|
||||
..empty_document(PROJECT_ID, "design")
|
||||
};
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&serde_json::to_string(&invalid_state).unwrap(),
|
||||
PROJECT_ID,
|
||||
"design",
|
||||
));
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&" ".repeat(UI_DESIGN_STATE_MAX_BYTES + 1),
|
||||
PROJECT_ID,
|
||||
"design",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_preview_and_editor_accept_valid_state_without_rewriting_asset_kind() {
|
||||
for kind in ["UI", "ui", "ui-design", "document"] {
|
||||
let (directory, asset_id) = fixture();
|
||||
crate::project::mutate_manifest_at(directory.path(), |manifest| {
|
||||
manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.unwrap()
|
||||
.kind = kind.to_string();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let path = directory.path().join("ui/design.json");
|
||||
let before = fs::read(&path).unwrap();
|
||||
let preview = read_local_project_text_preview_at(
|
||||
directory.path().to_str().unwrap(),
|
||||
"ui/design.json",
|
||||
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
preview.ui_design_asset_id.as_deref(),
|
||||
Some(asset_id.as_str())
|
||||
);
|
||||
assert_eq!(fs::read(&path).unwrap(), before);
|
||||
let loaded = load_ui_design_state_at(LoadUiDesignStateInput {
|
||||
project_path: directory.path().to_string_lossy().into_owned(),
|
||||
expected_project_id: PROJECT_ID.to_string(),
|
||||
asset_id: asset_id.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(loaded.revision, 0);
|
||||
let saved = save_ui_design_state_at(input(
|
||||
directory.path(),
|
||||
&asset_id,
|
||||
0,
|
||||
state_with_unavailable_image("assets/page.png"),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
saved,
|
||||
SaveUiDesignStateResult::Saved { revision: 1, .. }
|
||||
));
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(directory.path())
|
||||
.unwrap()
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.unwrap()
|
||||
.kind,
|
||||
kind,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_or_foreign_json_preview_never_grants_ui_editing_or_overwrites_content() {
|
||||
let (directory, asset_id) = fixture();
|
||||
crate::project::mutate_manifest_at(directory.path(), |manifest| {
|
||||
manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.unwrap()
|
||||
.kind = "document".to_string();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let path = directory.path().join("ui/design.json");
|
||||
let foreign = serde_json::to_string(&empty_document("other-project", &asset_id)).unwrap();
|
||||
for content in [r#"{"ordinary":true}"#, "{broken", foreign.as_str()] {
|
||||
fs::write(&path, content).unwrap();
|
||||
let preview = read_local_project_text_preview_at(
|
||||
directory.path().to_str().unwrap(),
|
||||
"ui/design.json",
|
||||
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
|
||||
).unwrap();
|
||||
assert_eq!(preview.ui_design_asset_id, None);
|
||||
assert_eq!(preview.content, content);
|
||||
assert!(load_ui_design_state_at(LoadUiDesignStateInput {
|
||||
project_path: directory.path().to_string_lossy().into_owned(),
|
||||
expected_project_id: PROJECT_ID.to_string(),
|
||||
asset_id: asset_id.clone(),
|
||||
})
|
||||
.is_err());
|
||||
assert!(save_ui_design_state_at(input(
|
||||
directory.path(),
|
||||
&asset_id,
|
||||
0,
|
||||
empty_document(PROJECT_ID, &asset_id).state,
|
||||
))
|
||||
.is_err());
|
||||
assert!(
|
||||
initialize_ui_design_state_at(directory.path(), PROJECT_ID, &asset_id).is_err()
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
fs::write(
|
||||
directory.path().join("ui/unregistered.json"),
|
||||
serde_json::to_string(&empty_document(PROJECT_ID, &asset_id)).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(read_local_project_text_preview_at(
|
||||
directory.path().to_str().unwrap(), "ui/unregistered.json",
|
||||
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
|
||||
).is_err());
|
||||
}
|
||||
|
||||
fn state_with_unavailable_image(path: &str) -> State {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"ui_trees": [],
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*",
|
||||
"devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob:; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
|
||||
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*",
|
||||
"devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"schemaVersion": "agc-template-library.v1",
|
||||
"library": "agc-game-templates",
|
||||
"libraryVersion": 1,
|
||||
"updatedAt": "2026-09-17T03:22:43Z",
|
||||
"templates": [
|
||||
{
|
||||
"id": "blank-2d-canvas",
|
||||
"title": "空白二维画布工程",
|
||||
"summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。",
|
||||
"tags": [
|
||||
"空白",
|
||||
"起步工程",
|
||||
"2d",
|
||||
"canvas"
|
||||
],
|
||||
"runtime": "html",
|
||||
"engine": "canvas",
|
||||
"engineVersion": "",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T03:22:43Z",
|
||||
"entry": "game/index.html",
|
||||
"zipKey": "templates/v1/blank-2d-canvas/template.zip",
|
||||
"zipSizeBytes": 1534,
|
||||
"zipSha256": "ff8f84e4793941acaf161738c2795f65c5d5390de8614f51aa9e3a5771767134",
|
||||
"coverKey": "templates/v1/blank-2d-canvas/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "afb753dc6d3de0f9fb6e03ec94f2be7221dd04c9d4ab3cf92311879f0af25192",
|
||||
"metadataKey": "templates/v1/blank-2d-canvas/template.json"
|
||||
},
|
||||
{
|
||||
"id": "blank-3d-scene",
|
||||
"title": "空白三维场景工程",
|
||||
"summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。",
|
||||
"tags": [
|
||||
"空白",
|
||||
"起步工程",
|
||||
"3d",
|
||||
"three.js"
|
||||
],
|
||||
"runtime": "html",
|
||||
"engine": "three.js",
|
||||
"engineVersion": "0.180.0",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T03:22:43Z",
|
||||
"entry": "game/index.html",
|
||||
"zipKey": "templates/v1/blank-3d-scene/template.zip",
|
||||
"zipSizeBytes": 1644,
|
||||
"zipSha256": "f3f295f4e5adcf1445d75229dc1b583376a9bc96d3f27a257d69ee3b7cace892",
|
||||
"coverKey": "templates/v1/blank-3d-scene/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "1429232adaf6df4457e45b4fc8d7ee9fab2e6bffce9f3f0016e91b81bb66c6a7",
|
||||
"metadataKey": "templates/v1/blank-3d-scene/template.json"
|
||||
},
|
||||
{
|
||||
"id": "blank-web",
|
||||
"title": "空白网页工程",
|
||||
"summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。",
|
||||
"tags": [
|
||||
"空白",
|
||||
"起步工程",
|
||||
"网页",
|
||||
"原生"
|
||||
],
|
||||
"runtime": "html",
|
||||
"engine": "none",
|
||||
"engineVersion": "",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T03:22:43Z",
|
||||
"entry": "game/index.html",
|
||||
"zipKey": "templates/v1/blank-web/template.zip",
|
||||
"zipSizeBytes": 1212,
|
||||
"zipSha256": "6fa4391f30342e8dcbdcf735f990d2534ea50405f119e4fa5879b83e8f00119e",
|
||||
"coverKey": "templates/v1/blank-web/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "326a2753386618971b1311043effa46c2e74bc1cfb116862c0ee4097dcd5086a",
|
||||
"metadataKey": "templates/v1/blank-web/template.json"
|
||||
},
|
||||
{
|
||||
"id": "phaser-2d-starter",
|
||||
"title": "Phaser 2D 起步工程",
|
||||
"summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。",
|
||||
"tags": [
|
||||
"起步工程",
|
||||
"2d",
|
||||
"phaser",
|
||||
"像素"
|
||||
],
|
||||
"runtime": "html",
|
||||
"engine": "phaser",
|
||||
"engineVersion": "4.2.1",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T03:22:43Z",
|
||||
"entry": "game/index.html",
|
||||
"zipKey": "templates/v1/phaser-2d-starter/template.zip",
|
||||
"zipSizeBytes": 8770,
|
||||
"zipSha256": "9026856c3c0b3a42401172e36ce8b450a65e9f11eb9096624d8990d51449d8ce",
|
||||
"coverKey": "templates/v1/phaser-2d-starter/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "fdb422027bf54bf755b2b91cd21fa10fdd7ce3f5c7e3ccd9ac3ffba602b12b96",
|
||||
"metadataKey": "templates/v1/phaser-2d-starter/template.json"
|
||||
},
|
||||
{
|
||||
"id": "threejs-3d-starter",
|
||||
"title": "Three.js 3D 起步工程",
|
||||
"summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。",
|
||||
"tags": [
|
||||
"起步工程",
|
||||
"3d",
|
||||
"three.js",
|
||||
"网页"
|
||||
],
|
||||
"runtime": "html",
|
||||
"engine": "three.js",
|
||||
"engineVersion": "0.180.0",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T03:22:43Z",
|
||||
"entry": "game/index.html",
|
||||
"zipKey": "templates/v1/threejs-3d-starter/template.zip",
|
||||
"zipSizeBytes": 1697,
|
||||
"zipSha256": "03096152b17cd6d55e7f6ccd518485136133cb54fd2a5a5d0ac8e0974149155c",
|
||||
"coverKey": "templates/v1/threejs-3d-starter/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "7ba013e8a8b515d7aff7146fe401afe5ba3416b9c69db181179705e1bce01beb",
|
||||
"metadataKey": "templates/v1/threejs-3d-starter/template.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -239,11 +239,10 @@ import { DeveloperProjectPanels } from './features/project-workspace/DeveloperPr
|
||||
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
|
||||
import {
|
||||
type DirectThreadConsumeResult,
|
||||
directThreadHistoryPage,
|
||||
directThreadHistoryItemsToMessages,
|
||||
type DirectThreadHistorySlice,
|
||||
type DirectThreadSubscriptionBootstrap,
|
||||
isDirectTurnInProgress,
|
||||
prependDirectHistoryMessages,
|
||||
} from './features/project-workspace/directThreadEvents';
|
||||
import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation';
|
||||
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
|
||||
@@ -626,7 +625,7 @@ function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') {
|
||||
* 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合),
|
||||
* 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息
|
||||
* 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。
|
||||
* 这里只把当前 messages 里"非历史来源、运行时拥有、且回读结果里没有"的消息保留在末尾。
|
||||
* 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。
|
||||
*/
|
||||
function mergeLoadedConversationWithPendingRuntimeMessages(
|
||||
loaded: ChatMessage[],
|
||||
@@ -644,7 +643,7 @@ function mergeLoadedConversationWithPendingRuntimeMessages(
|
||||
loaded.map((message) => `${message.role}\u0000${message.text}`),
|
||||
);
|
||||
const pending = current.filter((message) => {
|
||||
if (!message.runtimeOwned || message.fromHistory) {
|
||||
if (!message.runtimeOwned) {
|
||||
return false;
|
||||
}
|
||||
if (message.messageId) {
|
||||
@@ -1959,7 +1958,7 @@ export function App({
|
||||
);
|
||||
const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false);
|
||||
const directHistoryOldestItemIdRef = useRef<string | null>(null);
|
||||
const directHistoryLoadingRef = useRef<number | null>(null);
|
||||
const directHistoryLoadingRef = useRef(false);
|
||||
const [pendingCommand, setPendingCommand] = useState<PendingCommand | null>(
|
||||
null,
|
||||
);
|
||||
@@ -2152,7 +2151,6 @@ export function App({
|
||||
|
||||
function resetProjectSupervisorState() {
|
||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
||||
directHistoryLoadingRef.current = null;
|
||||
projectSupervisorRuntimeResumeProjectPathRef.current = null;
|
||||
projectSupervisorSessionIdRef.current = null;
|
||||
projectSupervisorRuntimeRef.current = null;
|
||||
@@ -4011,8 +4009,6 @@ export function App({
|
||||
}
|
||||
const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1;
|
||||
projectSupervisorHistoryLoadVersionRef.current = loadVersion;
|
||||
if (directCodexProductRuntime)
|
||||
directHistoryLoadingRef.current = loadVersion;
|
||||
try {
|
||||
// V2 projects do not have a Supervisor run or legacy conversation. Probe the
|
||||
// V2 authority first; a missing V2 session returns null and preserves the
|
||||
@@ -4109,7 +4105,6 @@ export function App({
|
||||
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
|
||||
let runtimeError = '';
|
||||
let loadedDirectHistoryHasMore = false;
|
||||
let loadedDirectHistoryCursor: string | null = null;
|
||||
const projectConversation = directCodexProductRuntime
|
||||
? (() => {
|
||||
return invoke<DirectThreadHistorySlice>(
|
||||
@@ -4117,16 +4112,16 @@ export function App({
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
limit: CONVERSATION_INITIAL_VISIBLE_COUNT,
|
||||
messagesOnly: true,
|
||||
},
|
||||
).then((slice) => {
|
||||
const page = directThreadHistoryPage(slice);
|
||||
loadedDirectHistoryHasMore = page.hasMore;
|
||||
loadedDirectHistoryCursor = page.cursor;
|
||||
loadedDirectHistoryHasMore = slice.hasMore;
|
||||
return {
|
||||
path: nextProjectPath,
|
||||
agentId: null,
|
||||
messages: page.messages,
|
||||
messages: directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
),
|
||||
} satisfies LocalConversationResult;
|
||||
});
|
||||
})()
|
||||
@@ -4202,7 +4197,7 @@ export function App({
|
||||
const conversationMessages = mergeProjectSupervisorConversation(
|
||||
resolvedProjectConversation.messages,
|
||||
supervisorConversation?.messages ?? [],
|
||||
).map((message) => ({ ...message, fromHistory: true }));
|
||||
);
|
||||
if (
|
||||
conversationContainsProjectSupervisorResponseStream(
|
||||
supervisorConversation?.messages ?? [],
|
||||
@@ -4223,7 +4218,9 @@ export function App({
|
||||
setProjectSupervisorRuntimeError(runtimeError || resumeError);
|
||||
if (directCodexProductRuntime) {
|
||||
setDirectHistoryHasMore(loadedDirectHistoryHasMore);
|
||||
directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor;
|
||||
directHistoryOldestItemIdRef.current =
|
||||
conversationMessages.find((message) => message.messageId)
|
||||
?.messageId ?? null;
|
||||
}
|
||||
setMessages((current) => {
|
||||
// replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。
|
||||
@@ -4284,10 +4281,6 @@ export function App({
|
||||
: workspaceStatus,
|
||||
);
|
||||
// Keep the default greeting when history is missing or blocked.
|
||||
} finally {
|
||||
if (directHistoryLoadingRef.current === loadVersion) {
|
||||
directHistoryLoadingRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4403,9 +4396,6 @@ export function App({
|
||||
setAgentRunHistoryFiles([]);
|
||||
setAgentRuntimeById({});
|
||||
setMessages(conversationMessages);
|
||||
// 只有新项目确实打开后才丢弃旧分页位置;打开失败时旧会话仍可继续翻页。
|
||||
directHistoryOldestItemIdRef.current = null;
|
||||
setDirectHistoryHasMore(false);
|
||||
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
|
||||
savedConversationProjectPathRef.current = openedProject.projectPath;
|
||||
savedConversationCountRef.current = conversationMessages.length;
|
||||
@@ -12490,63 +12480,48 @@ export function App({
|
||||
: null;
|
||||
|
||||
async function showEarlierConversationMessages() {
|
||||
if (hiddenConversationCount > 0) {
|
||||
setConversationVisibleCount((current) =>
|
||||
Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (directCodexProductRuntime && directHistoryHasMore) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
const projectPath = localProject?.projectPath;
|
||||
if (invoke && projectPath && directHistoryLoadingRef.current === null) {
|
||||
const loadVersion = projectSupervisorHistoryLoadVersionRef.current;
|
||||
const beforeItemId = directHistoryOldestItemIdRef.current;
|
||||
directHistoryLoadingRef.current = loadVersion;
|
||||
const isCurrentLoad = () =>
|
||||
manifestRefreshMountedRef.current &&
|
||||
localProjectPathRef.current === projectPath &&
|
||||
projectSupervisorHistoryLoadVersionRef.current === loadVersion;
|
||||
if (invoke && projectPath && !directHistoryLoadingRef.current) {
|
||||
directHistoryLoadingRef.current = true;
|
||||
try {
|
||||
const slice = await invoke<DirectThreadHistorySlice>(
|
||||
'read_direct_project_history_slice',
|
||||
{
|
||||
projectPath,
|
||||
beforeItemId,
|
||||
beforeItemId: directHistoryOldestItemIdRef.current,
|
||||
limit: CONVERSATION_VISIBLE_STEP,
|
||||
messagesOnly: true,
|
||||
},
|
||||
);
|
||||
if (!isCurrentLoad()) {
|
||||
if (localProjectPathRef.current !== projectPath) {
|
||||
return;
|
||||
}
|
||||
const page = directThreadHistoryPage(slice, beforeItemId);
|
||||
const older = page.messages.map((message) => ({
|
||||
const older = directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
).map((message) => ({
|
||||
role:
|
||||
message.role === 'user'
|
||||
? ('user' as const)
|
||||
: ('assistant' as const),
|
||||
text: message.content,
|
||||
runtimeOwned: true,
|
||||
fromHistory: true,
|
||||
messageId: message.messageId,
|
||||
updatedAt: message.updatedAt,
|
||||
}));
|
||||
setMessages((current) =>
|
||||
prependDirectHistoryMessages(current, older),
|
||||
);
|
||||
setMessages((current) => [...older, ...current]);
|
||||
setConversationVisibleCount((current) => current + older.length);
|
||||
setDirectHistoryHasMore(page.hasMore);
|
||||
directHistoryOldestItemIdRef.current = page.cursor;
|
||||
setDirectHistoryHasMore(slice.hasMore);
|
||||
directHistoryOldestItemIdRef.current =
|
||||
older.find((message) => message.messageId)?.messageId ??
|
||||
directHistoryOldestItemIdRef.current;
|
||||
} catch (error) {
|
||||
if (!isCurrentLoad()) return;
|
||||
setWorkspaceStatus(
|
||||
`读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
if (directHistoryLoadingRef.current === loadVersion) {
|
||||
directHistoryLoadingRef.current = null;
|
||||
}
|
||||
directHistoryLoadingRef.current = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -999,8 +999,6 @@ export interface ChatMessage {
|
||||
agentId?: string | null;
|
||||
updatedAt?: number;
|
||||
runtimeOwned?: boolean;
|
||||
/** 来自历史回读,不作为尚未落盘的实时消息追加到新历史页末尾。 */
|
||||
fromHistory?: boolean;
|
||||
}
|
||||
|
||||
export type DesignAgentInput =
|
||||
|
||||
@@ -38,16 +38,11 @@ export function useDirectActiveTurns({
|
||||
const [snapshotReadFailed, setSnapshotReadFailed] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
const inFlightRef = useRef<Promise<void> | null>(null);
|
||||
const retryTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (retryTimerRef.current !== null) {
|
||||
window.clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -78,12 +73,12 @@ export function useDirectActiveTurns({
|
||||
return;
|
||||
} catch {
|
||||
if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) {
|
||||
await new Promise<void>((resolve) => {
|
||||
retryTimerRef.current = window.setTimeout(() => {
|
||||
retryTimerRef.current = null;
|
||||
resolve();
|
||||
}, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt);
|
||||
});
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(
|
||||
resolve,
|
||||
DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { launcherNotifications } from '../../app/constants';
|
||||
@@ -32,8 +25,10 @@ import {
|
||||
type ProjectManifestSnapshotSource,
|
||||
rereadAuthoritativeProjectManifestSnapshot,
|
||||
} from '../../view/project-development/projectResourceLiveUpdateModel';
|
||||
import TemplateLibraryView from '../../view/template-library';
|
||||
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
|
||||
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
|
||||
import {
|
||||
DeveloperAgentDialogs,
|
||||
@@ -90,6 +85,11 @@ export function WorkspaceLauncherShell({
|
||||
setAgentChatProjectPath: developerAgent.setAgentChatProjectPath,
|
||||
rememberRecentWorkspace,
|
||||
});
|
||||
const templateLibrary = useTemplateLibrary({
|
||||
onProjectCreated: async (result) => {
|
||||
await homeProject.enterCreatedTemplateProject(result);
|
||||
},
|
||||
});
|
||||
const {
|
||||
projectPath,
|
||||
setProjectPath,
|
||||
@@ -208,18 +208,12 @@ export function WorkspaceLauncherShell({
|
||||
*/
|
||||
const manifestMergeNoticeScopeRef = useRef<string | null>(null);
|
||||
|
||||
// 打开项目由创建流程提供,其函数引用随渲染变化;标题栏只持有稳定的转发入口,
|
||||
// 否则发布 Context 会再次触发工作台 effect,形成发布/清理循环。
|
||||
const openProjectRef = useRef(openProject);
|
||||
useLayoutEffect(() => {
|
||||
openProjectRef.current = openProject;
|
||||
}, [openProject]);
|
||||
const openActiveProject = useCallback(
|
||||
(nextProjectPath: string) => {
|
||||
setProjectPath(nextProjectPath);
|
||||
void openProjectRef.current(nextProjectPath, 'open');
|
||||
void openProject(nextProjectPath, 'open');
|
||||
},
|
||||
[setProjectPath],
|
||||
[openProject, setProjectPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -229,6 +223,7 @@ export function WorkspaceLauncherShell({
|
||||
readFailed: snapshotReadFailed,
|
||||
onOpenProject: openActiveProject,
|
||||
});
|
||||
return () => setActiveProjectRuns(null);
|
||||
}, [
|
||||
activeTurns,
|
||||
currentProjectContext?.projectPath,
|
||||
@@ -236,7 +231,6 @@ export function WorkspaceLauncherShell({
|
||||
setActiveProjectRuns,
|
||||
snapshotReadFailed,
|
||||
]);
|
||||
useEffect(() => () => setActiveProjectRuns(null), [setActiveProjectRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
const projectPath = currentProjectContext?.projectPath ?? null;
|
||||
@@ -580,6 +574,13 @@ export function WorkspaceLauncherShell({
|
||||
void openProject(path, 'open');
|
||||
}}
|
||||
onProjectPick={() => void homeProject.pickAndOpenProject()}
|
||||
templateRecommendations={templateLibrary.templates}
|
||||
templateLibraryLoading={
|
||||
templateLibrary.status === 'loading' ||
|
||||
templateLibrary.status === 'idle'
|
||||
}
|
||||
templateLibraryError={templateLibrary.error}
|
||||
onTemplateLibraryOpen={() => setLauncherView('template-library')}
|
||||
/>
|
||||
) : launcherView === 'projects' ? (
|
||||
<ProjectsPage
|
||||
@@ -587,6 +588,11 @@ export function WorkspaceLauncherShell({
|
||||
homeProject={homeProject}
|
||||
recentProjects={recentProjects}
|
||||
/>
|
||||
) : launcherView === 'template-library' ? (
|
||||
<TemplateLibraryView
|
||||
controller={templateLibrary}
|
||||
onBack={() => setLauncherView('home')}
|
||||
/>
|
||||
) : launcherView === 'agent-chat' ? (
|
||||
<DeveloperAgentPanel
|
||||
controller={developerAgent}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user