Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e41bd3e56 | |||
| 085181c795 | |||
| 2d61a4c057 | |||
| e2bdb1105b | |||
| 71f5f36764 | |||
| f71361c292 | |||
| 6e9e7edcf8 | |||
| 480fe995ed | |||
| afd5b8d91f | |||
| c60d116af0 | |||
| 3eb99f5124 | |||
| 34b1ad5d5c |
@@ -13,7 +13,7 @@
|
||||
"taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求",
|
||||
"taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate",
|
||||
"agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。",
|
||||
"agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可",
|
||||
"agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,去除首尾空白后的描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令",
|
||||
"agc_generate_image.parameters.kind": "image=普通新图(保留生成原图),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图",
|
||||
"agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称",
|
||||
"agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件",
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
"icon_spec_generation": "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}",
|
||||
"ui_design_generation": "根据下方当前项目 UI 需求生成一张完整的游戏 UI/UX 原型图,玩法与界面结构以这些需求为准。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言。\n\n当前项目 UI 需求:{}",
|
||||
"scene_generation": "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}",
|
||||
"default_art_brief": "需要一张可直接用于 Web 小游戏首版原型的核心美术素材。",
|
||||
"spritesheet_generation": "为 Web 小游戏首版原型生成一张可切分的原创透明核心美术素材图集,适合放入本地 assets 并被游戏直接引用。严格从用户需求和美术 brief 提取当前项目自己的标题、玩法实体、目标物、收集物、障碍、状态与反馈,素材类别与数量以当前项目需求为准。所有元素沿用当前规范图的轮廓、配色、材质和光照,分区排布并留出清楚切分间距。角色轮廓、图标排布与配色采用项目原创设计。\n用户需求:{}\n美术资产 brief:{}",
|
||||
"ui_inspection_focus": "请只依据真实可见像素判断这是否是可供前端直接实现的完整游戏 UI 原型,不能依据文件名、生成提示词或图片内自述放行。纯场景图、概念图、地图、海报或只展示角色而没有可玩界面的插画必须判定失败;按当前项目的玩法识别界面结构与关键要素。逐项检查:informationHud=清楚显示当前玩法需要的分数、资源、生命、关卡或局内状态;gameplaySurface=主要可玩区域及空间规则清楚;objectiveEntities=玩家主体、目标/收集/危险物、谜题或文本选项、轨道等当前玩法等价关键要素可辨;primaryControls=当前玩法需要的开始、移动、暂停或操作控件清楚;failureRestartFlow=存在可识别的结束态表现意图或明确重开入口;responsiveLayout=能从可见布局、触控目标和可重排分组判断移动适配意图,实际双视口另由浏览器验证;implementationClarity=分区、层级和文字清楚到可指导 HTML/CSS;originalTheme=原创主题且未复刻现有游戏角色、Logo、贴图或受保护视觉语言。请只返回一个 JSON object,字段必须严格为:{\"checks\":{\"informationHud\":true,\"gameplaySurface\":true,\"objectiveEntities\":true,\"primaryControls\":true,\"failureRestartFlow\":true,\"responsiveLayout\":true,\"implementationClarity\":true,\"originalTheme\":true},\"issues\":[\"未通过项及原因;全部通过时必须为空数组\"],\"summary\":\"500 字以内中文结论\"}。只有八项 checks 全为 true 且 issues 为空才通过。",
|
||||
"default_inspection_focus": "请检查布局、遮挡、裁切、视觉层级、素材一致性,以及桌面与移动视口是否可用。",
|
||||
"custom_inspection_focus": "检查重点:{question}",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"preview.start.description": "启动当前项目的 loopback HTTP 预览。",
|
||||
"preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。",
|
||||
"image.inspect.description": "让视觉模型检查一至两张项目内图片。",
|
||||
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
|
||||
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
|
||||
"ui.workflow.run.description": "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。",
|
||||
"cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。",
|
||||
"unity.editor.execute.description": "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。",
|
||||
|
||||
@@ -13,7 +13,7 @@ mod codex_app_server;
|
||||
mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod design_runtime;
|
||||
mod design_tools;
|
||||
pub(crate) mod design_tools;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_codex_user_item;
|
||||
|
||||
@@ -7684,7 +7684,7 @@ done
|
||||
&temp.path().join("host"),
|
||||
&project,
|
||||
"turn-0001",
|
||||
"fixture-request",
|
||||
&format!("{:x}", Sha256::digest("请创建菜单".as_bytes())),
|
||||
false,
|
||||
&super::super::direct_validation::DirectValidationConfig::default(),
|
||||
)
|
||||
|
||||
@@ -4,10 +4,12 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::Manager;
|
||||
|
||||
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
|
||||
const DESIGN_REFERENCES_ROOT: &str = "references";
|
||||
const SEARCH_HIT_LIMIT: usize = 200;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -547,6 +549,123 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result<S
|
||||
fs::read_to_string(&target).map_err(|error| format!("读取失败:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_design_workspace_file(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, String> {
|
||||
let root = PathBuf::from(project_path.trim());
|
||||
let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?;
|
||||
let _ = app.emit(
|
||||
"design-agent-update",
|
||||
serde_json::json!({
|
||||
"projectPath": root.to_string_lossy(),
|
||||
"clientTurnId": "",
|
||||
"kind": "workspace",
|
||||
"messageId": null,
|
||||
"text": null,
|
||||
"view": null,
|
||||
}),
|
||||
);
|
||||
Ok(relative_path)
|
||||
}
|
||||
|
||||
fn import_design_workspace_file_at(
|
||||
root: &Path,
|
||||
file_name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
read_existing_manifest_for_project(root)?;
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"conversation.write",
|
||||
)?;
|
||||
|
||||
if file_name.contains(['/', '\\']) {
|
||||
return Err("附件文件名必须是单个文件名,不能包含目录".to_string());
|
||||
}
|
||||
let normalized_name =
|
||||
normalize_relative_path(file_name).map_err(|error| format!("附件文件名无效:{error}"))?;
|
||||
if normalized_name != file_name {
|
||||
return Err("附件文件名无效".to_string());
|
||||
}
|
||||
|
||||
let (_, references) = resolve_design_workspace_path(root, DESIGN_REFERENCES_ROOT)?;
|
||||
crate::ensure_game_creator_private_directory_tree(&references, "策划参考附件目录")?;
|
||||
crate::prepare_game_creator_private_path_for_read(&references, true, "策划参考附件目录")?;
|
||||
|
||||
let mut sequence = 1_u64;
|
||||
loop {
|
||||
let candidate_name = design_reference_file_name(&normalized_name, sequence);
|
||||
let relative_path = format!("{DESIGN_REFERENCES_ROOT}/{candidate_name}");
|
||||
let (_, target) = resolve_design_workspace_path(root, &relative_path)?;
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||||
}
|
||||
let mut file = match options.open(&target) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
sequence = sequence
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "无法为同名附件分配新序号".to_string())?;
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"创建策划参考附件失败:{}: {error}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(error) =
|
||||
crate::harden_new_game_creator_private_path(&target, false, "策划参考附件")
|
||||
{
|
||||
drop(file);
|
||||
let _ = fs::remove_file(&target);
|
||||
return Err(error);
|
||||
}
|
||||
let write_result = file.write_all(bytes).and_then(|_| file.sync_all());
|
||||
drop(file);
|
||||
if let Err(error) = write_result {
|
||||
let _ = fs::remove_file(&target);
|
||||
return Err(format!(
|
||||
"写入策划参考附件失败:{}: {error}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
return Ok(relative_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn design_reference_file_name(file_name: &str, sequence: u64) -> String {
|
||||
if sequence == 1 {
|
||||
return file_name.to_string();
|
||||
}
|
||||
let path = Path::new(file_name);
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or(file_name);
|
||||
match path.extension().and_then(|value| value.to_str()) {
|
||||
Some(extension) if !extension.is_empty() => {
|
||||
format!("{stem} ({sequence}).{extension}")
|
||||
}
|
||||
_ => format!("{stem} ({sequence})"),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_design_catalog(root: &Path) -> Result<Vec<DesignCatalogItem>, String> {
|
||||
let catalog_path = root.join("resources/catalog.json");
|
||||
let data: DesignCatalogFile = serde_json::from_str(
|
||||
@@ -756,6 +875,13 @@ mod tests {
|
||||
tempfile::tempdir().expect("tempdir")
|
||||
}
|
||||
|
||||
fn initialized_test_root() -> tempfile::TempDir {
|
||||
let temp = test_root();
|
||||
init_local_game_project_at(temp.path(), "design-import-test", "策划附件导入测试")
|
||||
.expect("init project");
|
||||
temp
|
||||
}
|
||||
|
||||
fn pack_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")
|
||||
}
|
||||
@@ -868,6 +994,138 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_reference_is_visible_to_workspace_list_and_read() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let manifest_before = read_existing_manifest_for_project(root).expect("read manifest");
|
||||
let revision_before =
|
||||
read_game_creator_agent_runtime_project_revision(root).expect("read revision");
|
||||
|
||||
let relative =
|
||||
import_design_workspace_file_at(root, "玩法构想.txt", "横版解谜\n".as_bytes())
|
||||
.expect("import text reference");
|
||||
|
||||
assert_eq!(relative, "references/玩法构想.txt");
|
||||
assert!(list_design_workspace_files(root)
|
||||
.expect("list workspace")
|
||||
.iter()
|
||||
.any(|entry| entry.path == relative && entry.kind == "file"));
|
||||
assert_eq!(
|
||||
read_design_workspace_file_at(root, &relative).expect("read imported reference"),
|
||||
"横版解谜\n"
|
||||
);
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(root).expect("read manifest after import"),
|
||||
manifest_before
|
||||
);
|
||||
assert_eq!(
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read revision after import"),
|
||||
revision_before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_keeps_existing_names_and_accepts_empty_and_binary_bytes() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
|
||||
let first = import_design_workspace_file_at(root, "brief.md", b"first")
|
||||
.expect("import first reference");
|
||||
let second = import_design_workspace_file_at(root, "brief.md", b"second")
|
||||
.expect("import repeated reference");
|
||||
let empty = import_design_workspace_file_at(root, "empty.bin", b"")
|
||||
.expect("import empty reference");
|
||||
let binary_bytes = [0_u8, 0xff, 0x10, 0x80];
|
||||
let binary = import_design_workspace_file_at(root, "bytes.bin", &binary_bytes)
|
||||
.expect("import binary reference");
|
||||
|
||||
assert_eq!(first, "references/brief.md");
|
||||
assert_eq!(second, "references/brief (2).md");
|
||||
assert_eq!(empty, "references/empty.bin");
|
||||
assert_eq!(binary, "references/bytes.bin");
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&first)).expect("read first"),
|
||||
b"first"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&second)).expect("read second"),
|
||||
b"second"
|
||||
);
|
||||
assert!(fs::read(root.join("design_artifacts").join(&empty))
|
||||
.expect("read empty")
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&binary)).expect("read binary"),
|
||||
binary_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_unsafe_names_and_denied_project_permission() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
for file_name in [
|
||||
"../outside.txt",
|
||||
"nested/file.txt",
|
||||
r"nested\file.txt",
|
||||
"C:stream",
|
||||
] {
|
||||
let error = import_design_workspace_file_at(root, file_name, b"blocked")
|
||||
.expect_err("reject unsafe file name");
|
||||
assert!(error.contains("文件名"), "unexpected error: {error}");
|
||||
}
|
||||
assert!(!root.join("outside.txt").exists());
|
||||
|
||||
let mut policy = ProjectPermissionPolicy::default();
|
||||
policy
|
||||
.denied_commands
|
||||
.push("conversation.write".to_string());
|
||||
write_project_permission_policy_at(root, policy).expect("deny conversation write");
|
||||
let error = import_design_workspace_file_at(root, "denied.txt", b"blocked")
|
||||
.expect_err("respect project permission policy");
|
||||
assert!(error.contains("conversation.write"));
|
||||
assert!(!root.join("design_artifacts/references/denied.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn import_rejects_linked_references_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
|
||||
symlink(outside.path(), root.join("design_artifacts/references"))
|
||||
.expect("link references directory");
|
||||
|
||||
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
|
||||
.expect_err("reject linked references directory");
|
||||
assert!(error.contains("符号链接") || error.contains("reparse point"));
|
||||
assert!(!outside.path().join("escape.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn import_rejects_windows_linked_references_directory_when_supported() {
|
||||
use std::os::windows::fs::symlink_dir;
|
||||
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
|
||||
if symlink_dir(outside.path(), root.join("design_artifacts/references")).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
|
||||
.expect_err("reject linked references directory");
|
||||
assert!(error.contains("符号链接") || error.contains("reparse point"));
|
||||
assert!(!outside.path().join("escape.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_context_injects_current_skill_only() {
|
||||
let resources = DesignResources::new(pack_root()).expect("pack");
|
||||
|
||||
@@ -3407,7 +3407,7 @@ async fn start_tool_bridge_for_source(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(in crate::agent) struct DirectExecutionTestFixture {
|
||||
pub(crate) struct DirectExecutionTestFixture {
|
||||
execution: Option<super::direct_execution::ExecutionSessionGuard>,
|
||||
_host: tempfile::TempDir,
|
||||
}
|
||||
@@ -3426,7 +3426,7 @@ impl Drop for DirectExecutionTestFixture {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(in crate::agent) async fn direct_execution_fixture(
|
||||
pub(crate) async fn direct_execution_fixture(
|
||||
root: &Path,
|
||||
turn: &str,
|
||||
) -> DirectExecutionTestFixture {
|
||||
|
||||
@@ -72,7 +72,8 @@ pub(crate) use canvas_generation::{
|
||||
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
|
||||
platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path,
|
||||
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
|
||||
PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
validate_platform_art_icon_prompt, PlatformArtAssetGenerationOptions,
|
||||
PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use draft_validation::{
|
||||
|
||||
@@ -687,7 +687,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options(
|
||||
let snapshot = platform_art_generation_runtime_request_snapshot(&state)?;
|
||||
// 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。
|
||||
if snapshot.generation_kind == generation_kind
|
||||
&& snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &[], &options)
|
||||
&& snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &options)
|
||||
&& options.asset_kind == kind
|
||||
&& options.aspect_ratio == ratio
|
||||
&& options.image_size == size
|
||||
@@ -2261,39 +2261,16 @@ async fn resolve_platform_art_generation_references_at(
|
||||
Ok(PlatformArtGenerationReferences { canonical, ordered })
|
||||
}
|
||||
|
||||
fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec<String> {
|
||||
// External Editor validates each description independently (currently at
|
||||
// 200 Unicode characters). Keep the gameplay context short enough that a
|
||||
// long creation request cannot reject the atlas before it is queued.
|
||||
const MAX_DESCRIPTION_CHARS: usize = 200;
|
||||
const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:";
|
||||
let category =
|
||||
"按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定";
|
||||
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
|
||||
category
|
||||
.chars()
|
||||
.count()
|
||||
.saturating_add(CONTEXT_PREFIX.chars().count()),
|
||||
);
|
||||
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
|
||||
vec![format!("{category}{CONTEXT_PREFIX}{project_context}")]
|
||||
}
|
||||
|
||||
fn truncate_inline_bounded(value: &str, max_chars: usize) -> String {
|
||||
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let actual_chars = normalized.chars().count();
|
||||
if actual_chars <= max_chars {
|
||||
return normalized;
|
||||
pub(crate) fn validate_platform_art_icon_prompt(prompt: &str) -> Result<(), String> {
|
||||
// 与 External Editor 的单条 iconDescriptions 合同一致;超限拒绝,不截断或拆条。
|
||||
let prompt = prompt.trim();
|
||||
if prompt.is_empty() {
|
||||
return Err("图标素材描述不能为空".to_string());
|
||||
}
|
||||
if max_chars <= 3 {
|
||||
return ".".repeat(max_chars);
|
||||
if prompt.chars().count() > 200 {
|
||||
return Err("图标素材描述不能超过 200 个字符".to_string());
|
||||
}
|
||||
let mut output = normalized
|
||||
.chars()
|
||||
.take(max_chars.saturating_sub(3))
|
||||
.collect::<String>();
|
||||
output.push_str("...");
|
||||
output
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_platform_art_image_with_limits(
|
||||
@@ -2565,10 +2542,10 @@ pub(in crate::agent) struct AdmittedPlatformArtGeneration {
|
||||
pub(in crate::agent) fn admit_platform_art_generation_at(
|
||||
root: &Path,
|
||||
prompt: &str,
|
||||
briefs: &[AgentGroupBrief],
|
||||
_briefs: &[AgentGroupBrief],
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
) -> Result<AdmittedPlatformArtGeneration, String> {
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||
let context =
|
||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||
let guard = super::external_generation_state::acquire_durable_platform_art_generation_guard(
|
||||
@@ -2590,7 +2567,7 @@ pub(in crate::agent) async fn generate_admitted_platform_art_asset_at(
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
admission: AdmittedPlatformArtGeneration,
|
||||
) -> Result<GeneratedPlatformArtAsset, String> {
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||
let context =
|
||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||
if admission.context != context
|
||||
@@ -2618,7 +2595,7 @@ pub(crate) async fn generate_platform_art_asset_with_options_at(
|
||||
briefs: &[AgentGroupBrief],
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
) -> Result<GeneratedPlatformArtAsset, String> {
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||
let runtime_context =
|
||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||
generate_platform_art_asset_with_runtime_options_at(
|
||||
@@ -2645,7 +2622,7 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
|
||||
if options.asset_kind != GameCreationAppAssetKind::IconSpritesheet {
|
||||
return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string());
|
||||
}
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||
let runtime_context =
|
||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||
generate_platform_art_asset_with_runtime_options_at(
|
||||
@@ -3106,6 +3083,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
runtime_context: Option<&PlatformArtGenerationRuntimeContext>,
|
||||
) -> Result<PreparedPlatformArtAssetGeneration, String> {
|
||||
if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet {
|
||||
validate_platform_art_icon_prompt(prompt)?;
|
||||
}
|
||||
enforce_project_permission_policy(root, "canvas.asset_generate")?;
|
||||
let persisted_runtime_state = runtime_context
|
||||
.map(|context| {
|
||||
@@ -3150,7 +3130,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
.timeout(EXTERNAL_GENERATION_SUBMIT_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|error| format!("创建 External Editor 生成提交客户端失败:{error}"))?;
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||
let (
|
||||
generated,
|
||||
canvas_context,
|
||||
@@ -3348,7 +3328,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
"/api/external/v1/editor/icon-spritesheets/generations",
|
||||
serde_json::json!({
|
||||
"referenceId": reference_id,
|
||||
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
|
||||
"iconDescriptions": [generation_prompt],
|
||||
"sliceCount": options.slice_count,
|
||||
"sliceMode": options.slice_mode,
|
||||
"gridX": options.grid_x,
|
||||
@@ -8413,7 +8393,6 @@ pub(crate) fn platform_art_asset_art_spec(
|
||||
|
||||
pub(crate) fn build_platform_art_asset_prompt(
|
||||
prompt: &str,
|
||||
briefs: &[AgentGroupBrief],
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
) -> String {
|
||||
if options.asset_kind == GameCreationAppAssetKind::Image {
|
||||
@@ -8452,20 +8431,8 @@ pub(crate) fn build_platform_art_asset_prompt(
|
||||
truncate_prompt_context(prompt.trim())
|
||||
);
|
||||
}
|
||||
let art_asset_brief = briefs
|
||||
.iter()
|
||||
.flat_map(|brief| brief.role_briefs.iter())
|
||||
.find(|role_brief| {
|
||||
role_brief.group_definition.id == "art" && role_brief.role_definition.id == "asset"
|
||||
})
|
||||
.map(|role_brief| role_brief.markdown.trim())
|
||||
.filter(|markdown| !markdown.is_empty())
|
||||
.unwrap_or(prompt_text!("media.default_art_brief"));
|
||||
format!(
|
||||
prompt_text!("media.spritesheet_generation"),
|
||||
truncate_inline(prompt, 240),
|
||||
truncate_prompt_context(art_asset_brief)
|
||||
)
|
||||
// 图集的工程化生图指令由 API Server 统一添加,客户端仅保留用户原文。
|
||||
prompt.trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -8533,15 +8500,15 @@ mod canvas_generation_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() {
|
||||
// 放行 icon-spritesheet 必须真的走到图集提示词与图集请求合同,
|
||||
// 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。
|
||||
fn generation_kind_catalog_keeps_art_spritesheet_description_and_art_spec() {
|
||||
// 图集描述由用户提供,工程化生图提示词由 API Server 添加。
|
||||
let options = PlatformArtAssetGenerationOptions {
|
||||
asset_kind: GameCreationAppAssetKind::IconSpritesheet,
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
assert!(
|
||||
build_platform_art_asset_prompt("原创收集玩法", &[], &options).contains("素材图集")
|
||||
assert_eq!(
|
||||
build_platform_art_asset_prompt("原创收集玩法", &options),
|
||||
"原创收集玩法"
|
||||
);
|
||||
assert_eq!(
|
||||
platform_art_asset_art_spec(&options)["assetType"],
|
||||
@@ -8945,7 +8912,7 @@ mod canvas_generation_tests {
|
||||
asset_label: "失败后可重试素材".to_string(),
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &[], &options);
|
||||
let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &options);
|
||||
let runtime_context =
|
||||
standalone_platform_art_generation_runtime_context(&generated_prompt, &options, false)
|
||||
.expect("standalone retry context");
|
||||
@@ -9121,8 +9088,8 @@ mod canvas_generation_tests {
|
||||
asset_label: "图标规范".to_string(),
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &[], &options);
|
||||
let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &[], &options);
|
||||
let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &options);
|
||||
let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &options);
|
||||
let first =
|
||||
standalone_platform_art_generation_runtime_context(&first_prompt, &options, false)
|
||||
.expect("first standalone slot context");
|
||||
@@ -9556,7 +9523,7 @@ mod canvas_generation_tests {
|
||||
asset_label: "图标规范".to_string(),
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &[], &options);
|
||||
let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &options);
|
||||
let context = standalone_platform_art_generation_runtime_context(&prompt, &options, false)
|
||||
.expect("current standalone context");
|
||||
let legacy_run_id = legacy_standalone_platform_art_generation_run_id(&options, false)
|
||||
@@ -9676,8 +9643,7 @@ mod canvas_generation_tests {
|
||||
asset_label: "另一个图标规范".to_string(),
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let other_prompt =
|
||||
build_platform_art_asset_prompt("另一个旧槽手工请求", &[], &other_options);
|
||||
let other_prompt = build_platform_art_asset_prompt("另一个旧槽手工请求", &other_options);
|
||||
let other_context = standalone_platform_art_generation_runtime_context(
|
||||
&other_prompt,
|
||||
&other_options,
|
||||
@@ -10877,7 +10843,7 @@ mod canvas_generation_tests {
|
||||
let current_prompt = "恢复时使用同一个生成意图";
|
||||
let current_options = PlatformArtAssetGenerationOptions::default();
|
||||
let durable_generation_prompt =
|
||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
||||
build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": "持久化且必须原样重发的生成正文",
|
||||
"kind": "spec",
|
||||
@@ -11143,7 +11109,7 @@ mod canvas_generation_tests {
|
||||
screen_color: None,
|
||||
};
|
||||
let prompt = "生成同一套整包美术";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": generation_prompt,
|
||||
"kind": "spec",
|
||||
@@ -11530,8 +11496,7 @@ mod canvas_generation_tests {
|
||||
asset_label: "账号隔离测试".to_string(),
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let generation_prompt =
|
||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": generation_prompt,
|
||||
"kind": "spec",
|
||||
@@ -11739,7 +11704,7 @@ mod canvas_generation_tests {
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let durable_generation_prompt =
|
||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
||||
build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": "持久化的原始生成正文",
|
||||
"kind": "spec",
|
||||
@@ -11883,7 +11848,7 @@ mod canvas_generation_tests {
|
||||
};
|
||||
let options = PlatformArtAssetGenerationOptions::default();
|
||||
let old_generation_prompt =
|
||||
build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &[], &options);
|
||||
build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &options);
|
||||
let binding_access =
|
||||
ExternalEditorBindingAccess::new(&base_url, "changed-intent-key", None)
|
||||
.expect("prepare changed-intent binding access");
|
||||
@@ -12050,7 +12015,7 @@ mod canvas_generation_tests {
|
||||
screen_color: None,
|
||||
};
|
||||
let prompt = "保持同一个生成提示词";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
|
||||
let binding_access =
|
||||
ExternalEditorBindingAccess::new(&base_url, "changed-reference-key", None)
|
||||
.expect("prepare changed-reference binding access");
|
||||
@@ -12313,8 +12278,7 @@ mod canvas_generation_tests {
|
||||
.expect("create accepted failure ledger access");
|
||||
let current_prompt = "失败恢复提示词";
|
||||
let current_options = PlatformArtAssetGenerationOptions::default();
|
||||
let generation_prompt =
|
||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||
let (state, _) = prepare_platform_art_generation_runtime_state(
|
||||
root,
|
||||
&runtime_context,
|
||||
@@ -12517,7 +12481,7 @@ mod canvas_generation_tests {
|
||||
screen_color: None,
|
||||
};
|
||||
let prompt = "恢复已受理视觉规范图";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
|
||||
let (_, _, frozen_platform_session) =
|
||||
resolve_canvas_sync_api_credentials(None, None).expect("freeze recovery account");
|
||||
let frozen_platform_session =
|
||||
@@ -12831,21 +12795,32 @@ mod canvas_generation_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_art_spritesheet_request_preserves_project_requirements() {
|
||||
let descriptions = canonical_art_spritesheet_icon_descriptions("原创收集玩法");
|
||||
assert_eq!(descriptions.len(), 1);
|
||||
assert!(descriptions[0].contains("原创收集玩法"));
|
||||
assert!(descriptions[0].contains("数量、类别、排列和切片方式由本次需求决定"));
|
||||
fn canonical_art_spritesheet_request_preserves_description_and_internal_newlines() {
|
||||
let options = PlatformArtAssetGenerationOptions {
|
||||
asset_kind: GameCreationAppAssetKind::IconSpritesheet,
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let prompt = "\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}";
|
||||
validate_platform_art_icon_prompt(prompt).expect("valid multiline description");
|
||||
assert_eq!(
|
||||
build_platform_art_asset_prompt(prompt, &options),
|
||||
"金币\n木制宝箱\n银色钥匙"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() {
|
||||
let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128));
|
||||
|
||||
assert!(!descriptions.is_empty());
|
||||
assert!(descriptions
|
||||
.iter()
|
||||
.all(|description| description.chars().count() <= 200));
|
||||
fn canonical_art_spritesheet_description_obeys_external_editor_unicode_item_limit() {
|
||||
let boundary = "🪙".repeat(200);
|
||||
validate_platform_art_icon_prompt(&format!("\u{0085} {boundary}\n"))
|
||||
.expect("200 Unicode characters after Rust trim must pass");
|
||||
let error = validate_platform_art_icon_prompt(&"🪙".repeat(201))
|
||||
.expect_err("201 Unicode characters must be rejected instead of truncated");
|
||||
assert!(error.contains("200"), "{error}");
|
||||
for blank in ["", " \r\n\t", "\u{0085}\u{2003}"] {
|
||||
assert!(validate_platform_art_icon_prompt(blank).is_err());
|
||||
}
|
||||
validate_platform_art_icon_prompt("\u{feff}")
|
||||
.expect("BOM is not whitespace under the API Server Rust trim contract");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -241,11 +241,72 @@ impl ProjectCommandTree {
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = child;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let Self::Group { pid, .. } = self;
|
||||
// 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。
|
||||
// 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。
|
||||
if !linux_project_command_group_has_live_members(*pid)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.request_owned_group_termination().map(|_| ())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_project_command_group_has_live_members(group: u32) -> Result<bool, String> {
|
||||
let inspect = || -> std::io::Result<bool> {
|
||||
let process_group = i32::try_from(group)
|
||||
.ok()
|
||||
.filter(|group| *group > 0)
|
||||
.ok_or_else(|| std::io::Error::other("受控命令进程组身份无效"))?;
|
||||
if unsafe { libc::kill(-process_group, 0) } != 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
return if error.raw_os_error() == Some(libc::ESRCH) {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(error)
|
||||
};
|
||||
}
|
||||
for entry in fs::read_dir("/proc")? {
|
||||
let entry = entry?;
|
||||
if entry.file_name().to_string_lossy().parse::<u32>().is_err() {
|
||||
continue;
|
||||
}
|
||||
let stat = match fs::read(entry.path().join("stat")) {
|
||||
Ok(stat) => stat,
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::NotFound
|
||||
|| error.raw_os_error() == Some(libc::ESRCH) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let invalid_stat = || std::io::Error::other("无法解析 /proc 进程组状态");
|
||||
// comm 可以包含括号和非 UTF-8 字节;只解析最后一个分隔符后的 ASCII 字段。
|
||||
let end = stat
|
||||
.windows(2)
|
||||
.rposition(|pair| pair == b") ")
|
||||
.ok_or_else(invalid_stat)?;
|
||||
let tail = std::str::from_utf8(&stat[end + 2..]).map_err(|_| invalid_stat())?;
|
||||
let mut fields = tail.split_whitespace();
|
||||
let state = fields.next().ok_or_else(invalid_stat)?;
|
||||
let process_group = fields
|
||||
.nth(1)
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.ok_or_else(invalid_stat)?;
|
||||
if process_group == group && state != "Z" && state != "X" {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
};
|
||||
inspect().map_err(|error| format!("读取受控进程组存活状态失败:{error}"))
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn owned_project_command_group_identity_matches(
|
||||
expected: Option<&str>,
|
||||
@@ -2504,6 +2565,67 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
const TEST: &str =
|
||||
"command_exec::tests::exited_group_accepts_orphan_zombies_but_rejects_live_members";
|
||||
const FIXTURE: &str = "AGC_COMMAND_ORPHAN_FIXTURE";
|
||||
if std::env::var_os(FIXTURE).is_none() {
|
||||
// subreaper 只影响隔离夹具,避免接管并行测试的子进程。
|
||||
let output = tokio::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", TEST, "--nocapture"])
|
||||
.env(FIXTURE, "1")
|
||||
.output()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
return;
|
||||
}
|
||||
assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0);
|
||||
let mut command = tokio::process::Command::new("/bin/sh");
|
||||
command
|
||||
.args(["-c", "sleep 60 & echo $!; read release"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped());
|
||||
command.as_std_mut().process_group(0);
|
||||
let mut child = command.spawn().unwrap();
|
||||
let tree = ProjectCommandTree::attach(&child).unwrap();
|
||||
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
|
||||
let mut line = String::new();
|
||||
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
|
||||
.await
|
||||
.unwrap();
|
||||
let descendant: i32 = line.trim().parse().unwrap();
|
||||
drop(child.stdin.take());
|
||||
child.wait().await.unwrap();
|
||||
let live_result = tree.after_main_exit(&mut child).await;
|
||||
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
|
||||
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::waitid(
|
||||
libc::P_PID,
|
||||
descendant as u32,
|
||||
&mut info,
|
||||
libc::WEXITED | libc::WNOWAIT,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
let zombie_result = tree.after_main_exit(&mut child).await;
|
||||
assert_eq!(
|
||||
unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) },
|
||||
descendant
|
||||
);
|
||||
let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理");
|
||||
assert!(error.contains("leader 身份未确认"), "{error}");
|
||||
zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对");
|
||||
tree.after_main_exit(&mut child).await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_process_group_refuses_missing_or_reused_leader_identity() {
|
||||
assert!(owned_project_command_group_identity_matches(
|
||||
|
||||
@@ -652,7 +652,7 @@ pub(crate) fn import_local_godot_project(
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "project.create")?;
|
||||
if discover_local_godot_project_root(root)?.is_none() {
|
||||
return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string());
|
||||
return Err("所选工作区未在根目录或一层子目录发现 project.godot".to_string());
|
||||
}
|
||||
let _lock = acquire_project_write_lock(root, "project.create")?;
|
||||
import_local_godot_project_at(root, project_id.trim(), name.trim())
|
||||
@@ -4963,6 +4963,9 @@ pub(crate) fn prepare_local_project_asset_generation(
|
||||
}
|
||||
let asset_kind = normalize_platform_art_asset_generation_kind(kind)
|
||||
.ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?;
|
||||
if asset_kind == GameCreationAppAssetKind::IconSpritesheet {
|
||||
crate::agent::validate_platform_art_icon_prompt(prompt)?;
|
||||
}
|
||||
// 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝,
|
||||
// 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。
|
||||
let reference_asset_ids =
|
||||
@@ -4972,7 +4975,12 @@ pub(crate) fn prepare_local_project_asset_generation(
|
||||
let target_category = normalize_platform_art_target_category(target_category)?;
|
||||
Ok(LocalProjectAssetGenerationRequest {
|
||||
root: PathBuf::from(project_path),
|
||||
prompt: local_project_asset_prompt(prompt)?,
|
||||
prompt: if asset_kind == GameCreationAppAssetKind::IconSpritesheet {
|
||||
// 图集只采用 API Server 的 trim + 单条长度合同,不套普通图片的控制字符限制。
|
||||
prompt.trim().to_string()
|
||||
} else {
|
||||
local_project_asset_prompt(prompt)?
|
||||
},
|
||||
options: PlatformArtAssetGenerationOptions {
|
||||
output_path: local_project_asset_single_line(
|
||||
output_path,
|
||||
@@ -5076,6 +5084,28 @@ mod local_project_asset_generation_tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_prompt_matches_api_server_single_description_contract() {
|
||||
for prompt in [
|
||||
" 金币\n\n宝箱\t钥匙 ".to_string(),
|
||||
"😀".repeat(200),
|
||||
"\u{85}金币\u{85}".to_string(),
|
||||
"\u{feff}".to_string(),
|
||||
"金币\u{0}宝箱".to_string(),
|
||||
] {
|
||||
assert_eq!(
|
||||
prepare("icon-spritesheet", &prompt)
|
||||
.expect("valid server description")
|
||||
.prompt,
|
||||
prompt.trim()
|
||||
);
|
||||
}
|
||||
for prompt in ["\u{85}\n ".to_string(), "😀".repeat(201)] {
|
||||
assert!(prepare("icon-spritesheet", &prompt).is_err());
|
||||
}
|
||||
assert!(prepare("image", &"图".repeat(201)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_kinds_all_reach_the_shared_generation_channel() {
|
||||
for kind in [
|
||||
|
||||
@@ -942,12 +942,14 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
|
||||
// elevated helper runs in a fresh process, so the in-memory runtime
|
||||
// config-dir override is unavailable there; recognize the packaged
|
||||
// path from the user's profile as well.
|
||||
#[cfg(windows)]
|
||||
if path_is_inside_game_creator_packaged_app_data(&home.join("AppData").join("Local"), &path)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
for environment_name in ["LOCALAPPDATA", "APPDATA"] {
|
||||
if let Some(root) = std::env::var_os(environment_name)
|
||||
.map(PathBuf::from)
|
||||
|
||||
@@ -157,6 +157,7 @@ mod tool_plan_handoff;
|
||||
mod user_input;
|
||||
mod windows;
|
||||
|
||||
use agent::design_tools::*;
|
||||
use agent::*;
|
||||
use agent_native_tools::*;
|
||||
use asset_generation_tasks::*;
|
||||
@@ -2613,6 +2614,7 @@ fn main() {
|
||||
debug_fast_forward_design_session,
|
||||
continue_design_agent_session,
|
||||
decide_design_phase,
|
||||
import_design_workspace_file,
|
||||
list_design_workspace,
|
||||
read_design_workspace_file,
|
||||
start_game_creator_agent_runtime_task,
|
||||
|
||||
@@ -84,9 +84,15 @@ fn godot_metadata_is_link(metadata: &fs::Metadata) -> bool {
|
||||
metadata.file_type().is_symlink() || godot_metadata_is_reparse_point(metadata)
|
||||
}
|
||||
|
||||
/// Godot 工程标记:`project.godot` 解析为普通文件即命中。
|
||||
///
|
||||
/// 2026-09-21 解除“必须是普通文件”的限制:符号链接、Windows reparse point 与
|
||||
/// 硬链接一律跟随,不再因为 `project.godot` 本身是链接而拒绝整个工作区。判据只
|
||||
/// 保留“解析后仍是文件”,目录或悬空链接仍然不算命中。
|
||||
fn inspect_godot_project_marker(root: &Path) -> Result<bool, String> {
|
||||
let project_file = root.join("project.godot");
|
||||
let metadata = match fs::symlink_metadata(&project_file) {
|
||||
// `fs::metadata` 跟随符号链接 / reparse point,因此链接指向的真实对象才是判据。
|
||||
let metadata = match fs::metadata(&project_file) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => {
|
||||
@@ -96,64 +102,12 @@ fn inspect_godot_project_marker(root: &Path) -> Result<bool, String> {
|
||||
));
|
||||
}
|
||||
};
|
||||
if godot_metadata_is_link(&metadata) {
|
||||
return Err(format!(
|
||||
"Godot 项目文件不能是符号链接或 reparse point:{}",
|
||||
project_file.display()
|
||||
));
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
return Err(format!(
|
||||
"Godot 项目文件必须是普通文件:{}",
|
||||
"Godot 项目文件必须是文件:{}",
|
||||
project_file.display()
|
||||
));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if metadata.nlink() != 1 {
|
||||
return Err(format!(
|
||||
"Godot 项目文件不能是硬链接文件:{}",
|
||||
project_file.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
|
||||
};
|
||||
|
||||
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||||
let file = fs::File::open(&project_file).map_err(|error| {
|
||||
format!(
|
||||
"打开 Godot 项目文件失败:{}: {error}",
|
||||
project_file.display()
|
||||
)
|
||||
})?;
|
||||
// SAFETY: the structure is plain data initialized by GetFileInformationByHandle.
|
||||
let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
|
||||
// SAFETY: file owns a live handle and information is a valid output pointer.
|
||||
// 取不到句柄信息、目录与 reparse point 一律按拒绝处理,保持 fail-closed。
|
||||
if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0
|
||||
|| information.dwFileAttributes
|
||||
& (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)
|
||||
!= 0
|
||||
{
|
||||
return Err(format!(
|
||||
"读取 Godot 项目文件 Windows 身份失败:{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
if information.nNumberOfLinks != 1 {
|
||||
return Err(format!(
|
||||
"Godot 项目文件不能是硬链接文件:{}",
|
||||
project_file.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -166,6 +120,62 @@ fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result<String, S
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// 把模板自带的 Godot 工程显示名改成用户选择的项目名。
|
||||
///
|
||||
/// 只改 `[application]` 段里的 `config/name` 一行:Godot 用它当工程显示名与窗口标题,
|
||||
/// 工程身份仍是 `project.godot` 所在目录,改显示名不影响工程发现或相对根。找不到该行
|
||||
/// 时保持模板原样,不猜测插入位置;除这一行以外的字节逐字保留。
|
||||
pub(crate) fn apply_godot_project_display_name(root: &Path, name: &str) -> Result<(), String> {
|
||||
let path = root.join("project.godot");
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取 Godot 工程配置失败:{}: {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
|
||||
let ends_with_newline = text.ends_with('\n');
|
||||
let mut replaced = false;
|
||||
let mut lines = Vec::new();
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
if !replaced {
|
||||
if let Some(rest) = trimmed.strip_prefix("config/name") {
|
||||
let rest = rest.trim_start();
|
||||
if let Some(value) = rest.strip_prefix('=') {
|
||||
let value = value.trim();
|
||||
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
|
||||
let indent = &line[..line.len() - trimmed.len()];
|
||||
lines.push(format!(
|
||||
"{indent}config/name=\"{}\"",
|
||||
escape_godot_project_string(name)
|
||||
));
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(line.to_string());
|
||||
}
|
||||
if !replaced {
|
||||
return Ok(());
|
||||
}
|
||||
let mut updated = lines.join(newline);
|
||||
if ends_with_newline {
|
||||
updated.push_str(newline);
|
||||
}
|
||||
write_game_creator_private_file(&path, updated.as_bytes(), "Godot 工程配置")
|
||||
}
|
||||
|
||||
fn escape_godot_project_string(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
@@ -346,13 +356,9 @@ pub(crate) fn discover_local_godot_project_root(
|
||||
matches.push(validate_godot_project_child_name(&entry.file_name())?);
|
||||
}
|
||||
matches.sort();
|
||||
if matches.len() > 1 {
|
||||
return Err(format!(
|
||||
"工作区一层子目录中发现多个 Godot 项目:{}",
|
||||
matches.join("、")
|
||||
));
|
||||
}
|
||||
let Some(relative_root) = matches.pop() else {
|
||||
// 2026-09-21 解除“必须唯一”的限制:一层子目录出现多个 Godot 工程时按名称排序
|
||||
// 取第一个,结果确定且可复现,不再因为存在第二个工程而整体失败关闭。
|
||||
let Some(relative_root) = matches.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -694,9 +700,8 @@ pub(crate) fn import_local_godot_project_at(
|
||||
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
|
||||
return Err("Godot 工作区目录不存在或不是普通文件夹".to_string());
|
||||
}
|
||||
let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| {
|
||||
"所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()
|
||||
})?;
|
||||
let godot_project_root = discover_local_godot_project_root(root)?
|
||||
.ok_or_else(|| "所选工作区未在根目录或一层子目录发现 project.godot".to_string())?;
|
||||
if project_id.is_empty() {
|
||||
return Err("项目 ID 不能为空".to_string());
|
||||
}
|
||||
|
||||
@@ -133,17 +133,18 @@ fn root_godot_project_takes_priority_over_direct_child_projects() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_multiple_direct_child_godot_projects_before_writing_agent_metadata() {
|
||||
fn picks_the_first_direct_child_godot_project_in_name_order() {
|
||||
let workspace = godot_import_test_path("multiple-children");
|
||||
write_godot_project(&workspace.join("alpha"), "Alpha");
|
||||
write_godot_project(&workspace.join("beta"), "Beta");
|
||||
|
||||
let error = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous")
|
||||
.expect_err("multiple direct child Godot projects must fail");
|
||||
let result = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous")
|
||||
.expect("multiple direct child Godot projects must resolve deterministically");
|
||||
|
||||
assert!(error.contains("多个 Godot 项目"), "{error}");
|
||||
assert!(!workspace.join(".agent").exists());
|
||||
assert!(!workspace.join("alpha/.agent").exists());
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha"));
|
||||
assert_manifest_godot_root(&workspace, "alpha");
|
||||
assert!(workspace.join(".agent/manifest.json").is_file());
|
||||
// 未选中的候选工程不写任何 AGC 元数据。
|
||||
assert!(!workspace.join("beta/.agent").exists());
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
@@ -187,23 +188,21 @@ fn calibrates_existing_manifest_to_the_discovered_godot_root() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_layout_does_not_rewrite_an_existing_manifest() {
|
||||
fn ambiguous_layout_calibrates_an_existing_manifest_deterministically() {
|
||||
let workspace = godot_import_test_path("ambiguous-existing");
|
||||
init_local_game_project_at(&workspace, "existing-project", "Existing Project")
|
||||
.expect("initialize existing workspace");
|
||||
write_godot_project(&workspace.join("game"), "Game");
|
||||
write_godot_project(&workspace.join("other"), "Other");
|
||||
let manifest_path = workspace.join(".agent/manifest.json");
|
||||
let original = fs::read(&manifest_path).expect("read original manifest");
|
||||
|
||||
let error = import_local_godot_project_at(&workspace, "ignored", "Ignored")
|
||||
.expect_err("ambiguous existing workspace must fail");
|
||||
let result = import_local_godot_project_at(&workspace, "ignored", "Ignored")
|
||||
.expect("ambiguous existing workspace must calibrate to one candidate");
|
||||
|
||||
assert!(error.contains("多个 Godot 项目"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read(&manifest_path).expect("read unchanged manifest"),
|
||||
original
|
||||
);
|
||||
assert_eq!(result.manifest.project_id, "existing-project");
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game"));
|
||||
assert_manifest_godot_root(&workspace, "game");
|
||||
assert!(!workspace.join("game/.agent").exists());
|
||||
assert!(!workspace.join("other/.agent").exists());
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
@@ -286,7 +285,7 @@ fn manifest_read_rejects_unsafe_persisted_godot_project_root() {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() {
|
||||
fn accepts_symbolic_link_project_marker() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let workspace = godot_import_test_path("linked-marker");
|
||||
@@ -294,11 +293,51 @@ fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() {
|
||||
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
|
||||
symlink("real.godot", workspace.join("project.godot")).expect("link project marker");
|
||||
|
||||
let error = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect_err("linked project.godot must fail");
|
||||
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect("linked project.godot must be accepted");
|
||||
|
||||
assert!(error.contains("符号链接"), "{error}");
|
||||
assert!(!workspace.join(".agent").exists());
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
|
||||
assert_manifest_godot_root(&workspace, ".");
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn accepts_windows_hard_link_project_marker() {
|
||||
let workspace = godot_import_test_path("windows-hard-link-marker");
|
||||
fs::create_dir_all(&workspace).expect("create hard link marker workspace");
|
||||
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
|
||||
fs::hard_link(
|
||||
workspace.join("real.godot"),
|
||||
workspace.join("project.godot"),
|
||||
)
|
||||
.expect("hard link project marker");
|
||||
|
||||
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect("hard linked project.godot must be accepted");
|
||||
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
|
||||
assert_manifest_godot_root(&workspace, ".");
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn accepts_windows_reparse_project_marker() {
|
||||
let workspace = godot_import_test_path("windows-reparse-marker");
|
||||
fs::create_dir_all(&workspace).expect("create reparse marker workspace");
|
||||
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
|
||||
if std::os::windows::fs::symlink_file("real.godot", workspace.join("project.godot")).is_err() {
|
||||
// 未开启开发者模式的机器创建文件符号链接需要额外权限,跳过而不是误报通过。
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect("reparse point project.godot must be accepted");
|
||||
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
|
||||
assert_manifest_godot_root(&workspace, ".");
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
@@ -344,7 +383,7 @@ fn ignores_unrelated_symbolic_link_while_importing_a_unique_regular_child() {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_linked_marker_inside_a_regular_child_candidate() {
|
||||
fn accepts_linked_marker_inside_a_regular_child_candidate() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let workspace = godot_import_test_path("linked-child-marker");
|
||||
@@ -353,11 +392,12 @@ fn rejects_linked_marker_inside_a_regular_child_candidate() {
|
||||
fs::write(godot_root.join("real.godot"), "[application]\n").expect("write real marker");
|
||||
symlink("real.godot", godot_root.join("project.godot")).expect("link project marker");
|
||||
|
||||
let error = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect_err("linked marker in a regular child must fail");
|
||||
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
|
||||
.expect("linked marker in a regular child must be accepted");
|
||||
|
||||
assert!(error.contains("符号链接"), "{error}");
|
||||
assert!(!workspace.join(".agent").exists());
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game"));
|
||||
assert_manifest_godot_root(&workspace, "game");
|
||||
assert!(!godot_root.join(".agent").exists());
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
@@ -397,6 +437,42 @@ fn rejects_non_godot_directory_without_writing_agent_metadata() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_only_the_godot_display_name_line() {
|
||||
let root = godot_import_test_path("display-name");
|
||||
fs::create_dir_all(&root).expect("create project root");
|
||||
fs::write(
|
||||
root.join("project.godot"),
|
||||
"config_version=5\n\n[application]\n\nconfig/name=\"模板名\"\nrun/main_scene=\"res://scenes/main.tscn\"\n",
|
||||
)
|
||||
.expect("write project.godot");
|
||||
|
||||
apply_godot_project_display_name(&root, "我的\"平台跳跃\"").expect("rewrite display name");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("project.godot")).expect("read project.godot"),
|
||||
"config_version=5\n\n[application]\n\nconfig/name=\"我的\\\"平台跳跃\\\"\"\nrun/main_scene=\"res://scenes/main.tscn\"\n"
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_a_godot_project_without_a_display_name_line_untouched() {
|
||||
let root = godot_import_test_path("no-display-name");
|
||||
fs::create_dir_all(&root).expect("create project root");
|
||||
let original =
|
||||
"config_version=5\n\n[application]\n\nrun/main_scene=\"res://scenes/main.tscn\"\n";
|
||||
fs::write(root.join("project.godot"), original).expect("write project.godot");
|
||||
|
||||
apply_godot_project_display_name(&root, "我的项目").expect("no display name is not an error");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("project.godot")).expect("read project.godot"),
|
||||
original
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
fn write_raw_manifest_fixture(workspace: &Path, payload: &serde_json::Value) -> (PathBuf, String) {
|
||||
let manifest_path = workspace.join(".agent/manifest.json");
|
||||
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
|
||||
|
||||
@@ -990,6 +990,16 @@ pub(crate) fn create_project_from_installed_template_at(
|
||||
&project_name,
|
||||
);
|
||||
}
|
||||
// Godot 模板同样按工程文件识别:走既有 Godot 导入流程,写入
|
||||
// `godotProjectRoot` 并按用户输入改写工程显示名,不生成 Web 占位入口。
|
||||
if discover_local_godot_project_root(&project_root)?.is_some() {
|
||||
apply_godot_project_display_name(&project_root, &project_name)?;
|
||||
return import_local_godot_project_at(
|
||||
&project_root,
|
||||
&format!("gameagent-{workspace_id}"),
|
||||
&project_name,
|
||||
);
|
||||
}
|
||||
init_local_game_project_at(
|
||||
&project_root,
|
||||
&format!("gameagent-{workspace_id}"),
|
||||
@@ -1492,6 +1502,49 @@ mod tests {
|
||||
fs::remove_dir_all(&projects_root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn godot_template_creates_native_project_with_relative_root_and_display_name() {
|
||||
let cache_root = tempfile::tempdir().expect("temp dir");
|
||||
let projects_root = unique_projects_root();
|
||||
let config_source = "config_version=5\n\n[application]\n\nconfig/name=\"Godot 模板\"\nrun/main_scene=\"res://scenes/main.tscn\"\n";
|
||||
let archive = build_archive(&[
|
||||
("project.godot", config_source.as_bytes()),
|
||||
("scenes/main.tscn", b"[gd_scene format=3]\n"),
|
||||
]);
|
||||
let mut summary = sample_summary();
|
||||
summary.id = "godot-fixture-template".to_string();
|
||||
summary.runtime = "godot".to_string();
|
||||
summary.entry = "project.godot".to_string();
|
||||
summary.zip_size_bytes = archive.len() as u64;
|
||||
summary.zip_sha256 = sha256_hex(&archive);
|
||||
let record = install_template_archive(cache_root.path(), &summary, &archive)
|
||||
.expect("install Godot template");
|
||||
|
||||
let result = create_project_from_installed_template_at(
|
||||
&projects_root,
|
||||
Path::new(&record.project_dir),
|
||||
Some("我的平台跳跃"),
|
||||
false,
|
||||
)
|
||||
.expect("create Godot project from template");
|
||||
|
||||
// Godot 模板走 Godot 导入:记录相对根,且不生成 Web 占位入口与并行目录。
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
|
||||
assert_eq!(result.manifest.name, "我的平台跳跃");
|
||||
let project_root = Path::new(&result.project_path);
|
||||
assert!(!project_root.join("game").exists());
|
||||
assert!(project_root.join("scenes/main.tscn").is_file());
|
||||
let config =
|
||||
fs::read_to_string(project_root.join("project.godot")).expect("read project.godot");
|
||||
assert!(config.contains("config/name=\"我的平台跳跃\""), "{config}");
|
||||
assert!(
|
||||
config.contains("run/main_scene=\"res://scenes/main.tscn\""),
|
||||
"只改显示名,其余行逐字保留:{config}"
|
||||
);
|
||||
assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists());
|
||||
fs::remove_dir_all(&projects_root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_to_create_project_when_template_is_not_installed() {
|
||||
let projects_root = tempfile::tempdir().expect("temp dir");
|
||||
|
||||
@@ -1827,7 +1827,7 @@ async fn agent_runtime_command_exec_diagnostic_command_cannot_pass_verification_
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(observation.status, "ok");
|
||||
assert_observation_status(&observation, "ok");
|
||||
assert!(observation.summary.contains("只作为诊断结果"));
|
||||
assert!(observation
|
||||
.detail
|
||||
|
||||
@@ -1649,6 +1649,7 @@ async fn direct_image_generation_notifies_after_manifest_commit() {
|
||||
},
|
||||
)
|
||||
.expect("allow generation");
|
||||
let execution = direct_execution_fixture(&root, "direct-image-refresh-turn").await;
|
||||
let listener =
|
||||
TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).expect("bind event receiver");
|
||||
let sink = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
|
||||
@@ -1691,6 +1692,12 @@ async fn direct_image_generation_notifies_after_manifest_commit() {
|
||||
.await
|
||||
.expect("read rejected result");
|
||||
assert_eq!(rejected["isError"], true);
|
||||
assert!(
|
||||
rejected["content"][0]["text"]
|
||||
.as_str()
|
||||
.is_some_and(|message| message.contains("prompt")),
|
||||
"{rejected}"
|
||||
);
|
||||
assert_eq!(
|
||||
read_manifest_invalidation_relay_payload_with_deadline(&listener)
|
||||
.expect_err("rejected generation must not emit a commit")
|
||||
@@ -1698,6 +1705,7 @@ async fn direct_image_generation_notifies_after_manifest_commit() {
|
||||
io::ErrorKind::TimedOut
|
||||
);
|
||||
drop(bridge);
|
||||
drop(execution);
|
||||
fs::remove_dir_all(root).ok();
|
||||
fs::remove_dir_all(config_dir).ok();
|
||||
}
|
||||
@@ -2532,42 +2540,45 @@ fn project_directory_status_reports_workspace_relative_godot_root() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() {
|
||||
fn project_directory_status_resolves_ambiguous_direct_child_godot_projects() {
|
||||
let root = unique_project_path();
|
||||
for child in ["alpha", "beta"] {
|
||||
// 目录枚举顺序不可信:故意倒序创建,证明选择按名称而不是按创建顺序。
|
||||
for child in ["beta", "alpha"] {
|
||||
let godot_root = root.join(child);
|
||||
fs::create_dir_all(&godot_root).expect("create nested Godot project");
|
||||
fs::write(godot_root.join("project.godot"), "[application]\n")
|
||||
.expect("write nested project.godot");
|
||||
}
|
||||
|
||||
let error = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
|
||||
.expect_err("ambiguous Godot workspace must fail inspection");
|
||||
let status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
|
||||
.expect("multiple direct child Godot projects must resolve deterministically");
|
||||
|
||||
assert!(error.contains("多个 Godot 项目"), "{error}");
|
||||
assert!(status.is_godot_project);
|
||||
assert_eq!(status.godot_project_root.as_deref(), Some("alpha"));
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() {
|
||||
fn godot_import_command_resolves_child_ambiguity_and_releases_the_lock() {
|
||||
let root = unique_project_path();
|
||||
for child in ["alpha", "beta"] {
|
||||
for child in ["beta", "alpha"] {
|
||||
let godot_root = root.join(child);
|
||||
fs::create_dir_all(&godot_root).expect("create nested Godot project");
|
||||
fs::write(godot_root.join("project.godot"), "[application]\n")
|
||||
.expect("write nested project.godot");
|
||||
}
|
||||
|
||||
let error = import_local_godot_project(
|
||||
let result = import_local_godot_project(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"ambiguous".to_string(),
|
||||
"Ambiguous".to_string(),
|
||||
)
|
||||
.expect_err("ambiguous Godot workspace must fail before locking");
|
||||
.expect("ambiguous Godot workspace must import the first candidate deterministically");
|
||||
|
||||
assert!(error.contains("多个 Godot 项目"), "{error}");
|
||||
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha"));
|
||||
assert!(root.join(".agent/manifest.json").is_file());
|
||||
assert!(!root.join("beta/.agent").exists());
|
||||
assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists());
|
||||
assert!(!root.join(".agent").exists());
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
@@ -3542,7 +3553,8 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th
|
||||
{
|
||||
let root = unique_project_path();
|
||||
let config_dir = unique_project_path();
|
||||
let base_url = spawn_mock_external_canvas_generation_api_server(None);
|
||||
let (request_sender, request_receiver) = mpsc::channel();
|
||||
let base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender));
|
||||
let _platform_session = crate::platform_session::install_test_platform_session(
|
||||
"toolbar-spritesheet-user",
|
||||
"editor-toolbar-spritesheet-key",
|
||||
@@ -3597,7 +3609,7 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th
|
||||
let asset = generate_local_project_asset(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"icon-spritesheet".to_string(),
|
||||
"原创收集玩法素材图集".to_string(),
|
||||
"\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}".to_string(),
|
||||
Some("1:1".to_string()),
|
||||
Some("1K".to_string()),
|
||||
Some("游戏首版图集".to_string()),
|
||||
@@ -3608,6 +3620,23 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th
|
||||
.await
|
||||
.expect("toolbar spritesheet generation");
|
||||
|
||||
let generation_request = request_receiver
|
||||
.try_iter()
|
||||
.find(|request| request.starts_with("POST /api/editor/icon-spritesheets/generations "))
|
||||
.expect("captured toolbar spritesheet request");
|
||||
let generation_body: Value = serde_json::from_str(
|
||||
generation_request
|
||||
.split_once("\r\n\r\n")
|
||||
.expect("generation request body")
|
||||
.1,
|
||||
)
|
||||
.expect("generation request json");
|
||||
assert_eq!(
|
||||
generation_body["iconDescriptions"],
|
||||
serde_json::json!(["金币\n木制宝箱\n银色钥匙"]),
|
||||
"客户端只去除首尾空白,完整保留用户描述,不注入或截断提示词"
|
||||
);
|
||||
|
||||
assert!(asset.local_path.starts_with("assets/canvas-generated/"));
|
||||
let manifest: Value =
|
||||
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
|
||||
@@ -6358,7 +6387,6 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() {
|
||||
};
|
||||
let prompt = build_platform_art_asset_prompt(
|
||||
"原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开",
|
||||
&[],
|
||||
&options,
|
||||
);
|
||||
for expected in [
|
||||
@@ -6400,7 +6428,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_defense() {
|
||||
fn art_spritesheet_generation_prompt_preserves_user_description() {
|
||||
let options = PlatformArtAssetGenerationOptions {
|
||||
output_path: Some("assets/art-spritesheet.png".to_string()),
|
||||
asset_kind: GameCreationAppAssetKind::IconSpritesheet,
|
||||
@@ -6409,21 +6437,12 @@ fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_de
|
||||
};
|
||||
let prompt = build_platform_art_asset_prompt(
|
||||
"原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物",
|
||||
&[],
|
||||
&options,
|
||||
);
|
||||
for expected in [
|
||||
"原创透明核心美术素材图集",
|
||||
"严格从用户需求和美术 brief 提取",
|
||||
"素材类别与数量以当前项目需求为准",
|
||||
"蛇头、直身、转角、尾部和四类食物",
|
||||
"角色轮廓、图标排布与配色采用项目原创设计",
|
||||
] {
|
||||
assert!(
|
||||
prompt.contains(expected),
|
||||
"art generation prompt missing {expected}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物"
|
||||
);
|
||||
|
||||
let art_spec = platform_art_asset_art_spec(&options).to_string();
|
||||
for expected in [
|
||||
|
||||
@@ -254,6 +254,7 @@ import {
|
||||
type DirectThreadSubscriptionBootstrap,
|
||||
} from './features/project-workspace/directThreadEvents';
|
||||
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
|
||||
import { importDesignFiles } from './features/project-workspace/importDesignFiles';
|
||||
import {
|
||||
appendMemoryContent,
|
||||
memoryScopeLabel,
|
||||
@@ -489,6 +490,7 @@ type AppProps = {
|
||||
initialSupervisorMessageClaimScope?: string;
|
||||
initialCreationType?: HomeCreationType | null;
|
||||
initialAttachments?: LauncherImportedAttachment[];
|
||||
onDesignFilesImportingChange?: ProjectSupervisorComponentProps['onDesignFilesImportingChange'];
|
||||
playRequest?: ProjectSupervisorComponentProps['playRequest'];
|
||||
onPlayRequestHandled?: ProjectSupervisorComponentProps['onPlayRequestHandled'];
|
||||
onManifestChange?: (
|
||||
@@ -539,6 +541,7 @@ export function App({
|
||||
initialSupervisorMessageClaimScope = '',
|
||||
initialCreationType = null,
|
||||
initialAttachments = [],
|
||||
onDesignFilesImportingChange,
|
||||
playRequest = null,
|
||||
onPlayRequestHandled,
|
||||
onManifestChange,
|
||||
@@ -707,6 +710,7 @@ export function App({
|
||||
DirectCodexTurnAttachment[]
|
||||
>([]);
|
||||
const [chatAttachmentNotice, setChatAttachmentNotice] = useState('');
|
||||
const [chatFilesImporting, setChatFilesImporting] = useState(false);
|
||||
/** 回合运行中再次发送的消息:FIFO 本地队列,当前回合结束后依次发出。 */
|
||||
const [chatTurnQueue, setChatTurnQueue] = useState<QueuedChatTurn[]>([]);
|
||||
const chatTurnQueueRef = useRef<QueuedChatTurn[]>([]);
|
||||
@@ -11470,8 +11474,7 @@ export function App({
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入盒上传本地文件:复用首页建项目那条 `upload_local_asset` 链路把文件写进项目,
|
||||
* 再以**项目相对路径**生成回合附件(绝对路径会被 Rust 侧附件规则判为失败)。
|
||||
* 策划文件直接导入工作区;游戏文件沿用资产上传与回合附件链路。
|
||||
*/
|
||||
async function handleChatComposerUploadFiles(files: readonly File[]) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
@@ -11480,6 +11483,22 @@ export function App({
|
||||
setChatAttachmentNotice('需要先打开本地项目,才能上传文件');
|
||||
return;
|
||||
}
|
||||
if (designAgentActiveRef.current) {
|
||||
if (chatFilesImporting || files.length === 0) return;
|
||||
setChatFilesImporting(true);
|
||||
onDesignFilesImportingChange?.(nextProjectPath, true);
|
||||
setChatAttachmentNotice('正在导入文件');
|
||||
try {
|
||||
const notice = await importDesignFiles(invoke, nextProjectPath, files);
|
||||
if (localProjectPathRef.current === nextProjectPath) {
|
||||
setChatAttachmentNotice(notice);
|
||||
}
|
||||
} finally {
|
||||
setChatFilesImporting(false);
|
||||
onDesignFilesImportingChange?.(nextProjectPath, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const remaining = MAX_CHAT_COMPOSER_ATTACHMENTS - chatAttachments.length;
|
||||
const accepted = files.slice(0, Math.max(remaining, 0));
|
||||
if (accepted.length === 0) {
|
||||
@@ -11822,6 +11841,7 @@ export function App({
|
||||
activeVersionId={chatActiveVersionId}
|
||||
attachments={chatAttachments}
|
||||
attachmentNotice={chatAttachmentNotice}
|
||||
importingFiles={chatFilesImporting}
|
||||
chatInput={chatInput}
|
||||
chatReferences={chatReferences}
|
||||
composerNotice={chatComposerNotice}
|
||||
|
||||
@@ -143,6 +143,8 @@ export type LauncherProjectContext = {
|
||||
startMode: ProjectStartMode | null;
|
||||
initialPrompt: string;
|
||||
attachments: LauncherImportedAttachment[];
|
||||
/** 仅用于工作区界面,不进入 Agent 消息。 */
|
||||
fileImportNotice?: string;
|
||||
recentRunStatus: string | null;
|
||||
recentRunStopReason: string | null;
|
||||
createdAt: number;
|
||||
|
||||
@@ -528,7 +528,24 @@ export function WorkspaceLauncherShell({
|
||||
});
|
||||
}
|
||||
|
||||
const designImportProjectRef = useRef<string | null>(null);
|
||||
const handleDesignFilesImportingChange = useCallback(
|
||||
(projectPath: string, importing: boolean) => {
|
||||
if (importing) designImportProjectRef.current = projectPath;
|
||||
else if (designImportProjectRef.current === projectPath)
|
||||
designImportProjectRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
async function switchToGameRuntime(nextProjectPath: string) {
|
||||
if (designImportProjectRef.current === nextProjectPath) {
|
||||
setLauncherNotice({
|
||||
title: '正在导入文件',
|
||||
message: '文件导入完成后,再做成游戏。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) throw new Error('需要在陶泥儿客户端内运行');
|
||||
await invoke('set_design_agent_runtime_mode', {
|
||||
@@ -638,6 +655,25 @@ export function WorkspaceLauncherShell({
|
||||
/>
|
||||
) : launcherView === 'project-development' && currentProjectContext ? (
|
||||
<>
|
||||
{currentProjectContext.fileImportNotice ? (
|
||||
<div className="launcher-manifest-merge-notices">
|
||||
<div className="game-resource-live-notice" role="status">
|
||||
<span>{currentProjectContext.fileImportNotice}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCurrentProjectContext((current) =>
|
||||
current
|
||||
? { ...current, fileImportNotice: undefined }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{manifestMergeNotice ? (
|
||||
<div className="launcher-manifest-merge-notices">
|
||||
<div
|
||||
@@ -739,6 +775,9 @@ export function WorkspaceLauncherShell({
|
||||
? currentProjectContext.attachments
|
||||
: []
|
||||
}
|
||||
onDesignFilesImportingChange={
|
||||
handleDesignFilesImportingChange
|
||||
}
|
||||
initialSupervisorMessageClaimScope={
|
||||
switchedToGameRuntime ? 'approved-design-build' : ''
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ export type ProjectSupervisorComponentProps = {
|
||||
initialSupervisorMessageClaimScope?: string;
|
||||
initialCreationType?: HomeCreationType | null;
|
||||
initialAttachments?: LauncherImportedAttachment[];
|
||||
onDesignFilesImportingChange?: (
|
||||
projectPath: string,
|
||||
importing: boolean,
|
||||
) => void;
|
||||
orchestrationMode?: 'single-supervisor' | 'professional-dag';
|
||||
projectSupervisorOnly?: boolean;
|
||||
planningStartMode?: boolean;
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
isAbsoluteProjectPath,
|
||||
projectPathHasControlCharacter,
|
||||
} from '../project-summary/projectSummary';
|
||||
import { importDesignFiles } from '../project-workspace/importDesignFiles';
|
||||
import {
|
||||
ensureHomeWebCreationEnvironment,
|
||||
HOME_WEB_PREFLIGHT_FAILURE,
|
||||
@@ -323,11 +324,18 @@ export function useHomeProjectCreation({
|
||||
activeRuntime: 'design',
|
||||
});
|
||||
}
|
||||
const importedAttachments = await importHomeAttachments(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
attachments,
|
||||
);
|
||||
const fileImportNotice =
|
||||
startMode === 'planning'
|
||||
? await importDesignFiles(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
attachments.map((item) => item.file),
|
||||
)
|
||||
: undefined;
|
||||
const importedAttachments =
|
||||
startMode === 'planning'
|
||||
? []
|
||||
: await importHomeAttachments(invoke, result.projectPath, attachments);
|
||||
await enterProjectDevelopment({
|
||||
projectPath: result.projectPath,
|
||||
projectName:
|
||||
@@ -342,10 +350,11 @@ export function useHomeProjectCreation({
|
||||
startMode,
|
||||
initialPrompt:
|
||||
prompt.trim() ||
|
||||
(attachments.length > 0
|
||||
(attachments.length > 0 && startMode !== 'planning'
|
||||
? '用户上传了参考附件,等待后续补充需求。'
|
||||
: ''),
|
||||
attachments: importedAttachments,
|
||||
fileImportNotice,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
createdAt: Date.now(),
|
||||
|
||||
+43
-5
@@ -2,6 +2,7 @@ import {
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
ChevronDown,
|
||||
FileUp,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Settings,
|
||||
@@ -155,6 +156,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
|
||||
attachments?: DirectCodexTurnAttachment[];
|
||||
/** 上传/校验附件的提示文案(失败与成功都用它,空串不渲染)。 */
|
||||
attachmentNotice?: string;
|
||||
importingFiles?: boolean;
|
||||
chatInput: string;
|
||||
chatReferences: ChatReference[];
|
||||
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
|
||||
@@ -218,6 +220,7 @@ export function ProjectSupervisorView({
|
||||
activeVersionId = null,
|
||||
attachments = [],
|
||||
attachmentNotice = '',
|
||||
importingFiles = false,
|
||||
chatInput,
|
||||
chatReferences,
|
||||
chatProjectAssets,
|
||||
@@ -238,7 +241,7 @@ export function ProjectSupervisorView({
|
||||
onCancelTurn,
|
||||
onCancelQueuedTurn,
|
||||
onRemoveAttachment,
|
||||
onUploadFiles: _onUploadFiles,
|
||||
onUploadFiles,
|
||||
queuedTurns = [],
|
||||
composerNotice = '',
|
||||
turnCancelling = false,
|
||||
@@ -287,6 +290,7 @@ export function ProjectSupervisorView({
|
||||
const [modelReady, setModelReady] = useState(false);
|
||||
const [modelValidating, setModelValidating] = useState(false);
|
||||
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
|
||||
const designFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const modelValidateInFlightRef = useRef(false);
|
||||
const runBusy = runtimePanelProps.controlBusy || submitting;
|
||||
const directTurns = directCodex
|
||||
@@ -397,6 +401,7 @@ export function ProjectSupervisorView({
|
||||
title={submitLabel}
|
||||
disabled={
|
||||
runtimePanelProps.controlBusy ||
|
||||
(designAgentActive && importingFiles) ||
|
||||
needsUserInput ||
|
||||
Boolean(designView?.session.pendingApproval) ||
|
||||
Boolean(designView?.session.pendingClarification) ||
|
||||
@@ -419,7 +424,7 @@ export function ProjectSupervisorView({
|
||||
{designAgentActive ? (
|
||||
<DesignAgentPhaseStatus
|
||||
view={designView}
|
||||
busy={runtimePanelProps.controlBusy}
|
||||
busy={runtimePanelProps.controlBusy || importingFiles}
|
||||
error={runtimePanelProps.error}
|
||||
onApprove={onDesignApprove ?? (() => undefined)}
|
||||
onClarify={onDesignClarify ?? (() => undefined)}
|
||||
@@ -683,7 +688,7 @@ export function ProjectSupervisorView({
|
||||
{designView || onDesignApprove ? (
|
||||
<DesignAgentPendingActions
|
||||
view={designView}
|
||||
busy={runtimePanelProps.controlBusy}
|
||||
busy={runtimePanelProps.controlBusy || importingFiles}
|
||||
error={runtimePanelProps.error}
|
||||
onApprove={onDesignApprove ?? (() => undefined)}
|
||||
onClarify={onDesignClarify ?? (() => undefined)}
|
||||
@@ -713,6 +718,10 @@ export function ProjectSupervisorView({
|
||||
directCodex ? ' is-direct-codex' : ''
|
||||
}`}
|
||||
onSubmit={(event) => {
|
||||
if (designAgentActive && importingFiles) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!directCodex) {
|
||||
onSubmit(event);
|
||||
return;
|
||||
@@ -775,7 +784,36 @@ export function ProjectSupervisorView({
|
||||
/>
|
||||
{showModelControls ? (
|
||||
<div className="project-supervisor-composer-controls">
|
||||
{directCodex ? (
|
||||
{designAgentActive && onUploadFiles ? (
|
||||
<div className="project-supervisor-composer-controls-left">
|
||||
<input
|
||||
ref={designFileInputRef}
|
||||
type="file"
|
||||
hidden
|
||||
multiple
|
||||
className="project-supervisor-upload-input"
|
||||
data-chat-composer-upload="true"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disabled={importingFiles}
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = '';
|
||||
if (files.length > 0) onUploadFiles(files);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="project-supervisor-attachment-trigger"
|
||||
aria-label="导入文件到策划工作区"
|
||||
title="导入文件到策划工作区"
|
||||
disabled={importingFiles}
|
||||
onClick={() => designFileInputRef.current?.click()}
|
||||
>
|
||||
<FileUp size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : directCodex ? (
|
||||
<div className="project-supervisor-composer-controls-left">
|
||||
<button
|
||||
type="button"
|
||||
@@ -825,7 +863,7 @@ export function ProjectSupervisorView({
|
||||
) : (
|
||||
submitButton
|
||||
)}
|
||||
{directCodex &&
|
||||
{(directCodex || designAgentActive) &&
|
||||
(composerNotice || attachmentNotice || voiceNotice) ? (
|
||||
<p className="project-supervisor-composer-notice" role="status">
|
||||
{composerNotice || attachmentNotice || voiceNotice}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { TauriInvoke } from '../../app/types';
|
||||
|
||||
/** 策划附件只写工作区;返回界面提示,不生成回合附件或资源清单。 */
|
||||
export async function importDesignFiles(
|
||||
invoke: TauriInvoke,
|
||||
projectPath: string,
|
||||
files: readonly File[],
|
||||
): Promise<string> {
|
||||
if (files.length === 0) return '';
|
||||
let imported = 0;
|
||||
const failures: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
await invoke<string>('import_design_workspace_file', {
|
||||
projectPath,
|
||||
fileName: file.name,
|
||||
bytes: Array.from(new Uint8Array(await file.arrayBuffer())),
|
||||
});
|
||||
imported += 1;
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`${file.name}:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const summary = `已导入 ${imported} 个文件到策划工作区`;
|
||||
return failures.length > 0
|
||||
? `${summary};${failures.length} 个文件导入失败:${failures.join(';')}。可重新选择失败文件重试。`
|
||||
: summary;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user