补充本地试玩包列表入口
新增 /exports 聊天命令和试玩包列表摘要 新增 Tauri 试玩包列表命令和安全过滤测试 同步共享命令契约、技术方案和决策记录
This commit is contained in:
@@ -417,6 +417,22 @@ struct LocalProjectExportPackageResult {
|
||||
total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalProjectExportPackageSummary {
|
||||
package_path: String,
|
||||
package_relative_path: String,
|
||||
total_bytes: u64,
|
||||
modified_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalProjectExportPackagesResult {
|
||||
project_path: String,
|
||||
packages: Vec<LocalProjectExportPackageSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalProjectDiffEntry {
|
||||
@@ -1686,6 +1702,15 @@ fn export_local_project_package(
|
||||
export_local_project_package_at(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_local_project_export_packages(
|
||||
project_path: String,
|
||||
) -> Result<LocalProjectExportPackagesResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "project.export_list")?;
|
||||
list_local_project_export_packages_at(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn diff_local_project_checkpoint(
|
||||
project_path: String,
|
||||
@@ -7854,6 +7879,73 @@ fn next_project_export_package_relative_path(root: &Path) -> Result<String, Stri
|
||||
Err("无法生成唯一试玩包文件名".to_string())
|
||||
}
|
||||
|
||||
fn list_local_project_export_packages_at(
|
||||
root: &Path,
|
||||
) -> Result<LocalProjectExportPackagesResult, String> {
|
||||
validate_project_root(root)?;
|
||||
let export_dir = resolve_local_project_path(root, "exports")?;
|
||||
if !export_dir.exists() {
|
||||
return Ok(LocalProjectExportPackagesResult {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
packages: Vec::new(),
|
||||
});
|
||||
}
|
||||
let export_dir_metadata = checked_export_package_metadata(&export_dir, "exports")?;
|
||||
if !export_dir_metadata.is_dir() {
|
||||
return Err("exports 必须是目录".to_string());
|
||||
}
|
||||
|
||||
let mut packages = Vec::new();
|
||||
for entry in fs::read_dir(&export_dir)
|
||||
.map_err(|error| format!("读取试玩包目录失败:{}: {error}", export_dir.display()))?
|
||||
{
|
||||
let entry = entry
|
||||
.map_err(|error| format!("读取试玩包文件失败:{}: {error}", export_dir.display()))?;
|
||||
let file_type = entry.file_type().map_err(|error| {
|
||||
format!(
|
||||
"读取试玩包文件类型失败:{}: {error}",
|
||||
entry.path().display()
|
||||
)
|
||||
})?;
|
||||
if file_type.is_symlink() || !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let file_name = match entry.file_name().to_str() {
|
||||
Some(value) => value.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
if !file_name.starts_with("playtest-package-") || !file_name.ends_with(".zip") {
|
||||
continue;
|
||||
}
|
||||
let package_relative_path =
|
||||
normalize_export_package_entry_path(&format!("exports/{file_name}"))?;
|
||||
let metadata = entry.metadata().map_err(|error| {
|
||||
format!("读取试玩包元数据失败:{}: {error}", entry.path().display())
|
||||
})?;
|
||||
let modified_at = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
|
||||
.unwrap_or(0);
|
||||
packages.push(LocalProjectExportPackageSummary {
|
||||
package_path: entry.path().to_string_lossy().into_owned(),
|
||||
package_relative_path,
|
||||
total_bytes: metadata.len(),
|
||||
modified_at,
|
||||
});
|
||||
}
|
||||
packages.sort_by(|left, right| {
|
||||
(right.modified_at, &right.package_relative_path)
|
||||
.cmp(&(left.modified_at, &left.package_relative_path))
|
||||
});
|
||||
|
||||
Ok(LocalProjectExportPackagesResult {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
packages,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_project_export_package_files(
|
||||
root: &Path,
|
||||
) -> Result<Vec<(String, PathBuf, u64)>, String> {
|
||||
@@ -8984,6 +9076,7 @@ fn main() {
|
||||
build_local_project_index,
|
||||
create_local_project_checkpoint,
|
||||
export_local_project_package,
|
||||
list_local_project_export_packages,
|
||||
diff_local_project_checkpoint,
|
||||
restore_local_project_checkpoint,
|
||||
read_project_permission_policy,
|
||||
@@ -12566,6 +12659,59 @@ mod tests {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_export_package_list_only_returns_recent_playtest_zips() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||||
fs::write(root.join("exports/playtest-package-001.zip"), "old").expect("write old zip");
|
||||
fs::write(root.join("exports/not-playtest.zip"), "ignore").expect("write ignored zip");
|
||||
fs::write(root.join("exports/playtest-package-002.zip"), "newer").expect("write newer zip");
|
||||
fs::write(root.join("game/playtest-package-003.zip"), "wrong dir")
|
||||
.expect("write wrong dir zip");
|
||||
|
||||
let result = list_local_project_export_packages_at(&root).expect("list packages");
|
||||
|
||||
assert_eq!(result.project_path, root.to_string_lossy().into_owned());
|
||||
let paths = result
|
||||
.packages
|
||||
.iter()
|
||||
.map(|package| package.package_relative_path.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
"exports/playtest-package-002.zip",
|
||||
"exports/playtest-package-001.zip"
|
||||
]
|
||||
);
|
||||
assert_eq!(result.packages[0].total_bytes, 5);
|
||||
assert!(result.packages[0].modified_at > 0);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_project_export_package_list_skips_symlink_packages() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||||
fs::write(root.join("exports/playtest-package-real.zip"), "real").expect("write zip");
|
||||
std::os::unix::fs::symlink(
|
||||
root.join("memory/project.md"),
|
||||
root.join("exports/playtest-package-link.zip"),
|
||||
)
|
||||
.expect("create package symlink");
|
||||
|
||||
let result = list_local_project_export_packages_at(&root).expect("list packages");
|
||||
|
||||
assert_eq!(result.packages.len(), 1);
|
||||
assert_eq!(
|
||||
result.packages[0].package_relative_path,
|
||||
"exports/playtest-package-real.zip"
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_project_export_package_rejects_symlink_assets() {
|
||||
|
||||
@@ -300,6 +300,18 @@ interface LocalProjectExportPackageResult {
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
interface LocalProjectExportPackageSummary {
|
||||
packagePath: string;
|
||||
packageRelativePath: string;
|
||||
totalBytes: number;
|
||||
modifiedAt: number;
|
||||
}
|
||||
|
||||
interface LocalProjectExportPackagesResult {
|
||||
projectPath: string;
|
||||
packages: LocalProjectExportPackageSummary[];
|
||||
}
|
||||
|
||||
interface LocalProjectDiffResult {
|
||||
checkpointId: string;
|
||||
added: Array<{ path: string; status: string }>;
|
||||
@@ -1881,6 +1893,7 @@ const chatCommandHelp = [
|
||||
'/read 路径:读取本地项目内文本文件',
|
||||
'/run:运行自检,启动本地 HTTP 预览并交给外部浏览器',
|
||||
'/export:导出本地试玩包',
|
||||
'/exports:列出本地试玩包',
|
||||
'/preview:启动本地 HTTP 预览并交给外部浏览器',
|
||||
'/open-preview:打开当前本地预览',
|
||||
'/preview-status:查看预览状态',
|
||||
@@ -2245,6 +2258,7 @@ function summarizeNextProjectActions(
|
||||
preview?.status === 'running' && preview.url ? '/open-preview' : '/run',
|
||||
);
|
||||
addSuggestion('导出本地试玩包', '/export');
|
||||
addSuggestion('查看本地试玩包', '/exports');
|
||||
} else {
|
||||
addSuggestion('查看最近 loop 进展', '/trace');
|
||||
}
|
||||
@@ -2384,6 +2398,24 @@ function summarizeProjectExportPackage(
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeProjectExportPackages(
|
||||
result: LocalProjectExportPackagesResult,
|
||||
) {
|
||||
if (result.packages.length === 0) {
|
||||
return '本地试玩包:暂无。输入 /export 导出当前可试玩原型。';
|
||||
}
|
||||
const visiblePackages = result.packages.slice(0, 8);
|
||||
const lines = visiblePackages.map(
|
||||
(item) => `- ${item.packageRelativePath} · ${item.totalBytes}B`,
|
||||
);
|
||||
if (result.packages.length > visiblePackages.length) {
|
||||
lines.push(
|
||||
`- 还有 ${result.packages.length - visiblePackages.length} 个更早试玩包`,
|
||||
);
|
||||
}
|
||||
return `本地试玩包:\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
function checkpointIdFromManifestPath(path: string) {
|
||||
const match = path.match(/^\.agent\/checkpoints\/([^/]+)\/manifest\.json$/);
|
||||
return match?.[1] ?? null;
|
||||
@@ -3230,6 +3262,7 @@ function isProjectPolicyConfirmableCommandId(value: string) {
|
||||
'project.diff',
|
||||
'project.restore',
|
||||
'project.export_package',
|
||||
'project.export_list',
|
||||
'file.list',
|
||||
'file.read',
|
||||
'memory.read',
|
||||
@@ -3288,6 +3321,7 @@ export function needsInitializedChatProject(
|
||||
'file.read',
|
||||
'project.checkpoint',
|
||||
'project.export_package',
|
||||
'project.export_list',
|
||||
'project.restore',
|
||||
'project.policy_write',
|
||||
'preview.open',
|
||||
@@ -4483,7 +4517,9 @@ export function App() {
|
||||
commandId.startsWith('file.') ||
|
||||
commandId === 'agent.trace_read' ||
|
||||
commandId === 'project.index' ||
|
||||
commandId === 'project.diff'
|
||||
commandId === 'project.diff' ||
|
||||
commandId === 'project.export_package' ||
|
||||
commandId === 'project.export_list'
|
||||
) {
|
||||
setFileStatus(message);
|
||||
}
|
||||
@@ -5812,7 +5848,7 @@ export function App() {
|
||||
...current,
|
||||
{
|
||||
role: 'assistant',
|
||||
text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、agent.run_status、agent.kill、agent.retry、agent.resume、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
|
||||
text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、agent.run_status、agent.kill、agent.retry、agent.resume、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
|
||||
},
|
||||
]);
|
||||
return;
|
||||
@@ -6158,6 +6194,11 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prompt === '/exports') {
|
||||
void executeProjectExportPackages(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (prompt === '/preview') {
|
||||
if (!requireChatProjectForUserAction()) {
|
||||
return;
|
||||
@@ -7221,6 +7262,74 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function executeProjectExportPackages(
|
||||
announceToChat: boolean,
|
||||
skipPolicyConfirm = false,
|
||||
) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setFileStatus('需要在 Tauri App 内运行');
|
||||
if (announceToChat) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: '需要在 Tauri App 内运行。' },
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nextProjectPath = announceToChat
|
||||
? requireChatProjectForUserAction()
|
||||
: resolveChatProjectPath(localProject);
|
||||
if (!nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
announceToChat &&
|
||||
!skipPolicyConfirm &&
|
||||
(await queueProjectPolicyConfirmationIfNeeded(
|
||||
invoke,
|
||||
'project.export_list',
|
||||
nextProjectPath,
|
||||
`列出 ${nextProjectPath} 的本地试玩包`,
|
||||
'准备列出本地试玩包。',
|
||||
() => void executeProjectExportPackages(true, true),
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const result = await invoke<LocalProjectExportPackagesResult>(
|
||||
'list_local_project_export_packages',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
setFileStatus(`已列出 ${result.packages.length} 个本地试玩包`);
|
||||
setCommandLog((current) => [...current, 'project.export_list']);
|
||||
if (announceToChat) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
role: 'assistant',
|
||||
text: summarizeProjectExportPackages(result),
|
||||
draftCommand:
|
||||
result.packages.length > 0 ? '/open-project' : '/export',
|
||||
draftCommandLabel:
|
||||
result.packages.length > 0 ? '显示目录' : '导出试玩包',
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setFileStatus(message);
|
||||
if (announceToChat) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: message },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function executeProjectCheckpoints(
|
||||
announceToChat: boolean,
|
||||
skipListPolicyConfirm = false,
|
||||
|
||||
@@ -2020,6 +2020,25 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
totalBytes: 512,
|
||||
};
|
||||
}
|
||||
if (command === 'list_local_project_export_packages') {
|
||||
return {
|
||||
projectPath: String(args?.projectPath ?? ''),
|
||||
packages: [
|
||||
{
|
||||
packagePath: `${String(args?.projectPath ?? '')}/exports/playtest-package-002.zip`,
|
||||
packageRelativePath: 'exports/playtest-package-002.zip',
|
||||
totalBytes: 2048,
|
||||
modifiedAt: 1700000002000,
|
||||
},
|
||||
{
|
||||
packagePath: `${String(args?.projectPath ?? '')}/exports/playtest-package-001.zip`,
|
||||
packageRelativePath: 'exports/playtest-package-001.zip',
|
||||
totalBytes: 1024,
|
||||
modifiedAt: 1700000001000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
@@ -2637,6 +2656,28 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
|
||||
submitChat('/exports');
|
||||
expect(await screen.findByText(/本地试玩包:/)).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/exports\/playtest-package-002\.zip · 2048B/),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/exports\/playtest-package-001\.zip · 1024B/),
|
||||
).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('list_local_project_export_packages', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
});
|
||||
const exportListRevealButtons = screen.getAllByRole('button', {
|
||||
name: '显示目录',
|
||||
});
|
||||
fireEvent.click(
|
||||
exportListRevealButtons[exportListRevealButtons.length - 1],
|
||||
);
|
||||
expect(screen.getByLabelText('创作想法')).toHaveProperty(
|
||||
'value',
|
||||
'/open-project',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '启动预览' }));
|
||||
expect(await screen.findByText('preview.start')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
@@ -7873,6 +7914,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText(/\/checkpoint:保存本地项目快照/)).not.toBeNull();
|
||||
expect(screen.getByText(/\/export:导出本地试玩包/)).not.toBeNull();
|
||||
expect(screen.getByText(/\/exports:列出本地试玩包/)).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/\/checkpoints:列出最近 checkpoint/),
|
||||
).not.toBeNull();
|
||||
@@ -10437,6 +10479,28 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
submitChat('/policy-confirm project.export_list');
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/确认:project\.checkpoint、project\.restore、project\.export_list/,
|
||||
),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
policy: {
|
||||
deniedCommands: [],
|
||||
confirmCommands: [
|
||||
'project.checkpoint',
|
||||
'project.restore',
|
||||
'project.export_list',
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('allows canvas project commands to require project policy confirmation', async () => {
|
||||
@@ -12538,6 +12602,79 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('requires confirmation for export list when project policy asks for it', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'local-project-draft',
|
||||
'未命名游戏原型',
|
||||
);
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'init_local_game_project') {
|
||||
const projectPath = String(args?.projectPath ?? '');
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return {
|
||||
path: '.agent/policy.json',
|
||||
policy: {
|
||||
deniedCommands: [],
|
||||
confirmCommands: ['project.export_list'],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command === 'list_local_project_export_packages') {
|
||||
return {
|
||||
projectPath: String(args?.projectPath ?? ''),
|
||||
packages: [
|
||||
{
|
||||
packagePath: `${String(args?.projectPath ?? '')}/exports/playtest-package-001.zip`,
|
||||
packageRelativePath: 'exports/playtest-package-001.zip',
|
||||
totalBytes: 1024,
|
||||
modifiedAt: 1700000001000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderAppAt('/');
|
||||
|
||||
submitChat('/project /tmp/authorized-game');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
expect(
|
||||
await screen.findByText('已设置本地项目:/tmp/authorized-game'),
|
||||
).not.toBeNull();
|
||||
invoke.mockClear();
|
||||
|
||||
submitChat('/exports');
|
||||
|
||||
expect(await screen.findByText('准备列出本地试玩包。')).not.toBeNull();
|
||||
expect(screen.getByText('project.export_list')).not.toBeNull();
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'list_local_project_export_packages',
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
expect(await screen.findByText(/本地试玩包:/)).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/exports\/playtest-package-001\.zip · 1024B/),
|
||||
).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('list_local_project_export_packages', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the local project index from chat through the authorized project path', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'local-project-draft',
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
- 背景: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`。
|
||||
- 补充:新增 `/exports` 只读聊天入口和 `project.export_list` 自动命令,用于列出当前项目 `exports/playtest-package-*.zip` 历史试玩包;该入口只读、不删除旧包、不做系统分享,给用户继续 `/export` 或显示目录的草稿。
|
||||
- 影响范围:`apps/ai-game-creator-shell` 的聊天命令、Tauri 本地项目能力、共享命令契约和 AI 游戏创作 App 实施计划。
|
||||
- 验证方式:运行 AI 游戏创作壳主窗口 smoke、Tauri `export` 定向测试、共享契约测试、类型检查、编码检查和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
@@ -100,7 +100,7 @@ game-project/
|
||||
## v1 验收
|
||||
|
||||
- 用户能创建本地 Web 游戏项目。
|
||||
- 用户侧看到当前工作区项目名 / 路径、最近 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`。
|
||||
- 用户侧看到当前工作区项目名 / 路径、最近 run、预览状态、任务完成数 / ready 数、资产数量 / 来源分布、最近命令摘要、聊天框、上传入口、Agent 状态列表和单 Agent 对话;聊天输入 `/brief` 可在普通聊天消息里生成当前项目简报并提供 `/next` 草稿,不新增普通用户面板;聊天输入 `/risks` 可在普通聊天消息里查看当前项目风险并提供首个风险处理草稿,不新增普通用户面板;聊天输入 `/handoff` 可在普通聊天消息里生成当前项目交接摘要并提供 `/next` 草稿,不新增普通用户面板;聊天输入 `/runs` 可在普通聊天消息里列出已加载 Run 历史读取命令,不新增普通用户面板;聊天输入 `/run-files` 可在普通聊天消息里列出 Agent 运行辅助文件读取命令,不新增普通用户面板;聊天输入 `/export` 确认后把当前可试玩原型导出为本地试玩 ZIP,聊天输入 `/exports` 可只读列出已导出的本地试玩包;Agent 状态列表和单 Agent 对话在 `/llm-status` 后显示该 agent 当前 LLM provider / 模型 / 流式 / API Key 读取状态,但不显示密钥本体;最近项目资产入口显示本地路径、kind、mediaType 和来源类型,并可一键填入 `/read` 草稿,开发环境通过独立窗口查看任务拆分、专业组细节、产物、文件面板、嵌入预览以及 `.agent/logs/command.log` / `preview.log` / `agent.log`。
|
||||
- 生成代码和资产进入用户本地项目目录。
|
||||
- 本地 HTTP 预览能启动,并在外部浏览器展示可玩原型。
|
||||
- 美术/音乐资产能从画板链路回流到本地项目。
|
||||
@@ -117,6 +117,7 @@ game-project/
|
||||
- `/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`。
|
||||
- `/exports` 聊天入口由 `appSurface.test.ts` 主窗口 smoke、项目权限确认测试和 Tauri Rust 测试覆盖:只读执行 `project.export_list`,列出 `exports/playtest-package-*.zip`,跳过符号链接和非试玩包文件,不删除旧包、不做系统分享、不新增普通用户面板。
|
||||
- 主窗口策略快捷入口只填入 `/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 编译。
|
||||
@@ -184,7 +185,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`、`/export`、`/open-preview`、`/assets`、`/artifacts`、`/run-artifacts`、`/run-files`、`/logs`、`/agent-resume ` 等安全命令草稿方向,并提供一个首选草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件,用户必须再发送草稿并按原命令权限流继续。
|
||||
- 聊天输入 `/next` 只使用主窗口当前已加载的 manifest、最近 run trace 和最近命令摘要,在聊天里给出下一步建议,列出 `/tasks`、`/trace`、`/run`、`/export`、`/exports`、`/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,不新增状态数据库。
|
||||
@@ -196,6 +197,7 @@ game-project/
|
||||
- 聊天输入 `/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。
|
||||
- 聊天输入 `/exports` 会只读执行 `project.export_list`,列出当前项目 `exports/playtest-package-*.zip` 历史试玩包,并提供显示目录或继续 `/export` 的草稿;该命令不删除文件、不分享文件、不新增面板。
|
||||
- 聊天输入 `/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 为空或包含控制字符时在聊天侧直接拒绝。
|
||||
|
||||
@@ -41,6 +41,11 @@ describe('AI 游戏创作 App 共享契约', () => {
|
||||
(command) => command.id === 'project.export_package',
|
||||
)?.permission,
|
||||
).toBe('confirm');
|
||||
expect(
|
||||
GAME_CREATION_APP_COMMANDS.find(
|
||||
(command) => command.id === 'project.export_list',
|
||||
)?.permission,
|
||||
).toBe('auto');
|
||||
expect(
|
||||
GAME_CREATION_APP_COMMANDS.find(
|
||||
(command) => command.id === 'project.status',
|
||||
|
||||
@@ -21,6 +21,7 @@ export const GAME_CREATION_APP_COMMANDS = [
|
||||
{ id: 'project.diff', permission: 'auto' },
|
||||
{ id: 'project.restore', permission: 'confirm' },
|
||||
{ id: 'project.export_package', permission: 'confirm' },
|
||||
{ id: 'project.export_list', permission: 'auto' },
|
||||
{ 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; 43] = [
|
||||
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 44] = [
|
||||
command("help.show", GameCreationAppPermission::Auto),
|
||||
command("project.create", GameCreationAppPermission::Confirm),
|
||||
command("project.status", GameCreationAppPermission::Auto),
|
||||
@@ -30,6 +30,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 43] = [
|
||||
command("project.diff", GameCreationAppPermission::Auto),
|
||||
command("project.restore", GameCreationAppPermission::Confirm),
|
||||
command("project.export_package", GameCreationAppPermission::Confirm),
|
||||
command("project.export_list", GameCreationAppPermission::Auto),
|
||||
command("project.policy_read", GameCreationAppPermission::Auto),
|
||||
command("project.policy_write", GameCreationAppPermission::Confirm),
|
||||
command("task.list", GameCreationAppPermission::Auto),
|
||||
@@ -648,6 +649,12 @@ mod tests {
|
||||
GameCreationAppPermission::Confirm
|
||||
);
|
||||
|
||||
let export_list = GAME_CREATION_APP_COMMANDS
|
||||
.iter()
|
||||
.find(|command| command.id == "project.export_list")
|
||||
.expect("command should exist");
|
||||
assert_eq!(export_list.permission, GameCreationAppPermission::Auto);
|
||||
|
||||
let status = GAME_CREATION_APP_COMMANDS
|
||||
.iter()
|
||||
.find(|command| command.id == "project.status")
|
||||
|
||||
Reference in New Issue
Block a user