补充本地试玩包列表入口

新增 /exports 聊天命令和试玩包列表摘要

新增 Tauri 试玩包列表命令和安全过滤测试

同步共享命令契约、技术方案和决策记录
This commit is contained in:
AIGameCreator App
2026-07-03 15:40:07 +08:00
parent f3d9ae38d0
commit 88279cea3d
8 changed files with 413 additions and 5 deletions
@@ -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() {
+111 -2
View File
@@ -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")