补充本地试玩包导出入口
新增 /export 聊天命令和主窗口导出确认入口 新增本地试玩 ZIP 白名单打包和安全记录 同步共享命令契约、技术方案和决策记录
This commit is contained in:
@@ -408,6 +408,15 @@ struct LocalProjectCheckpointResult {
|
||||
total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalProjectExportPackageResult {
|
||||
package_path: String,
|
||||
package_relative_path: String,
|
||||
file_count: usize,
|
||||
total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalProjectDiffEntry {
|
||||
@@ -622,6 +631,8 @@ const LOCAL_CONVERSATION_SCHEMA_VERSION: &str = "game-creator-conversation.v1";
|
||||
const GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES: usize = 12;
|
||||
const MAX_CANVAS_EXPORT_FILES: usize = 500;
|
||||
const MAX_CANVAS_EXPORT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
const MAX_PROJECT_EXPORT_PACKAGE_FILES: usize = 1200;
|
||||
const MAX_PROJECT_EXPORT_PACKAGE_BYTES: u64 = 512 * 1024 * 1024;
|
||||
const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 15] = [
|
||||
".agent/agent.db",
|
||||
".agent/spec.md",
|
||||
@@ -1665,6 +1676,16 @@ fn create_local_project_checkpoint(
|
||||
create_local_project_checkpoint_at(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_local_project_package(
|
||||
project_path: String,
|
||||
) -> Result<LocalProjectExportPackageResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "project.export_package")?;
|
||||
let _lock = acquire_project_write_lock(root, "project.export_package")?;
|
||||
export_local_project_package_at(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn diff_local_project_checkpoint(
|
||||
project_path: String,
|
||||
@@ -7697,6 +7718,239 @@ fn create_local_project_checkpoint_at(root: &Path) -> Result<LocalProjectCheckpo
|
||||
})
|
||||
}
|
||||
|
||||
fn export_local_project_package_at(root: &Path) -> Result<LocalProjectExportPackageResult, String> {
|
||||
validate_project_root(root)?;
|
||||
ensure_project_export_package_dir(root, "game")?;
|
||||
let game_index_path = resolve_local_project_path(root, "game/index.html")?;
|
||||
if !game_index_path.is_file() {
|
||||
return Err("导出试玩包前需要先生成 game/index.html".to_string());
|
||||
}
|
||||
let game_index_metadata = checked_export_package_metadata(&game_index_path, "game/index.html")?;
|
||||
if !game_index_metadata.is_file() {
|
||||
return Err("导出试玩包前需要先生成 game/index.html".to_string());
|
||||
}
|
||||
let game_index = fs::read_to_string(&game_index_path)
|
||||
.map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?;
|
||||
if game_index.trim().is_empty() {
|
||||
return Err("导出试玩包前 game/index.html 不能为空".to_string());
|
||||
}
|
||||
let lower_game_index = game_index.to_ascii_lowercase();
|
||||
if !lower_game_index.contains("<html") && !lower_game_index.contains("<!doctype html") {
|
||||
return Err("导出试玩包前 game/index.html 必须是 HTML 文档".to_string());
|
||||
}
|
||||
validate_game_html_smoke(&game_index)?;
|
||||
ensure_project_export_package_dir(root, "exports")?;
|
||||
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
|
||||
if !readme_path.is_file() {
|
||||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||||
}
|
||||
let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?;
|
||||
if !readme_metadata.is_file() {
|
||||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||||
}
|
||||
|
||||
let files = collect_project_export_package_files(root)?;
|
||||
let total_bytes = files.iter().map(|(_, _, size)| *size).sum::<u64>();
|
||||
if files.len() > MAX_PROJECT_EXPORT_PACKAGE_FILES {
|
||||
return Err(format!(
|
||||
"试玩包文件数量超过上限:{} > {}",
|
||||
files.len(),
|
||||
MAX_PROJECT_EXPORT_PACKAGE_FILES
|
||||
));
|
||||
}
|
||||
if total_bytes > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
|
||||
return Err(format!(
|
||||
"试玩包文件总大小超过上限:{}B > {}B",
|
||||
total_bytes, MAX_PROJECT_EXPORT_PACKAGE_BYTES
|
||||
));
|
||||
}
|
||||
let package_relative_path = next_project_export_package_relative_path(root)?;
|
||||
let package_path = resolve_local_project_path(root, &package_relative_path)?;
|
||||
if let Some(parent) = package_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("创建导出目录失败:{}: {error}", parent.display()))?;
|
||||
}
|
||||
|
||||
let file = File::create(&package_path)
|
||||
.map_err(|error| format!("创建试玩包失败:{}: {error}", package_path.display()))?;
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
for (relative_path, absolute_path, _) in &files {
|
||||
writer
|
||||
.start_file(relative_path, options)
|
||||
.map_err(|error| format!("写入试玩包条目失败:{relative_path}: {error}"))?;
|
||||
let bytes = fs::read(absolute_path)
|
||||
.map_err(|error| format!("读取导出文件失败:{}: {error}", absolute_path.display()))?;
|
||||
writer
|
||||
.write_all(&bytes)
|
||||
.map_err(|error| format!("写入试玩包文件失败:{relative_path}: {error}"))?;
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|error| format!("完成试玩包失败:{}: {error}", package_path.display()))?;
|
||||
|
||||
let updated_at = unix_timestamp();
|
||||
let log_path = root.join(".agent/logs/command.log");
|
||||
if let Some(parent) = log_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?;
|
||||
}
|
||||
let output = format!(
|
||||
"导出试玩包:{},{} 个文件,{}B",
|
||||
package_relative_path,
|
||||
files.len(),
|
||||
total_bytes
|
||||
);
|
||||
let line = format!("{updated_at} project.export_package: {output}\n");
|
||||
fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.and_then(|mut file| file.write_all(line.as_bytes()))
|
||||
.map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))?;
|
||||
record_command_run(
|
||||
root,
|
||||
GameCreationAppCommandRunState {
|
||||
command_id: "project.export_package".to_string(),
|
||||
status: GameCreationAppCommandRunStatus::Completed,
|
||||
output,
|
||||
log_path: log_path.to_string_lossy().into_owned(),
|
||||
updated_at,
|
||||
},
|
||||
)?;
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "project.export_package",
|
||||
"packagePath": package_relative_path,
|
||||
"fileCount": files.len(),
|
||||
"totalBytes": total_bytes,
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(LocalProjectExportPackageResult {
|
||||
package_path: package_path.to_string_lossy().into_owned(),
|
||||
package_relative_path,
|
||||
file_count: files.len(),
|
||||
total_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> {
|
||||
let seed = unix_millis();
|
||||
for suffix in 0..1000 {
|
||||
let file_name = if suffix == 0 {
|
||||
format!("playtest-package-{seed}.zip")
|
||||
} else {
|
||||
format!("playtest-package-{seed}-{suffix}.zip")
|
||||
};
|
||||
let relative_path = format!("exports/{file_name}");
|
||||
let package_path = resolve_local_project_path(root, &relative_path)?;
|
||||
if !package_path.exists() {
|
||||
return Ok(relative_path);
|
||||
}
|
||||
}
|
||||
Err("无法生成唯一试玩包文件名".to_string())
|
||||
}
|
||||
|
||||
fn collect_project_export_package_files(
|
||||
root: &Path,
|
||||
) -> Result<Vec<(String, PathBuf, u64)>, String> {
|
||||
let mut files = Vec::new();
|
||||
collect_project_export_package_dir_files(root, "game", &mut files)?;
|
||||
if resolve_local_project_path(root, "assets")?.exists() {
|
||||
collect_project_export_package_dir_files(root, "assets", &mut files)?;
|
||||
}
|
||||
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
|
||||
let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?;
|
||||
if !readme_metadata.is_file() {
|
||||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||||
}
|
||||
let readme_size = readme_metadata.len();
|
||||
files.push(("exports/README.md".to_string(), readme_path, readme_size));
|
||||
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn ensure_project_export_package_dir(root: &Path, relative_dir: &str) -> Result<(), String> {
|
||||
let dir = resolve_local_project_path(root, relative_dir)?;
|
||||
let metadata = checked_export_package_metadata(&dir, relative_dir)?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(format!("{relative_dir} 必须是目录"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_project_export_package_dir_files(
|
||||
root: &Path,
|
||||
relative_dir: &str,
|
||||
files: &mut Vec<(String, PathBuf, u64)>,
|
||||
) -> Result<(), String> {
|
||||
let dir = resolve_local_project_path(root, relative_dir)?;
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let dir_metadata = checked_export_package_metadata(&dir, relative_dir)?;
|
||||
if !dir_metadata.is_dir() {
|
||||
return Err(format!("{relative_dir} 必须是目录"));
|
||||
}
|
||||
let mut dirs = vec![dir];
|
||||
while let Some(current_dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(¤t_dir)
|
||||
.map_err(|error| format!("读取导出目录失败:{}: {error}", current_dir.display()))?
|
||||
{
|
||||
let entry = entry
|
||||
.map_err(|error| format!("读取导出文件失败:{}: {error}", current_dir.display()))?;
|
||||
let file_type = entry.file_type().map_err(|error| {
|
||||
format!("读取导出文件类型失败:{}: {error}", entry.path().display())
|
||||
})?;
|
||||
let relative_path =
|
||||
normalize_export_package_entry_path(&relative_project_path(root, &entry.path())?)?;
|
||||
if file_type.is_symlink() {
|
||||
return Err(format!("试玩包不能包含符号链接:{relative_path}"));
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
dirs.push(entry.path());
|
||||
} else if file_type.is_file() {
|
||||
let size = entry
|
||||
.metadata()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"读取导出文件元数据失败:{}: {error}",
|
||||
entry.path().display()
|
||||
)
|
||||
})?
|
||||
.len();
|
||||
files.push((relative_path, entry.path(), size));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn checked_export_package_metadata(
|
||||
path: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<fs::Metadata, String> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.map_err(|error| format!("读取导出文件类型失败:{}: {error}", path.display()))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(format!(
|
||||
"试玩包不能包含符号链接:{}",
|
||||
normalize_export_package_entry_path(relative_path)?
|
||||
));
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn normalize_export_package_entry_path(relative_path: &str) -> Result<String, String> {
|
||||
if relative_path.chars().any(char::is_control) {
|
||||
return Err("试玩包条目路径不能包含控制字符".to_string());
|
||||
}
|
||||
normalize_relative_path(relative_path)
|
||||
}
|
||||
|
||||
fn diff_local_project_checkpoint_at(
|
||||
root: &Path,
|
||||
checkpoint_id: &str,
|
||||
@@ -8729,6 +8983,7 @@ fn main() {
|
||||
append_local_conversation_message,
|
||||
build_local_project_index,
|
||||
create_local_project_checkpoint,
|
||||
export_local_project_package,
|
||||
diff_local_project_checkpoint,
|
||||
restore_local_project_checkpoint,
|
||||
read_project_permission_policy,
|
||||
@@ -12245,6 +12500,144 @@ mod tests {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_export_package_uses_runtime_whitelist_and_records() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||||
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
|
||||
.expect("write playable html");
|
||||
write_local_project_file_at(&root, "assets/hero.txt", "hero asset").expect("write asset");
|
||||
write_local_project_file_at(&root, "exports/README.md", "playtest notes")
|
||||
.expect("write readme");
|
||||
write_local_project_file_at(&root, "memory/project.md", "private memory")
|
||||
.expect("write memory");
|
||||
fs::write(root.join(".env"), "LOCAL_PLACEHOLDER=not-for-export")
|
||||
.expect("write local config");
|
||||
fs::write(root.join(".agent/run.latest.json"), "{}").expect("write trace");
|
||||
|
||||
let result = export_local_project_package_at(&root).expect("export package");
|
||||
|
||||
assert_eq!(result.file_count, 3);
|
||||
assert!(result
|
||||
.package_relative_path
|
||||
.starts_with("exports/playtest-package-"));
|
||||
assert!(result.package_relative_path.ends_with(".zip"));
|
||||
let file = File::open(&result.package_path).expect("open export package");
|
||||
let mut archive = zip::ZipArchive::new(file).expect("read export package");
|
||||
let mut names = Vec::new();
|
||||
for index in 0..archive.len() {
|
||||
names.push(
|
||||
archive
|
||||
.by_index(index)
|
||||
.expect("zip entry")
|
||||
.name()
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"assets/hero.txt".to_string(),
|
||||
"exports/README.md".to_string(),
|
||||
"game/index.html".to_string(),
|
||||
]
|
||||
);
|
||||
assert!(!names.iter().any(|name| name.starts_with(".agent/")));
|
||||
assert!(!names.iter().any(|name| name.starts_with("memory/")));
|
||||
assert!(!names.iter().any(|name| name.contains(".env")));
|
||||
|
||||
let manifest = serde_json::from_str::<Value>(
|
||||
&fs::read_to_string(root.join(".agent/manifest.json")).expect("manifest"),
|
||||
)
|
||||
.expect("manifest json");
|
||||
assert!(manifest["commandRuns"]
|
||||
.as_array()
|
||||
.expect("command runs")
|
||||
.iter()
|
||||
.any(|run| run["commandId"] == "project.export_package"));
|
||||
let command_log =
|
||||
fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log");
|
||||
assert!(command_log.contains("project.export_package"));
|
||||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||||
assert!(agent_db.contains("\"recordType\":\"project.export_package\""));
|
||||
assert!(!agent_db.contains("LOCAL_PLACEHOLDER=not-for-export"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_project_export_package_rejects_symlink_assets() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||||
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
|
||||
.expect("write playable html");
|
||||
write_local_project_file_at(&root, "exports/README.md", "playtest notes")
|
||||
.expect("write readme");
|
||||
std::os::unix::fs::symlink(
|
||||
root.join("memory/project.md"),
|
||||
root.join("assets/private.md"),
|
||||
)
|
||||
.expect("create symlink asset");
|
||||
|
||||
let error = export_local_project_package_at(&root).expect_err("symlink should be rejected");
|
||||
|
||||
assert!(error.contains("试玩包不能包含符号链接"));
|
||||
assert!(!root.join("exports").join("playtest-package-").exists());
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||||
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
|
||||
.expect("write playable html");
|
||||
write_local_project_file_at(&root, "memory/project.md", "private memory")
|
||||
.expect("write memory");
|
||||
std::os::unix::fs::symlink(
|
||||
root.join("memory/project.md"),
|
||||
root.join("exports/README.md"),
|
||||
)
|
||||
.expect("create readme symlink");
|
||||
|
||||
let error = export_local_project_package_at(&root).expect_err("readme symlink rejected");
|
||||
|
||||
assert!(error.contains("符号链接"), "{error}");
|
||||
fs::remove_file(root.join("exports/README.md")).expect("remove readme symlink");
|
||||
write_local_project_file_at(&root, "exports/README.md", "playtest notes")
|
||||
.expect("write readme");
|
||||
fs::remove_dir_all(root.join("assets")).expect("remove assets dir");
|
||||
fs::create_dir_all(root.join("memory/assets")).expect("create memory assets");
|
||||
fs::write(root.join("memory/assets/hero.txt"), "private asset").expect("write asset");
|
||||
std::os::unix::fs::symlink(root.join("memory/assets"), root.join("assets"))
|
||||
.expect("create assets symlink");
|
||||
|
||||
let error = export_local_project_package_at(&root).expect_err("assets symlink rejected");
|
||||
|
||||
assert!(error.contains("符号链接"), "{error}");
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_export_package_requires_playable_html_and_readme() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||||
let missing_readme =
|
||||
export_local_project_package_at(&root).expect_err("default html is not playable");
|
||||
assert!(missing_readme.contains("游戏入口必须包含可渲染画布"));
|
||||
|
||||
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
|
||||
.expect("write playable html");
|
||||
let missing_readme =
|
||||
export_local_project_package_at(&root).expect_err("readme should be required");
|
||||
assert!(missing_readme.contains("exports/README.md"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() {
|
||||
let root = unique_project_path();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -953,7 +953,8 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
fireEvent.change(screen.getByLabelText('项目目录'), {
|
||||
target: { value: '/tmp/typed-game' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '显示目录' }));
|
||||
const revealButtons = screen.getAllByRole('button', { name: '显示目录' });
|
||||
fireEvent.click(revealButtons[revealButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('open_local_project_directory', {
|
||||
@@ -971,7 +972,8 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
fireEvent.change(screen.getByLabelText('项目目录'), {
|
||||
target: { value: 'relative-game' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '显示目录' }));
|
||||
const revealButtons = screen.getAllByRole('button', { name: '显示目录' });
|
||||
fireEvent.click(revealButtons[revealButtons.length - 1]);
|
||||
|
||||
expect(screen.getByText('请提供项目绝对路径')).not.toBeNull();
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
@@ -1580,7 +1582,8 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||||
|
||||
await screen.findByText('已打开:/tmp/authorized-game');
|
||||
fireEvent.click(screen.getByRole('button', { name: '显示目录' }));
|
||||
const revealButtons = screen.getAllByRole('button', { name: '显示目录' });
|
||||
fireEvent.click(revealButtons[revealButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('open_local_project_directory', {
|
||||
@@ -2009,6 +2012,14 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
totalBytes: 256,
|
||||
};
|
||||
}
|
||||
if (command === 'export_local_project_package') {
|
||||
return {
|
||||
packagePath: `${String(args?.projectPath ?? '')}/exports/playtest-package-unit.zip`,
|
||||
packageRelativePath: 'exports/playtest-package-unit.zip',
|
||||
fileCount: 4,
|
||||
totalBytes: 512,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
@@ -2125,7 +2136,9 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
submitChat('/brief');
|
||||
expect(await screen.findByText(/项目简报:/)).not.toBeNull();
|
||||
expect(screen.getByText(/任务:完成 0\/16 · ready 1/)).not.toBeNull();
|
||||
expect(screen.getByText(/最近 Run:run-main-shortcut-trace/)).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/最近 Run:run-main-shortcut-trace/),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看下一步' }));
|
||||
expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/next');
|
||||
expect(
|
||||
@@ -2148,8 +2161,9 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
expect(
|
||||
screen.getByText(/最近 run 已通过,但当前本地预览未运行。 建议:\/run/),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText(/还有 1 个 ready 任务等待处理。 建议:\/tasks/))
|
||||
.not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/还有 1 个 ready 任务等待处理。 建议:\/tasks/),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '处理首个风险' }));
|
||||
expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/run');
|
||||
expect(
|
||||
@@ -2356,9 +2370,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
submitChat('/artifacts');
|
||||
expect(await screen.findByText(/常用生成产物:/)).not.toBeNull();
|
||||
expect(screen.getByText(/读入口 · game\/index\.html/)).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/读发布说明 · exports\/README\.md/),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText(/读发布说明 · exports\/README\.md/)).not.toBeNull();
|
||||
const readEntryButtons = screen.getAllByRole('button', { name: '读入口' });
|
||||
fireEvent.click(readEntryButtons[readEntryButtons.length - 1]);
|
||||
expect(composerInput).toHaveProperty('value', '/read game/index.html');
|
||||
@@ -2592,6 +2604,39 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出' }));
|
||||
expect(await screen.findByText('project.export_package')).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'从 /tmp/authorized-game/game、assets 和 exports/README.md 导出本地试玩包',
|
||||
),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/已导出本地试玩包:exports\/playtest-package-unit\.zip/,
|
||||
),
|
||||
).not.toBeNull();
|
||||
const exportRevealButtons = screen.getAllByRole('button', {
|
||||
name: '显示目录',
|
||||
});
|
||||
fireEvent.click(exportRevealButtons[exportRevealButtons.length - 1]);
|
||||
expect(screen.getByLabelText('创作想法')).toHaveProperty(
|
||||
'value',
|
||||
'/open-project',
|
||||
);
|
||||
|
||||
submitChat('/export');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByText('准备导出本地试玩包。').length,
|
||||
).toBeGreaterThan(1),
|
||||
);
|
||||
expect(
|
||||
screen.getAllByText('project.export_package').length,
|
||||
).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '启动预览' }));
|
||||
expect(await screen.findByText('preview.start')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
@@ -2663,6 +2708,9 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
expect(invoke).toHaveBeenCalledWith('create_local_project_checkpoint', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('export_local_project_package', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('start_local_game_preview', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
});
|
||||
@@ -7824,6 +7872,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
screen.getByText(/\/audit:审计当前项目的 Agent 能力证据/),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText(/\/checkpoint:保存本地项目快照/)).not.toBeNull();
|
||||
expect(screen.getByText(/\/export:导出本地试玩包/)).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/\/checkpoints:列出最近 checkpoint/),
|
||||
).not.toBeNull();
|
||||
@@ -7882,9 +7931,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
expect(
|
||||
screen.getByText(/\/runs:列出已加载 Run 历史读取命令/),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/\/logs:列出常用日志读取命令/),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText(/\/logs:列出常用日志读取命令/)).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/\/canvas 画板项目ID:打开本机画板项目/),
|
||||
).not.toBeNull();
|
||||
@@ -11967,77 +12014,78 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
'local-project-draft',
|
||||
'未命名游戏原型',
|
||||
);
|
||||
const invoke = vi.fn(async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'is_local_project_directory_non_empty') {
|
||||
return false;
|
||||
}
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'init_local_game_project') {
|
||||
return {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
manifestPath: '/tmp/authorized-game/.agent/manifest.json',
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||||
agentId: null,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_file') {
|
||||
if (args?.relativePath === '.agent/logs/command.log') {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'is_local_project_directory_non_empty') {
|
||||
return false;
|
||||
}
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'init_local_game_project') {
|
||||
return {
|
||||
path: '.agent/logs/command.log',
|
||||
absolutePath:
|
||||
'/tmp/authorized-game/.agent/logs/command.log',
|
||||
content: 'permission.pending preview.start\npermission.confirm preview.start\n',
|
||||
projectPath: '/tmp/authorized-game',
|
||||
manifestPath: '/tmp/authorized-game/.agent/manifest.json',
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (args?.relativePath === '.agent/logs/preview.log') {
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '.agent/logs/preview.log',
|
||||
absolutePath:
|
||||
'/tmp/authorized-game/.agent/logs/preview.log',
|
||||
content: 'preview.start http://127.0.0.1:3210/\n',
|
||||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||||
agentId: null,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
if (args?.relativePath === '.agent/logs/agent.log') {
|
||||
if (command === 'read_local_project_file') {
|
||||
if (args?.relativePath === '.agent/logs/command.log') {
|
||||
return {
|
||||
path: '.agent/logs/command.log',
|
||||
absolutePath: '/tmp/authorized-game/.agent/logs/command.log',
|
||||
content:
|
||||
'permission.pending preview.start\npermission.confirm preview.start\n',
|
||||
};
|
||||
}
|
||||
if (args?.relativePath === '.agent/logs/preview.log') {
|
||||
return {
|
||||
path: '.agent/logs/preview.log',
|
||||
absolutePath: '/tmp/authorized-game/.agent/logs/preview.log',
|
||||
content: 'preview.start http://127.0.0.1:3210/\n',
|
||||
};
|
||||
}
|
||||
if (args?.relativePath === '.agent/logs/agent.log') {
|
||||
return {
|
||||
path: '.agent/logs/agent.log',
|
||||
absolutePath: '/tmp/authorized-game/.agent/logs/agent.log',
|
||||
content: 'Planner 正在整理规格\nGenerator 已写入草案\n',
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||||
);
|
||||
}
|
||||
if (command === 'list_local_project_files') {
|
||||
return { projectPath: '/tmp/authorized-game', files: [] };
|
||||
}
|
||||
if (command === 'get_limited_local_commands') {
|
||||
return [{ id: 'game.custom_smoke', title: '自定义自检' }];
|
||||
}
|
||||
if (command === 'run_limited_local_command') {
|
||||
return {
|
||||
path: '.agent/logs/agent.log',
|
||||
absolutePath: '/tmp/authorized-game/.agent/logs/agent.log',
|
||||
content: 'Planner 正在整理规格\nGenerator 已写入草案\n',
|
||||
commandId: 'game.custom_smoke',
|
||||
status: 'completed',
|
||||
output: 'custom smoke passed',
|
||||
logPath: '.agent/logs/custom.log',
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||||
);
|
||||
}
|
||||
if (command === 'list_local_project_files') {
|
||||
return { projectPath: '/tmp/authorized-game', files: [] };
|
||||
}
|
||||
if (command === 'get_limited_local_commands') {
|
||||
return [{ id: 'game.custom_smoke', title: '自定义自检' }];
|
||||
}
|
||||
if (command === 'run_limited_local_command') {
|
||||
return {
|
||||
commandId: 'game.custom_smoke',
|
||||
status: 'completed',
|
||||
output: 'custom smoke passed',
|
||||
logPath: '.agent/logs/custom.log',
|
||||
};
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return manifest;
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return manifest;
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderAppAt('/?dev');
|
||||
const logPanel = within(screen.getByLabelText('日志'));
|
||||
@@ -12064,7 +12112,9 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
expect(
|
||||
await screen.findByText('已读取:.agent/logs/command.log'),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText(/permission\.confirm preview\.start/)).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/permission\.confirm preview\.start/),
|
||||
).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('read_local_project_file', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
relativePath: '.agent/logs/command.log',
|
||||
@@ -12166,10 +12216,11 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
|
||||
submitChat('/status');
|
||||
|
||||
const statusMessage = await screen.findByText((_, element) =>
|
||||
element?.classList.contains('message--assistant') === true &&
|
||||
element.textContent?.includes('项目:未命名游戏原型') === true &&
|
||||
element.textContent.includes('目录:/tmp/authorized-game'),
|
||||
const statusMessage = await screen.findByText(
|
||||
(_, element) =>
|
||||
element?.classList.contains('message--assistant') === true &&
|
||||
element.textContent?.includes('项目:未命名游戏原型') === true &&
|
||||
element.textContent.includes('目录:/tmp/authorized-game'),
|
||||
);
|
||||
expect(statusMessage.textContent).toContain('任务:已完成 1,待处理 15');
|
||||
expect(statusMessage.textContent).toContain('资产:1 个');
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 AI 游戏创作 App 本地试玩包导出只打包运行白名单
|
||||
|
||||
- 背景:AI 游戏创作 App 需要给普通用户提供首版本地试玩包,但不能把项目记忆、trace、日志、运行时配置或密钥类文件混入可分发 ZIP。
|
||||
- 决策:v1 新增 `/export` 聊天入口和 `project.export_package` 确认命令。导出前重新校验 `game/index.html` 是可试玩自包含 HTML;ZIP 只包含 `game/**`、`assets/**` 和 `exports/README.md`,输出到 `exports/playtest-package-*.zip`;导出拒绝符号链接和不安全条目路径,并写入 manifest `commandRuns`、`.agent/logs/command.log` 和 `.agent/agent.db`。
|
||||
- 影响范围:`apps/ai-game-creator-shell` 的聊天命令、Tauri 本地项目能力、共享命令契约和 AI 游戏创作 App 实施计划。
|
||||
- 验证方式:运行 AI 游戏创作壳主窗口 smoke、Tauri `export` 定向测试、共享契约测试、类型检查、编码检查和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-07-01 AI 游戏创作 App v1 使用本地 JSONL 对话和派生 Agent 状态
|
||||
|
||||
- 背景:AI 游戏创作 App 已有 Godcoder 式本地工程护栏、项目黑板、角色私有记忆、manifest 和 run trace;新增结构化对话记录、agent 状态列表和单 agent 对话入口时,需要避免引入平行状态源或提前承诺后台 runner 能力。
|
||||
|
||||
@@ -100,7 +100,7 @@ game-project/
|
||||
## v1 验收
|
||||
|
||||
- 用户能创建本地 Web 游戏项目。
|
||||
- 用户侧看到当前工作区项目名 / 路径、最近 run、预览状态、任务完成数 / ready 数、资产数量 / 来源分布、最近命令摘要、聊天框、上传入口、Agent 状态列表和单 Agent 对话;聊天输入 `/brief` 可在普通聊天消息里生成当前项目简报并提供 `/next` 草稿,不新增普通用户面板;聊天输入 `/risks` 可在普通聊天消息里查看当前项目风险并提供首个风险处理草稿,不新增普通用户面板;聊天输入 `/handoff` 可在普通聊天消息里生成当前项目交接摘要并提供 `/next` 草稿,不新增普通用户面板;聊天输入 `/runs` 可在普通聊天消息里列出已加载 Run 历史读取命令,不新增普通用户面板;聊天输入 `/run-files` 可在普通聊天消息里列出 Agent 运行辅助文件读取命令,不新增普通用户面板;Agent 状态列表和单 Agent 对话在 `/llm-status` 后显示该 agent 当前 LLM provider / 模型 / 流式 / API Key 读取状态,但不显示密钥本体;最近项目资产入口显示本地路径、kind、mediaType 和来源类型,并可一键填入 `/read` 草稿,开发环境通过独立窗口查看任务拆分、专业组细节、产物、文件面板、嵌入预览以及 `.agent/logs/command.log` / `preview.log` / `agent.log`。
|
||||
- 用户侧看到当前工作区项目名 / 路径、最近 run、预览状态、任务完成数 / ready 数、资产数量 / 来源分布、最近命令摘要、聊天框、上传入口、Agent 状态列表和单 Agent 对话;聊天输入 `/brief` 可在普通聊天消息里生成当前项目简报并提供 `/next` 草稿,不新增普通用户面板;聊天输入 `/risks` 可在普通聊天消息里查看当前项目风险并提供首个风险处理草稿,不新增普通用户面板;聊天输入 `/handoff` 可在普通聊天消息里生成当前项目交接摘要并提供 `/next` 草稿,不新增普通用户面板;聊天输入 `/runs` 可在普通聊天消息里列出已加载 Run 历史读取命令,不新增普通用户面板;聊天输入 `/run-files` 可在普通聊天消息里列出 Agent 运行辅助文件读取命令,不新增普通用户面板;聊天输入 `/export` 确认后把当前可试玩原型导出为本地试玩 ZIP;Agent 状态列表和单 Agent 对话在 `/llm-status` 后显示该 agent 当前 LLM provider / 模型 / 流式 / API Key 读取状态,但不显示密钥本体;最近项目资产入口显示本地路径、kind、mediaType 和来源类型,并可一键填入 `/read` 草稿,开发环境通过独立窗口查看任务拆分、专业组细节、产物、文件面板、嵌入预览以及 `.agent/logs/command.log` / `preview.log` / `agent.log`。
|
||||
- 生成代码和资产进入用户本地项目目录。
|
||||
- 本地 HTTP 预览能启动,并在外部浏览器展示可玩原型。
|
||||
- 美术/音乐资产能从画板链路回流到本地项目。
|
||||
@@ -116,6 +116,7 @@ game-project/
|
||||
- `/handoff` 聊天入口由 `appSurface.test.ts` 的主窗口 smoke 覆盖:只基于当前已加载的 manifest / trace / agent 状态 / run history 生成交接摘要,提供 `/next` 草稿,不触发 Tauri 读写、文件读取、预览启动或新增普通用户面板。
|
||||
- `/runs` 聊天入口由 `appSurface.test.ts` 的主窗口 smoke 覆盖:只基于当前已加载的 latest trace 和已载入历史 run 批次生成 `/trace` 或 `/read .agent/runs/...` 草稿,不额外触发 Tauri 读取、不滚动加载更多历史、不新增普通用户面板。
|
||||
- `/run-files` 聊天入口由 `appSurface.test.ts` 的主窗口 smoke 覆盖:只列出 `.agent/output.jsonl`、`.agent/activity.jsonl` 和 `.agent/context.bundle.json` 的 `/read` 草稿,不直接读取辅助文件、不触发 Tauri 读写或新增普通用户面板。
|
||||
- `/export` 聊天入口由 `appSurface.test.ts` 的主窗口 smoke 和 Tauri Rust 测试覆盖:确认后执行 `project.export_package`,导出 `exports/playtest-package-*.zip`,只打包 `game/**`、`assets/**` 和 `exports/README.md`,不包含 `.agent/`、`memory/`、运行时配置、日志、trace 或密钥文件;导出前必须通过 `game/index.html` 可试玩静态验收,并写入 manifest `commandRuns`、`.agent/logs/command.log` 和 `.agent/agent.db`。
|
||||
- 主窗口策略快捷入口只填入 `/policy-confirm project.index`、`/policy-confirm asset.register`、`/policy-confirm memory.write`、`/policy-confirm preview.start`、`/policy-confirm preview.open`、`/policy-confirm preview.stop`、`/policy-confirm agent.run_status`、`/policy-confirm conversation.read` 或 `/policy-confirm conversation.write` 草稿;Agent 状态栏的“继续说明”只填入 `/agent-resume ` 草稿,不直接触发 run 生命周期写入。
|
||||
- 主窗口最近 checkpoint 列表展示 checkpoint id、文件数、大小和创建时间,同时提供直接对比、填入 `/diff`、确认回滚和填入 `/restore`;回滚继续走 `project.restore` 确认卡,不直接写项目文件。
|
||||
- `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 窗口边界、正式用户 App 不嵌入游戏预览 iframe、用户侧预览命令交给外部浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式发布只登记 `launcher` 启动器窗口,选择工作区后才关闭启动器并打开 `main` 主窗口,开发面板只在 debug/dev 路径打开,独立壳能完成 release 编译。
|
||||
@@ -183,7 +184,7 @@ game-project/
|
||||
- 聊天输入 `/risks` 只使用主窗口当前已加载的 manifest、最近 run trace、预览状态、任务状态、资产来源和最近命令摘要,在聊天里列出当前项目风险,并提供首个风险处理草稿;该命令不调用 Tauri 读写、不读取文件、不启动或打开预览、不新增普通用户面板,用户必须再发送草稿并按原命令权限流继续。
|
||||
- 聊天输入 `/handoff` 只使用主窗口当前已加载的 manifest、最近 run trace、Agent 状态和已载入历史 run 批次,在聊天里生成项目交接摘要,并提供 `/next` 作为后续草稿;该命令不调用 Tauri 读写、不读取文件、不启动或打开预览、不新增普通用户面板,用户必须再发送草稿并按原命令权限流继续。
|
||||
- 聊天输入 `/runs` 只使用主窗口当前已加载的 latest trace 和已载入历史 run 批次,在聊天里列出 `/trace` 和 `/read .agent/runs/...` 读取草稿;该命令不调用 Tauri 读写、不滚动加载更多历史、不启动或打开预览、不新增普通用户面板,用户必须再发送草稿并按原命令权限流继续。
|
||||
- 聊天输入 `/next` 只使用主窗口当前已加载的 manifest、最近 run trace 和最近命令摘要,在聊天里给出下一步建议,列出 `/tasks`、`/trace`、`/run`、`/open-preview`、`/assets`、`/artifacts`、`/run-artifacts`、`/run-files`、`/logs`、`/agent-resume ` 等安全命令草稿方向,并提供一个首选草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件,用户必须再发送草稿并按原命令权限流继续。
|
||||
- 聊天输入 `/next` 只使用主窗口当前已加载的 manifest、最近 run trace 和最近命令摘要,在聊天里给出下一步建议,列出 `/tasks`、`/trace`、`/run`、`/export`、`/open-preview`、`/assets`、`/artifacts`、`/run-artifacts`、`/run-files`、`/logs`、`/agent-resume ` 等安全命令草稿方向,并提供一个首选草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件,用户必须再发送草稿并按原命令权限流继续。
|
||||
- 主窗口可从 agent 状态列表进入单个 agent 对话;该入口只加载目标 agent 的 conversation JSONL,发送消息后追加到同一 agent conversation,不开启平行任务图、不 fork run,也不归档历史会话。
|
||||
- v1 通过独立启动器窗口选择本地项目后再进入主窗口;主窗口只承载当前工作区的聊天、配置和 agent 状态,切换项目时关闭当前主窗口并回到启动器,主窗口按钮和 `/switch-project` 聊天命令都复用同一 Tauri 启动器入口,避免在主窗口内用遮罩面板混合多个工作区上下文。
|
||||
- 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要和当前项目预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。
|
||||
@@ -194,6 +195,7 @@ game-project/
|
||||
- 聊天输入 `/commands` 会只读列出 Tauri runtime 暴露的受限命令白名单,读取失败或非 Tauri 环境下回退到共享契约默认列表;该命令不执行白名单命令,也不要求先初始化项目。
|
||||
- 聊天输入 `/smoke` 会生成待确认的 `command.run_limited` 内置命令,当前只映射到白名单 `game.static_smoke`,不开放任意命令解析。
|
||||
- 聊天输入 `/run` 会生成待确认的 `game.run_local` 内置命令,确认后复用白名单 `game.static_smoke` 运行当前 `game/index.html`,通过后启动只读本地 HTTP 预览并交给外部浏览器;该命令不开放任意 shell。
|
||||
- 聊天输入 `/export` 会生成待确认的 `project.export_package` 内置命令,确认后只把 `game/**`、`assets/**` 和 `exports/README.md` 打包到 `exports/playtest-package-*.zip`;导出前重新校验 `game/index.html` 是可试玩自包含 HTML,拒绝符号链接和越界路径,不把 `.agent/`、`memory/`、日志、trace、运行时配置或密钥文件写入 ZIP。
|
||||
- 聊天输入 `/preview` 会生成待确认的 `preview.start` 内置命令,确认后启动只读本地 HTTP 预览并交给外部浏览器;`/open-preview` 在本地项目已初始化后会生成待确认的 `preview.open`,并且只打开当前已授权项目对应的 `127.0.0.1` 本地预览;`/preview-status` 只查询当前已授权项目对应的本地 HTTP 预览并写入 `preview.status` 命令日志;`/preview-stop` 只停止当前项目预览,不打开、展示或停止其它项目遗留的全局预览,不向普通用户暴露预览面板。
|
||||
- 聊天输入 `/memory [short|long|blackboard]` 读取短期、长期或黑板记忆;`/remember [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并追加短期、长期或黑板记忆,未写 scope 时默认追加长期记忆;主窗口“记到黑板”“覆盖黑板”“清空黑板”只填入 `/remember blackboard `、`/memory-set blackboard ` 或 `/forget-memory blackboard` 草稿,仍由用户补内容并走聊天确认;`/memory-set [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并覆盖保存对应记忆;`/forget-memory [short|long|blackboard]` 生成待确认的 `memory.delete`。
|
||||
- 聊天输入 `/canvas 画板项目ID` 会生成待确认的 `canvas.project_open`,只打开本机 Genarrative 编辑器里的指定画板项目,不开放任意 URL;确认后聊天先反馈正在打开,再回写真实打开 URL。画板项目 ID 为空或包含控制字符时在聊天侧直接拒绝。
|
||||
|
||||
@@ -36,6 +36,11 @@ describe('AI 游戏创作 App 共享契约', () => {
|
||||
(command) => command.id === 'game.run_local',
|
||||
)?.permission,
|
||||
).toBe('confirm');
|
||||
expect(
|
||||
GAME_CREATION_APP_COMMANDS.find(
|
||||
(command) => command.id === 'project.export_package',
|
||||
)?.permission,
|
||||
).toBe('confirm');
|
||||
expect(
|
||||
GAME_CREATION_APP_COMMANDS.find(
|
||||
(command) => command.id === 'project.status',
|
||||
|
||||
@@ -20,6 +20,7 @@ export const GAME_CREATION_APP_COMMANDS = [
|
||||
{ id: 'project.checkpoint', permission: 'confirm' },
|
||||
{ id: 'project.diff', permission: 'auto' },
|
||||
{ id: 'project.restore', permission: 'confirm' },
|
||||
{ id: 'project.export_package', permission: 'confirm' },
|
||||
{ id: 'project.policy_read', permission: 'auto' },
|
||||
{ id: 'project.policy_write', permission: 'confirm' },
|
||||
{ id: 'task.list', permission: 'auto' },
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct GameCreationAppCommandDescriptor {
|
||||
pub permission: GameCreationAppPermission,
|
||||
}
|
||||
|
||||
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 42] = [
|
||||
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 43] = [
|
||||
command("help.show", GameCreationAppPermission::Auto),
|
||||
command("project.create", GameCreationAppPermission::Confirm),
|
||||
command("project.status", GameCreationAppPermission::Auto),
|
||||
@@ -29,6 +29,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 42] = [
|
||||
command("project.checkpoint", GameCreationAppPermission::Confirm),
|
||||
command("project.diff", GameCreationAppPermission::Auto),
|
||||
command("project.restore", GameCreationAppPermission::Confirm),
|
||||
command("project.export_package", GameCreationAppPermission::Confirm),
|
||||
command("project.policy_read", GameCreationAppPermission::Auto),
|
||||
command("project.policy_write", GameCreationAppPermission::Confirm),
|
||||
command("task.list", GameCreationAppPermission::Auto),
|
||||
@@ -638,6 +639,15 @@ mod tests {
|
||||
.expect("command should exist");
|
||||
assert_eq!(run_local.permission, GameCreationAppPermission::Confirm);
|
||||
|
||||
let export_package = GAME_CREATION_APP_COMMANDS
|
||||
.iter()
|
||||
.find(|command| command.id == "project.export_package")
|
||||
.expect("command should exist");
|
||||
assert_eq!(
|
||||
export_package.permission,
|
||||
GameCreationAppPermission::Confirm
|
||||
);
|
||||
|
||||
let status = GAME_CREATION_APP_COMMANDS
|
||||
.iter()
|
||||
.find(|command| command.id == "project.status")
|
||||
|
||||
Reference in New Issue
Block a user