Compare commits

..

21 Commits

Author SHA1 Message Date
k88936 1d6e7057bc 移除识别绑定旧计算命名
清理请求错误、结果校验和日志中的旧计算及联合文案
2026-09-05 15:39:17 +08:00
k88936 6daff74c85 更新并简化 UI 识别与绑定系统提示 2026-09-05 15:22:26 +08:00
k88936 ad49056d36 更新 UI workflow 阶段决策记录
移除历史阶段描述并记录现行阶段与无迁移约束
2026-09-05 14:14:37 +08:00
k88936 28af87e3ad 统一 UI workflow 阶段并拒绝未知值
现行阶段原地使用并移除旧阶段 fallback 与迁移兼容
2026-09-05 14:11:00 +08:00
k88936 d994ccaa78 更正独立素材上限语义
将128限制恢复为独立素材数量且不计入页面参考图
2026-09-05 13:33:52 +08:00
k88936 42f1109b90 限制联合图片上下文总量
将页面参考图与独立素材的单次请求总数限制为128张
2026-09-05 13:30:03 +08:00
k88936 b772ec3efb 记录联合识别重跑与阶段排名语义
说明重复识别会生成新结果并明确 workflow_stage_rank 的后端持久化边界
2026-09-05 13:25:38 +08:00
k88936 d014cb1d06 限制联合请求独立素材总量
将单次识别绑定请求的独立素材数量限制为128个
2026-09-05 13:24:58 +08:00
k88936 e34ae89db3 移除废弃的独立识别路径
让联合识别绑定成为 UI 编辑器唯一实现并清理旧状态投影
2026-09-05 13:11:38 +08:00
k88936 88853d0a30 清理联合识别后的节点选择
替换生成新树后清除已失效的旧节点选中状态
2026-09-05 13:09:40 +08:00
k88936 fabc64df6c 避免工作流渲染校验重复克隆状态
直接复用联合结果写入后的 State 完成组件校验
2026-09-05 13:08:50 +08:00
k88936 27803a6e78 明确联合结果按图片返回树
让提示与每张输入图一棵树的校验契约一致
2026-09-05 13:07:27 +08:00
k88936 828b3e1173 消除联合请求初始化崩溃路径
将 provider client 与配置 unwrap 改为显式错误
2026-09-05 13:02:38 +08:00
k88936 deb60065a5 校验联合结果素材引用
拒绝不存在的图片素材组件引用

拒绝不存在的字体绑定引用
2026-09-05 12:58:49 +08:00
k88936 5c6a940b9c 稳定联合识别图片上下文顺序
页面角色优先作为 ROOT

其余界面图按 ID 排序保持请求可复现
2026-09-05 12:49:56 +08:00
k88936 8df5ebff69 简化联合工作流动作状态
移除旧识别状态回退与重复分支

统一联合步骤的运行中和完成态显示
2026-09-05 12:47:43 +08:00
k88936 041b51adc1 归一化UI编辑器完成态入口
将旧 visual-binding 初始步骤归一到联合识别绑定步骤

简化工作流步骤索引计算
2026-09-05 12:46:55 +08:00
k88936 5a31809e21 修正联合概览状态定位
结构问题高亮 layout_status

组件问题高亮 components_status
2026-09-05 12:44:26 +08:00
k88936 44bebae0a9 统一UI编辑器联合计算入口
工作流导航收敛为参考分析与一键识别绑定两步

联合概览同时展示结构与组件状态
2026-09-05 12:01:54 +08:00
k88936 62793ac445 正式合并UI识别与组件绑定
删除旧 recognition 与 binding 命令及前端 DTO

新增 recognition_and_binding 单次 workflow 与联合概览

移除 debug 超时特判并直达 binding-ready
2026-09-05 11:55:47 +08:00
k88936 f630c518f3 重构UI工作流联合识别绑定契约
更新 UI workflow 以 recognition_and_binding 单次计算为正式识别路径

保留 merge 能力但移出联合识别绑定流程

声明联合提示词 placeholder 与 binding-ready 直达阶段
2026-09-05 11:07:42 +08:00
729 changed files with 73539 additions and 120229 deletions
-1
View File
@@ -5,7 +5,6 @@
## 目录约定
- `.codex/skills/` 是项目专属 skill 根目录。每个 skill 以目录中的 `SKILL.md` 为入口,配套的参考资料和脚本放在同一目录下。
- `spec-driven-development` 负责跨模块、公开契约、SpacetimeDB、AGC/Runtime 和复杂 UI 任务的规范先行与逐里程碑验收;具体规则以 `docs/` 下的 SDD 工作流和模板为准。
- `.codex/plugins/` 保存随仓库分发的项目插件资源及其参考资料。当前的 `game-studio` 插件提供浏览器游戏设计、原型、2D/3D 技术栈、素材管线和 playtest 工作流;是否启用遵循当前 Codex 的插件加载机制,不依赖旧工具的环境变量或个人配置脚本。
- `.codex/hooks/``.codex/environments/` 等目录保存项目工具链所需的 hooks 和环境模板;它们不替代项目代码中的运行时配置。
- 长期有效的产品、架构、接口、排障和协作知识统一放在 `docs/``docs/project-memory/`,不复制到本目录。
@@ -1,43 +0,0 @@
---
name: spec-driven-development
description: 在 Genarrative 中处理跨模块功能、公开 API/DTO、SpacetimeDB schema、AGC/Runtime 或复杂 UI 状态链路时,按主规范、里程碑规范、单里程碑实现计划和验收证据推进;小型局部修复不触发。
license: MIT
metadata:
codex:
tags: [SDD, 规范驱动开发, 主规范, 里程碑, 验收]
---
# SDD 规范驱动开发
本 skill 把 Genarrative 的复杂任务路由到规范先行、单里程碑交付的工作流。权威规则和模板分别见:
- `docs/【协作规范】规范驱动开发工作流-2026-09-12.md`
- `docs/project-memory/shared-memory/【模板】规范驱动开发主规范与里程碑模板-2026-09-12.md`
## 触发条件
使用本 skill 的任务包括:
- 跨前端、`api-server``platform-*``spacetime-client``spacetime-module``module-*` 的功能。
- `/api/external/v1`、共享 DTO、OpenAPI、SpacetimeDB schema、迁移或跨版本重放合同变化。
- AGC / DirectProject、Agent Runtime、工具白名单、审批、持久化恢复或资源工作流变化。
- 复杂 UI 状态链路、入口/页面生命周期变化,或需要多个独立验收面的功能。
文案、单文件局部修复、无行为变化重构和一次性诊断继续使用轻量流程;执行中一旦触及公开行为或跨模块合同,切换到本 skill。
## 执行要求
1. 检查工作树,读取当前专题、源码、测试、契约和历史决策;写清交付结果、验收判据和不做项。
2. 找到或更新唯一主规范。主规范描述行为合同、边界、非目标、兼容/迁移和证据,不绑定实现细节。
3.`docs/project-memory/plans/` 创建一个或多个 `【里程碑】中文标题-YYYY-MM-DD.md`,填写 `Version``Status``Date``Parent Spec`、范围、依赖和验收标准;评审通过前不写业务代码。
4. 每次只选择一个已评审里程碑,创建对应的 `【实施计划】中文标题-YYYY-MM-DD.md`,明确修改边界、顺序、验证命令、风险和回滚点。
5. 发现行为需要变化时,严格按 `主规范 → 尚未实现的里程碑规范 → 当前实现计划 → 代码与测试` 更新。
6. 完成一个里程碑后,提供主规范/里程碑逐条对照、自动化验证、必要运行时 smoke、边界验证和未验证项;未验收不得推进下一个里程碑。
7. 全部验收通过后,将持久结论合并回主规范,删除已完成的临时计划,并检查文档、契约、测试和提交边界一致。
## 约束
- 里程碑和实现计划是开发期协调文件,完成或取消后删除;阶段编号和临时名称不得进入产品代码、用户文档、测试名称或提交标题。
- 活动计划可以提交以便团队同步,但不得把一次性实现步骤写进长期规范。
- 继续遵守现有 API、SpacetimeDB、AGC、编码和中文文档约束;本 skill 不替代专题门禁。
- 文档任务至少运行 `npm run check:doc-index``npm run check:encoding``git diff --check`;代码任务再运行范围匹配的测试、类型检查、schema/OpenAPI 或运行时 smoke。
-5
View File
@@ -8,11 +8,6 @@ LLM_BASE_URL="https://api.vectorengine.cn/v1"
# but it should not be relied on by browser code.
LLM_API_KEY=""
# Router account provisioning secret (server-side only). Prefer the protected
# file form in production; never expose either value to clients or commit it.
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET=""
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=""
# Optional frontend override for the local proxy path.
VITE_LLM_PROXY_BASE_URL="/api/llm"
-31
View File
@@ -232,37 +232,6 @@ jobs:
done
done
- name: Prepare standalone Rust crate dependencies
shell: bash
run: |
set -euo pipefail
# agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的
# exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们;
# 而 check:native-shells 会经 agent-runtime-*:check 用 `cargo test --manifest-path`
# 单独跑这两个 crate。不在这里预热的话,这两条测试会在测试阶段自己
# `Updating crates.io index`crates.io 一抖动整条 native shell 作业就红
# (见 #327 / PR #316 run 1950)。
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
# 解析,不再触碰 registry index。
for manifest_path in \
server-rs/crates/agent-runtime-core/Cargo.toml \
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest_path}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2
exit 1
fi
sleep $((attempt * 2))
done
done
- name: Run native shell gates
run: npm run check:native-shells
-2
View File
@@ -40,8 +40,6 @@ temp*build*/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-path/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json
/apps/ai-game-creator-shell/src-tauri/resources/plugins/
/plugins/agc-cocos-editor/native/payload/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
-1
View File
@@ -16,7 +16,6 @@
3. [`docs/project-memory/README.md`](docs/project-memory/README.md)、[`project-overview.md`](docs/project-memory/shared-memory/project-overview.md)、[`team-conventions.md`](docs/project-memory/shared-memory/team-conventions.md)、[`development-workflow.md`](docs/project-memory/shared-memory/development-workflow.md)。
4. 与任务相关的 [`decision-log.md`](docs/project-memory/shared-memory/decision-log.md)、[`pitfalls.md`](docs/project-memory/shared-memory/pitfalls.md)、[`docs/README.md`](docs/README.md) 和当前专题文档。
- 落地工程修改前,先确认是否已有足够具体的 PRD、技术方案或当前融合文档;文档仍存在编码级歧义时,先补文档再编码。
- 跨模块功能、公开 API/DTO、SpacetimeDB schema、AGC/Runtime 或复杂 UI 状态链路必须按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](docs/【协作规范】规范驱动开发工作流-2026-09-12.md) 先完成主规范、里程碑规范和单里程碑实现计划;评审与验收门禁未通过前不得进入下一里程碑。局部修复、文案和无行为变化重构继续走轻量流程。
- 本仓库的本地 RAG 位于 [`scripts/rag/`](scripts/rag/);RAG 只作为候选上下文,不替代打开源文件核对。默认不安装 RAG 运行时依赖,需要启用时必须先询问用户,并只安装到 gitignored 的 `.rag/runtime/`
## 绝对约束
-50
View File
@@ -26,8 +26,6 @@ import type {
AdminErrorReportDetail,
AdminErrorReportEntry,
AdminErrorReportListResponse,
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminLoginResponse,
AdminMeResponse,
@@ -251,16 +249,6 @@ export function getAdminDatabaseTableRows(
);
}
export function getAdminExternalApiKeys(
token: string,
query: AdminExternalApiKeyListQuery = {},
) {
return request<AdminExternalApiKeyListResponse>(
`/admin/api/external-api-keys${buildExternalApiKeyQuery(query)}`,
{ token },
);
}
export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
return request<AdminDebugHttpResponse>('/admin/api/debug/http', {
method: 'POST',
@@ -940,28 +928,6 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
return queryString ? `?${queryString}` : '';
}
function buildExternalApiKeyQuery(query: AdminExternalApiKeyListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'ownerUserId', query.ownerUserId);
appendQueryParam(params, 'publicUserCode', query.publicUserCode);
appendQueryParam(params, 'keyId', query.keyId);
appendQueryParam(params, 'name', query.name);
appendQueryParam(params, 'keyPrefix', query.keyPrefix);
appendQueryParam(params, 'createdAfter', query.createdAfter);
appendQueryParam(params, 'createdBefore', query.createdBefore);
appendQueryParam(params, 'status', query.status);
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(Math.floor(query.limit)));
}
if (typeof query.offset === 'number' && Number.isFinite(query.offset)) {
params.set('offset', String(Math.floor(query.offset)));
}
appendQueryParam(params, 'sortColumn', query.sortColumn);
appendQueryParam(params, 'sortDirection', query.sortDirection);
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function buildEditorAssetListQuery(query: AdminEditorAssetListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'cursor', query.cursor);
@@ -1072,19 +1038,3 @@ function buildAdminApiError(
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function getAgcModelCatalog(token: string) {
return request<import('./adminApiTypes').AdminAgcModelCatalog>(
'/admin/api/agc-models',
{ token },
);
}
export function saveAgcModelCatalog(
token: string,
body: import('./adminApiTypes').AdminAgcModelCatalog,
) {
return request<import('./adminApiTypes').AdminAgcModelCatalog>(
'/admin/api/agc-models',
{ token, method: 'PUT', body },
);
}
-57
View File
@@ -275,51 +275,6 @@ export interface AdminDatabaseTableStatPayload {
errorMessage: string | null;
}
export interface AdminExternalApiKeyListQuery {
ownerUserId?: string;
publicUserCode?: string;
keyId?: string;
name?: string;
keyPrefix?: string;
createdAfter?: string;
createdBefore?: string;
status?: 'active' | 'revoked';
limit?: number;
offset?: number;
sortColumn?:
| 'keyId'
| 'ownerUserId'
| 'name'
| 'keyPrefix'
| 'createdAt'
| 'lastUsedAt'
| 'updatedAt';
sortDirection?: 'asc' | 'desc';
}
export interface AdminExternalApiKeyPayload {
keyId: string;
ownerUserId: string;
name: string;
keyPrefix: string;
scopes: string[];
createdAt: string;
lastUsedAt: string | null;
revokedAt: string | null;
updatedAt: string;
status: 'active' | 'revoked';
}
export interface AdminExternalApiKeyListResponse {
keys: AdminExternalApiKeyPayload[];
total: number;
limit: number;
offset: number;
scannedCount: number;
scanLimit: number;
scanLimitReached: boolean;
}
export interface AdminDebugHeaderInput {
name: string;
value: string;
@@ -998,15 +953,3 @@ export interface AdminRechargeRefundActionResponse {
export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
export interface AdminAgcModel {
id: string;
alias: string;
modelId: string;
enabled: boolean;
}
export interface AdminAgcModelCatalog {
revision: number;
defaultModelId: string;
models: AdminAgcModel[];
}
-4
View File
@@ -18,7 +18,6 @@ import {
setStoredAdminToken,
} from '../auth/adminAuthStore';
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -290,9 +289,6 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'agc-models' ? (
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
-1
View File
@@ -50,7 +50,6 @@ const routeIcons = {
'editor-showcase': Star,
'editor-assets': Images,
accounts: Users,
'agc-models': ListChecks,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
+1 -6
View File
@@ -16,13 +16,9 @@ export type AdminRouteId =
| 'editor-generation-pricing'
| 'editor-showcase'
| 'editor-assets'
| 'agc-models'
| 'accounts';
export type AdminTabPermission = Exclude<
AdminRouteId,
'accounts' | 'agc-models'
>;
export type AdminTabPermission = Exclude<AdminRouteId, 'accounts'>;
/** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */
export interface AdminRouteDefinition {
@@ -51,7 +47,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
label: '模型定价',
hash: '#editor-generation-pricing',
},
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -1,56 +0,0 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient';
import { AdminAgcModelsPage } from './AdminAgcModelsPage';
vi.mock('../api/adminApiClient', () => ({
getAgcModelCatalog: vi.fn(),
saveAgcModelCatalog: vi.fn(),
isAdminApiError: vi.fn(() => false),
formatAdminApiError: vi.fn(() => '保存失败'),
}));
vi.mock('../components/useAdminWriteConfirm', () => ({
useAdminWriteConfirm: () => ({
confirmWrite: async () => true,
confirmDialog: null,
}),
}));
afterEach(cleanup);
test('edits alias and upstream model without changing the stable identifier or revision', async () => {
const catalog = {
revision: 3,
defaultModelId: 'quality',
models: [
{ id: 'quality', alias: '高质量', modelId: 'gpt-6-astra', enabled: true },
],
};
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
...input,
revision: 4,
}));
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
await screen.findByDisplayValue('gpt-6-astra');
fireEvent.change(screen.getByLabelText('模型 1 别名'), {
target: { value: '精细创作' },
});
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() =>
expect(saveAgcModelCatalog).toHaveBeenCalledWith('test', {
...catalog,
models: [{ ...catalog.models[0], alias: '精细创作' }],
}),
);
await waitFor(() => {
expect(screen.getAllByText('已保存').length).toBeGreaterThanOrEqual(2);
});
});
@@ -1,255 +0,0 @@
import { CircleHelp, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient';
import type { AdminAgcModel, AdminAgcModelCatalog } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
export function AdminAgcModelsPage({
token,
onUnauthorized,
}: {
token: string;
onUnauthorized: (message?: string) => void;
}) {
const [catalog, setCatalog] = useState<AdminAgcModelCatalog | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [saved, setSaved] = useState(false);
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
async function refresh() {
if (!token) return;
setBusy(true);
setError('');
setSaved(false);
try {
setCatalog(await getAgcModelCatalog(token));
} catch (error) {
handlePageError(error, onUnauthorized, setError);
} finally {
setBusy(false);
}
}
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
function update(id: string, patch: Partial<AdminAgcModel>) {
setSaved(false);
setCatalog(
(current) =>
current && {
...current,
models: current.models.map((model) =>
model.id === id ? { ...model, ...patch } : model,
),
},
);
}
async function save() {
if (!catalog || busy) return;
if (
!(await confirmWrite({
action: '保存 AGC 模型目录',
target: `${catalog.models.length} 个模型`,
}))
)
return;
setBusy(true);
setError('');
setSaved(false);
try {
setCatalog(await saveAgcModelCatalog(token, catalog));
setSaved(true);
} catch (error) {
handlePageError(error, onUnauthorized, setError);
} finally {
setBusy(false);
}
}
return (
<section className="admin-page admin-page-wide admin-agc-models">
<div className="admin-page-heading">
<div>
<h2>AGC </h2>
<p></p>
</div>
<span className="admin-agc-models-revision">
v{catalog?.revision ?? '-'}
</span>
</div>
<div className="admin-agc-models-summary">
<div>
<span></span>
<strong>
{catalog?.models.filter((model) => model.enabled).length ?? 0}
</strong>
</div>
<div>
<span></span>
<strong>
{catalog
? (catalog.models.find(
(model) => model.id === catalog.defaultModelId,
)?.alias ?? '未设置')
: '未设置'}
</strong>
</div>
<div>
<span></span>
<strong>{busy ? '处理中' : saved ? '已保存' : '待修改'}</strong>
</div>
</div>
<section className="admin-panel admin-agc-models-panel">
<div className="admin-panel-heading">
<div>
<h3></h3>
<span></span>
</div>
<CircleHelp size={17} aria-label="模型目录帮助" />
</div>
<div className="admin-agc-models-toolbar">
<button
type="button"
title="重新读取"
aria-label="重新读取模型"
disabled={busy}
onClick={() => void refresh()}
>
<RefreshCcw size={16} />
</button>
<button
type="button"
title="添加模型"
aria-label="添加模型"
disabled={busy || !catalog || catalog.models.length >= 32}
onClick={() => {
setSaved(false);
setCatalog(
(current) =>
current && {
...current,
models: [
...current.models,
{
id: crypto.randomUUID(),
alias: '',
modelId: '',
enabled: true,
},
],
},
);
}}
>
<Plus size={16} />
</button>
<button
type="button"
disabled={busy || !catalog}
onClick={() => void save()}
>
<Save size={16} />
</button>
</div>
{error ? <p role="alert">{error}</p> : null}
{saved ? <p role="status"></p> : null}
{busy ? <p role="status"></p> : null}
<div className="admin-table-wrap admin-agc-models-table">
<table className="admin-table admin-agc-models-table-grid">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{catalog?.models.map((model, index) => (
<tr key={model.id}>
<td>
<input
aria-label={`模型 ${index + 1} 别名`}
maxLength={40}
required
value={model.alias}
disabled={busy}
onChange={(e) =>
update(model.id, { alias: e.target.value })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 实际模型名`}
maxLength={200}
required
value={model.modelId}
disabled={busy}
onChange={(e) =>
update(model.id, { modelId: e.target.value })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 启用`}
type="checkbox"
checked={model.enabled}
disabled={busy || model.id === catalog.defaultModelId}
onChange={(e) =>
update(model.id, { enabled: e.target.checked })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 默认`}
name="agc-default-model"
type="radio"
checked={model.id === catalog.defaultModelId}
disabled={busy || !model.enabled}
onChange={() => {
setSaved(false);
setCatalog({ ...catalog, defaultModelId: model.id });
}}
/>
</td>
<td>
<button
type="button"
title="删除模型"
aria-label={`删除模型 ${index + 1}`}
disabled={busy || model.id === catalog.defaultModelId}
onClick={() => {
setSaved(false);
setCatalog({
...catalog,
models: catalog.models.filter(
(candidate) => candidate.id !== model.id,
),
});
}}
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{confirmDialog}
</section>
);
}
@@ -7,7 +7,6 @@ import { beforeEach, expect, test, vi } from 'vitest';
import {
getAdminDatabaseTableRows,
getAdminDatabaseTables,
getAdminExternalApiKeys,
} from '../api/adminApiClient';
import type { AdminDatabaseTableRowsResponse } from '../api/adminApiTypes';
import {
@@ -21,7 +20,6 @@ vi.mock('../api/adminApiClient', () => ({
),
getAdminDatabaseTableRows: vi.fn(),
getAdminDatabaseTables: vi.fn(),
getAdminExternalApiKeys: vi.fn(),
isAdminApiError: vi.fn(() => false),
}));
@@ -76,7 +74,6 @@ const referralRows = [
beforeEach(() => {
vi.clearAllMocks();
window.location.hash = '#tables?table=profile_referral_relation';
vi.mocked(getAdminExternalApiKeys).mockReset();
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
fetchErrors: [],
tables: ['profile_referral_relation'],
@@ -95,55 +92,6 @@ beforeEach(() => {
});
});
test('external_api_key 使用专用安全查询且详情不展示原始 JSON', async () => {
const user = userEvent.setup();
window.location.hash = '#tables?table=external_api_key';
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
fetchErrors: [],
tables: ['external_api_key'],
});
vi.mocked(getAdminExternalApiKeys).mockResolvedValue({
keys: [
{
keyId: 'external-api-key-1',
ownerUserId: 'user-1',
name: 'agc_auto_generate',
keyPrefix: 'tnr_sk_fixture',
scopes: ['llm:responses'],
createdAt: '2026-08-29T00:00:00Z',
lastUsedAt: null,
revokedAt: null,
updatedAt: '2026-08-29T00:00:00Z',
status: 'active',
},
],
total: 1,
limit: 100,
offset: 0,
scannedCount: 1,
scanLimit: 5000,
scanLimitReached: false,
});
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await user.type(screen.getByPlaceholderText('精确 ownerUserId'), 'user-1');
await user.click(screen.getByRole('button', { name: '安全查询' }));
await waitFor(() => {
expect(getAdminExternalApiKeys).toHaveBeenLastCalledWith(
'admin-token',
expect.objectContaining({ ownerUserId: 'user-1' }),
);
});
expect(await screen.findByText('tnr_sk_fixture')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '详情' }));
expect(screen.getByRole('dialog')).toBeTruthy();
expect(screen.queryByText('复制 JSON')).toBeNull();
expect(screen.queryByText('key_hash')).toBeNull();
});
test('后台表查询页通过页面级固定栏翻页并提示扫描结果可能不完整', async () => {
const user = userEvent.setup();
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
File diff suppressed because it is too large Load Diff
+2 -137
View File
@@ -3093,140 +3093,5 @@ button:disabled {
background: var(--admin-surface, #fff);
}
.admin-detail-modal__panel header,
.admin-detail-modal__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.admin-detail-modal__panel pre {
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
background: #f8fafc;
padding: 12px;
border-radius: 8px;
}
.admin-agc-models {
min-width: 0;
}
.admin-agc-models-revision {
padding: 6px 10px;
border: 1px solid #e7d9cc;
border-radius: 999px;
background: #fffaf6;
color: #9a8170;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.admin-agc-models-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.admin-agc-models-summary > div {
display: grid;
gap: 6px;
padding: 16px 18px;
border: 1px solid #eadfd6;
border-radius: 10px;
background: #fffdfa;
}
.admin-agc-models-summary span,
.admin-agc-models-panel > .admin-panel-heading span {
color: #9a8170;
font-size: 12px;
}
.admin-agc-models-summary strong {
overflow: hidden;
color: #3d2a20;
font-size: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-agc-models-panel {
border: 1px solid #eadfd6;
border-radius: 10px;
background: #fffdfa;
box-shadow: 0 10px 30px rgb(78 48 28 / 6%);
}
.admin-agc-models-panel > .admin-panel-heading > div {
display: grid;
gap: 4px;
}
.admin-agc-models-panel > .admin-panel-heading > svg {
color: #b9947a;
}
.admin-agc-models-toolbar {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 4px 0 8px;
}
.admin-agc-models-toolbar button,
.admin-agc-models-table-grid button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 34px;
padding: 0 11px;
border: 1px solid #e2d3c7;
border-radius: 8px;
background: #fffaf6;
color: #684d3d;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.admin-agc-models-toolbar button:hover,
.admin-agc-models-toolbar button:focus-visible,
.admin-agc-models-table-grid button:hover,
.admin-agc-models-table-grid button:focus-visible {
border-color: #c99d80;
background: #fff;
outline: none;
}
.admin-agc-models-toolbar button:last-child {
border-color: #a96442;
background: #a96442;
color: #fff;
}
.admin-agc-models-table-grid {
min-width: 720px;
}
.admin-agc-models-table-grid th {
padding-top: 12px;
padding-bottom: 12px;
background: #fcf7f2;
}
.admin-agc-models-table-grid td {
padding-top: 14px;
padding-bottom: 14px;
}
.admin-agc-models-table-grid td input:not([type]) {
width: 100%;
min-width: 180px;
box-sizing: border-box;
padding: 9px 10px;
border: 1px solid #e1d3c8;
border-radius: 7px;
background: #fff;
color: #3d2a20;
}
.admin-agc-models-table-grid td input:not([type]):focus-visible {
border-color: #b97854;
outline: none;
box-shadow: 0 0 0 3px rgb(185 120 84 / 14%);
}
.admin-agc-models-table-grid td:has(input[type='checkbox']),
.admin-agc-models-table-grid td:has(input[type='radio']) {
width: 72px;
text-align: center;
vertical-align: middle;
}
@media (max-width: 680px) {
.admin-agc-models-summary {
grid-template-columns: 1fr;
}
}
.admin-detail-modal__actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.admin-detail-modal__panel pre { max-height: 360px; overflow: auto; white-space: pre-wrap; background: #f8fafc; padding: 12px; border-radius: 8px; }
-4
View File
@@ -1,4 +0,0 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
@@ -1,14 +1,13 @@
{
"schemaVersion": "game-creator-config.v2",
"agentMode": "codex_app_server",
"llm": {
"apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra",
"model": "gpt-5.6-sol",
"apiKind": "openai_responses",
"reasoningEffort": "max",
"stream": true,
"webSearchEnabled": true,
"webSearchEnabled": false,
"contextWindowTokens": 128000,
"autoCompactTokenLimit": 64000,
"toolOutputTokenLimit": 12000,
@@ -16,5 +15,8 @@
"maxRetries": 2,
"retryBackoffMs": 500
},
"agentLlm": {}
"agentLlm": {},
"planning": {
"capabilityEnabled": true
}
}
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
"version": "0.1.27",
"version": "0.1.12",
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.mjs",
@@ -19,6 +19,8 @@
"config": "node scripts/game-creator-config-wizard.mjs",
"test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open",
"test:chat:manual": "node scripts/agent-swarm-test-chat.mjs",
"test:plan": "node scripts/agent-swarm-test-chat.mjs --plan --task \"我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。\"",
"test:plan:manual": "node scripts/agent-swarm-test-chat.mjs --plan",
"agent-run": "node scripts/run-cli-with-config.mjs --agent-run",
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
"agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs",
@@ -51,7 +53,6 @@
"focus-trap-react": "^12.0.3",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"phaser": "^4.2.1",
"react": "^19.0.0",
"react-arborist": "^3.16.0",
"react-colorful": "^5.8.0",
@@ -1948,7 +1948,6 @@ async function runE2e(options) {
...process.env,
NO_COLOR: '1',
[platformSessionFixtureEnv]: fixturePath,
GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1',
}),
);
childReport = parseChildReport(childResult);
@@ -773,8 +773,7 @@ export async function prepareIsolatedSuiteAppData({
isScopedAgentsSuite() ||
isProjectSkillSuite() ||
isParallelReadSuite() ||
isSupervisorSwarmSuite() ||
isSupervisorAutonomousPlayableLaneDefenseSuite()
isSupervisorSwarmSuite()
? 'private-copy'
: 'hardlink';
try {
@@ -797,7 +796,7 @@ export async function prepareIsolatedSuiteAppData({
storageMode === 'private-copy' &&
(linkedMetadata.dev !== source.metadata.dev ||
linkedMetadata.ino !== source.metadata.ino) &&
(process.platform === 'win32' || (linkedMetadata.mode & 0o077) === 0);
(linkedMetadata.mode & 0o077) === 0;
const hardlinkValid =
storageMode === 'hardlink' &&
linkedMetadata.dev === source.metadata.dev &&
@@ -1977,7 +1976,7 @@ export async function verifyIsolatedSuiteConfigLinksUnchanged() {
linkedMetadata.ino === link.linkedIno &&
(linkedMetadata.dev !== sourceMetadata.dev ||
linkedMetadata.ino !== sourceMetadata.ino) &&
(process.platform === 'win32' || (linkedMetadata.mode & 0o077) === 0)
(linkedMetadata.mode & 0o077) === 0
: linkedMetadata.dev === link.dev && linkedMetadata.ino === link.ino;
const sourceMetadataStable =
sourceMetadata.mode === link.sourceMode &&
@@ -36,6 +36,8 @@ export const ungeneratedGameEntryMarker =
'还没有生成游戏。回到聊天输入创意并确认生成后';
export const defaultRealSwarmTestTask =
'制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。';
export const defaultRealSwarmPlanTask =
'我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。';
export const swarmTurnReportPrefix = '[turn.report] ';
export const swarmTurnReportSchema = 'game-creator-swarm-turn-report.v1';
@@ -138,10 +140,15 @@ export const usage = `用法:
--keep-project 保留自动创建的一次性项目
--no-open 手工模式启动预览但不自动打开浏览器
--task <需求> 通过 manual 入口非交互提交自定义需求
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,手工模式默认不限时
--plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时
--dry-run 只检查目录发现和项目准备,不启动 LLM
-h, --help 显示帮助
`;
环境变量:
AGC_PLAN_GDD_DECISION 审批卡自动应答动作,默认 approverevise/reject 必须
同时用 AGC_PLAN_GDD_COMMENT 给出真实修改意见
AGC_PLAN_GDD_COMMENT revise/reject 的意见原文`;
function readOptionValue(args, index, option) {
const value = args[index + 1]?.trim();
@@ -170,6 +177,7 @@ export function parseSwarmTestArguments(args) {
keepProject: false,
openBrowser: true,
task: null,
plan: false,
timeoutMinutes: null,
dryRun: false,
help: false,
@@ -194,6 +202,8 @@ export function parseSwarmTestArguments(args) {
if (task.length > 4_000) throw new Error('--task 不能超过 4000 字符');
options.task = task;
index += 1;
} else if (argument === '--plan') {
options.plan = true;
} else if (argument === '--timeout-minutes') {
if (options.timeoutMinutes !== null) {
throw new Error('--timeout-minutes 只能指定一次');
@@ -212,11 +222,16 @@ export function parseSwarmTestArguments(args) {
}
export function shouldStartPersistentPreview(options) {
return !options.task;
// 立项策划链路只出 GDD,没有可试玩产物,任何模式都不该起预览。
return !options.task && !options.plan;
}
export function resolveSwarmTestTimeoutMs(options) {
const minutes = options.timeoutMinutes ?? (options.task ? 50 : null);
// 立项策划的设计目标是五分钟出方案,给一分钟余量;再久就是卡住了,早失败
// 比让 harness 空等更有用。做游戏那条链路的 50 分钟不变。
const planMinutes = options.plan ? 6 : null;
const minutes =
options.timeoutMinutes ?? planMinutes ?? (options.task ? 50 : null);
return minutes === null ? null : minutes * 60_000;
}
@@ -371,15 +386,7 @@ async function runMissingConfigWizard(setActiveChild, explicitConfigDir) {
}
}
async function secureWindowsPrivateRuntimePath(
targetPath,
options,
secureWindowsPath = null,
) {
if (secureWindowsPath) {
await secureWindowsPath(targetPath, options);
return;
}
async function secureWindowsPrivateRuntimePath(targetPath, options) {
const { secureWindowsGameCreatorPathForCurrentUser } = await import(
'./game-creator-config-wizard.mjs'
);
@@ -391,7 +398,6 @@ async function copyPrivateRuntimeConfigEntry(
runtimeConfigDir,
fileName,
required,
secureWindowsPath = null,
) {
const sourcePath = path.join(sourceConfigDir, fileName);
const sourceMetadata = await lstat(sourcePath).catch((error) => {
@@ -411,11 +417,9 @@ async function copyPrivateRuntimeConfigEntry(
const sourceBytes = await readFile(sourcePath);
const destinationFile = await open(destinationPath, 'wx', 0o600);
try {
await secureWindowsPrivateRuntimePath(
destinationPath,
{ isDirectory: false },
secureWindowsPath,
);
await secureWindowsPrivateRuntimePath(destinationPath, {
isDirectory: false,
});
await destinationFile.writeFile(sourceBytes);
await destinationFile.sync();
} finally {
@@ -448,11 +452,7 @@ async function copyPrivateRuntimeConfigEntry(
return true;
}
export async function prepareSwarmTestRuntimeConfig(
sourceConfigDir,
tempRoot,
{ secureWindowsPath = null } = {},
) {
export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) {
if (!path.isAbsolute(sourceConfigDir)) {
throw new Error('配置来源目录必须是绝对路径');
}
@@ -472,11 +472,9 @@ export async function prepareSwarmTestRuntimeConfig(
);
try {
if (process.platform === 'win32') {
await secureWindowsPrivateRuntimePath(
runtimeConfigDir,
{ isDirectory: true },
secureWindowsPath,
);
await secureWindowsPrivateRuntimePath(runtimeConfigDir, {
isDirectory: true,
});
} else {
await chmod(runtimeConfigDir, 0o700);
}
@@ -494,14 +492,12 @@ export async function prepareSwarmTestRuntimeConfig(
runtimeConfigDir,
configFileName,
true,
secureWindowsPath,
);
await copyPrivateRuntimeConfigEntry(
canonicalSourceConfigDir,
runtimeConfigDir,
localConfigFileName,
false,
secureWindowsPath,
);
return {
path: await realpath(runtimeConfigDir),
@@ -983,12 +979,25 @@ export function swarmAutoPilotShouldCloseInput(output, promptsAfterSubmit) {
return swarmAutoPilotSitsAtPrompt(output) && promptsAfterSubmit >= 1;
}
// GDD 审批位不能等 CLI 退出之后再处理:Run 停在这里时状态是 waiting-for-user-input
// 而 swarm CLI 恰好把这个状态算作「本轮还在跑」,turn 永远不 settleCLI 也就永远
// 不退出。所以审批必须在 CLI 还活着的时候并发做完,让 Run 自己继续跑到收束。
// 这一句是 PlanGddCompletionBlockerKind::AwaitingApprovalDecision 专有的投影文案,
// 另外三个 blocked 子状态都不会打出它;即便认错了,真正的判据也是随后那次
// --plan-gdd-status,没有待决定审批时不会有任何写入。
const planGddApprovalWaitPattern = /等待 Fast GDD 审批决定/u;
export function swarmOutputAwaitsPlanGddApproval(line) {
return planGddApprovalWaitPattern.test(line);
}
async function runTaskCargo(
cliArguments,
task,
setActiveChild,
timeoutMs,
autoPilot = false,
onPlanGddApprovalWait = null,
) {
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
stdio: ['pipe', 'pipe', 'inherit'],
@@ -1000,6 +1009,25 @@ async function runTaskCargo(
let taskSubmitted = false;
let promptsSeen = 0;
let sittingAtPrompt = false;
let planGddApproval = null;
let planGddApprovalError = null;
let planGddApprovalStarted = false;
let planGddApprovalPromise = null;
const startPlanGddApproval = () => {
planGddApprovalStarted = true;
console.log(
`[自动审批] 检测到 Fast GDD 审批位,正在提交 ${resolvePlanGddAutoDecision().action}`,
);
planGddApprovalPromise = onPlanGddApprovalWait()
.then((value) => {
planGddApproval = value;
})
.catch((error) => {
planGddApprovalError = error;
// 审批没成的话 Run 会一直停在等待位,干等到超时只会把真正的原因埋掉。
void terminateChildTree(child).catch(() => {});
});
};
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
process.stdout.write(chunk);
@@ -1012,6 +1040,13 @@ async function runTaskCargo(
reportLines.push(normalizedLine);
settled = true;
}
if (
onPlanGddApprovalWait &&
!planGddApprovalStarted &&
swarmOutputAwaitsPlanGddApproval(normalizedLine)
) {
startPlanGddApproval();
}
}
if (!autoPilot || child.stdin.writableEnded) return;
const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine);
@@ -1050,9 +1085,12 @@ async function runTaskCargo(
if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) {
reportLines.push(normalizedPendingLine);
}
await planGddApprovalPromise;
if (planGddApprovalError) throw planGddApprovalError;
return {
...result,
turnReportOutput: reportLines.join('\n'),
planGddApproval,
};
} finally {
setActiveChild(null);
@@ -1742,6 +1780,196 @@ export async function validateSwarmProjectArtifacts(projectPath, options) {
return inspection;
}
// 这四条路径的权威定义都在 Rust 侧 `planning_storage.rs``PLAN_SESSION_PATH`、
// `PLAN_GDD_INDEX_PATH`、`PLAN_STORAGE_ROOT`、`PLAN_FAST_GDD_PATH`)。跨语言没有共享
// 常量的通道,改路径时要连同 `GddApprovalCard.tsx` 一起动。
export const planningOutputPaths = [
'.agent/planning/session.json',
'.agent/planning/index.json',
'.agent/planning/pending.json',
'game/fast_gdd.md',
];
export async function inspectPlanningOutputs(projectPath) {
const outputs = [];
for (const relativePath of planningOutputPaths) {
const absolutePath = path.join(projectPath, ...relativePath.split('/'));
const metadata = await lstat(absolutePath).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
outputs.push({
path: relativePath,
exists: Boolean(metadata?.isFile()),
bytes: metadata?.isFile() ? metadata.size : 0,
});
}
return outputs;
}
async function reportPlanningOutputs(projectPath) {
const outputs = await inspectPlanningOutputs(projectPath);
console.log('\n立项策划产物:');
for (const output of outputs) {
console.log(
output.exists
? ` [有] ${output.path}${output.bytes} 字节)`
: ` [无] ${output.path}`,
);
}
}
const planGddApprovalTimeoutMs = 60_000;
export const planGddStatusOutputPrefix = 'planGddStateJson=';
export const planGddDecisionOutputPrefix = 'planGddDecisionJson=';
function parsePrefixedJsonLine(output, prefix, label) {
const line = output
.split('\n')
.map((value) => (value.endsWith('\r') ? value.slice(0, -1) : value))
.find((value) => value.startsWith(prefix));
if (!line) throw new Error(`${label}缺少 ${prefix} 输出`);
try {
return JSON.parse(line.slice(prefix.length));
} catch (error) {
throw new Error(`解析${label}失败:${error.message}`);
}
}
export function parsePlanGddStatusOutput(output) {
return parsePrefixedJsonLine(
output,
planGddStatusOutputPrefix,
'Fast GDD 审批状态',
);
}
export function parsePlanGddDecisionOutput(output) {
return parsePrefixedJsonLine(
output,
planGddDecisionOutputPrefix,
'Fast GDD 审批回执',
);
}
// 审批卡是这条链路唯一的人类判据,所以自动应答默认只投 approve,且只在投影确实有
// 一张待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把
// 判据换成噪声——所以那两条分支只在跑的人自己用 AGC_PLAN_GDD_COMMENT 给出意见时
// 才走。手工调 --plan-gdd-decide 也能达到同样效果,但那要求 plan 根 run 仍然活着,
// 而它恰好是本进程持有的 CLI 子进程。
export function planGddAutoApprovalIsPending(state) {
return Boolean(state?.pendingApproval);
}
export function resolvePlanGddAutoDecision(env = process.env) {
const action = (env.AGC_PLAN_GDD_DECISION ?? 'approve').trim();
if (!['approve', 'revise', 'reject'].includes(action)) {
throw new Error('AGC_PLAN_GDD_DECISION 只能是 approve / revise / reject');
}
const comment = (env.AGC_PLAN_GDD_COMMENT ?? '').trim();
if (action === 'approve') return { action, comment: null };
if (!comment) {
throw new Error(
`${action} 必须同时设 AGC_PLAN_GDD_COMMENT 提供真实修改意见`,
);
}
return { action, comment };
}
async function settlePlanGddApproval(
projectPath,
runtimeConfigPath,
setActiveChild,
) {
const readStatus = async () => {
const result = await runCapturedCargo(
['--config-dir', runtimeConfigPath, '--plan-gdd-status', projectPath],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批状态查询',
},
);
if (result.code !== 0 || result.signal) {
throw new Error(
`读取 Fast GDD 审批状态失败:${result.stderr.trim() || result.stdout.trim()}`,
);
}
return parsePlanGddStatusOutput(result.stdout);
};
const before = await readStatus();
if (!planGddAutoApprovalIsPending(before)) {
return { decided: false, state: before };
}
const { action, comment } = resolvePlanGddAutoDecision();
const decision = await runCapturedCargo(
[
'--config-dir',
runtimeConfigPath,
'--plan-gdd-decide',
projectPath,
action,
...(comment === null ? [] : ['--stdin']),
],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批决定',
stdin: comment,
},
);
if (decision.code !== 0 || decision.signal) {
throw new Error(
`提交 Fast GDD 审批决定失败:${decision.stderr.trim() || decision.stdout.trim()}`,
);
}
const receipt = parsePlanGddDecisionOutput(decision.stdout);
// 回执落盘和唤醒后台任务是两件事:decide 命令把唤醒失败降级成 recoveryPending
// 于是审批已经生效、Run 却仍停在 waiting-for-user-input。实测就是这样——只有
// 补一次 --agent-resume 才会重新起 turn。这是仓库自己给这个状态定义的恢复动作。
let recovered = false;
if (receipt.recoveryPending) {
const resume = await runCapturedCargo(
['--config-dir', runtimeConfigPath, '--agent-resume', projectPath],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批后恢复后台任务',
},
);
if (resume.code !== 0 || resume.signal) {
throw new Error(
`审批已提交但恢复后台任务失败:${resume.stderr.trim() || resume.stdout.trim()}`,
);
}
recovered = true;
}
return { decided: true, receipt, recovered, state: await readStatus() };
}
async function reportPlanGddApproval(approval) {
const { state } = approval;
console.log('\nFast GDD 审批:');
if (!approval.decided) {
console.log(` [无待决定审批] 当前投影状态=${state.state}`);
return;
}
console.log(
` [已决定 ${approval.receipt.decisionRef.action}] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`,
);
if (approval.recovered) {
console.log(
' [已恢复] 审批回执的 recoveryPending 由一次 --agent-resume 收口',
);
}
if (state.session) {
console.log(
` 澄清轮次=${state.session.clarificationRound} 返工深度=${state.session.repairDepth} phase=${state.session.phase}`,
);
}
}
export async function hasConfiguredEditorApiKey(configDir) {
let configured = false;
for (const fileName of [configFileName, localConfigFileName]) {
@@ -1894,7 +2122,13 @@ export async function runSwarmTestChat(options) {
const setActiveChild = (child) => {
activeChild = child;
};
// GDD 审批要和 swarm CLI 并发跑,两者不能共用 activeChild 这一个槽位:审批子进程
// 结束时的 setActiveChild(null) 会把 CLI 从槽里抹掉,Ctrl-C 就杀不到它了。
const concurrentChildren = new Set();
const setConcurrentChild = (child) => {
if (child) concurrentChildren.add(child);
else concurrentChildren.clear();
};
const stopRequested = () => receivedSignal !== null;
const handleSignal = (signal) => {
const repeatedSignal = receivedSignal !== null;
@@ -1972,10 +2206,11 @@ export async function runSwarmTestChat(options) {
);
}
console.log('LLM 配置已就绪。');
const requirementNoun = options.plan ? '立项策划需求' : '游戏需求';
console.log(
options.task
? '已提交一条非交互游戏需求,正在等待 Swarm 自主完成。\n'
: '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n',
? `已提交一条非交互${requirementNoun},正在等待 Swarm 自主完成。\n`
: `输入一条${requirementNoun}并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n`,
);
phase = 'chat';
@@ -1985,7 +2220,8 @@ export async function runSwarmTestChat(options) {
runtimeConfig.path,
'--swarm-chat',
'--init',
'--autonomous-game-build',
// 做方案链路只能跑 standard 档,后端对 plan + autonomous 是硬否决。
options.plan ? '--plan' : '--autonomous-game-build',
project.path,
];
let chat;
@@ -1998,6 +2234,15 @@ export async function runSwarmTestChat(options) {
timeoutDeadline === null
? null
: Math.max(1, timeoutDeadline - Date.now()),
options.plan,
options.plan
? () =>
settlePlanGddApproval(
project.path,
runtimeConfig.path,
setConcurrentChild,
)
: null,
)
: await runInteractiveCargo(chatArguments, setActiveChild);
} catch (error) {
@@ -2013,6 +2258,32 @@ export async function runSwarmTestChat(options) {
if (options.task) {
turnReport = parseSettledSwarmTurnReport(chat.turnReportOutput);
}
if (options.plan) {
// 立项策划不出游戏产物,正式验收在 GDD 审批卡上;这里只报告落盘情况,
// 是否收束已经由 CLI 的退出码判过了。
// 自动任务档的审批已经在 CLI 运行期间并发做完了;手工档(人自己敲 Ctrl+D
// 退出)没有那次触发,退出后补一次,没有待决定审批时它是只读的。
phase = 'plan-approval';
const approval =
chat.planGddApproval ??
(await settlePlanGddApproval(
project.path,
runtimeConfig.path,
setConcurrentChild,
));
if (receivedSignal) break session;
phase = 'plan-report';
await reportPlanGddApproval(approval);
await reportPlanningOutputs(project.path);
phase = 'complete';
console.log(
approval.decided
? '\n立项策划链路已收束:Fast GDD 已批准,策划产物见上方清单。'
: '\n立项策划链路已收束:Run 正常结束但没有待决定审批,策划产物见上方清单。',
);
break session;
}
phase = 'artifact-validation';
const requireEditorImages = await hasConfiguredEditorApiKey(
runtimeConfig.path,
);
@@ -4,11 +4,6 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
const releaseTarget =
@@ -164,30 +159,10 @@ export async function prepareReleaseVersion() {
return nextVersion;
}
export function buildTauriBuildArguments(
args = [],
target = releaseTarget,
platform = process.platform,
) {
const noBundle = args.includes('--no-bundle');
const targetIndex = args.indexOf('--target');
const explicitTarget =
targetIndex >= 0
? args[targetIndex + 1]
: args
.find((value) => value.startsWith('--target='))
?.slice('--target='.length);
const targetArgs = noBundle || explicitTarget ? [] : ['--target', target];
const features = defaultEditorFeatures(
explicitTarget || (noBundle ? platform : target),
);
return [
'build',
...withDefaultCargoFeatures([...targetArgs, ...args], features),
];
}
export function runTauriBuild(args = []) {
const noBundle = args.includes('--no-bundle');
const hasTarget = args.includes('--target');
const targetArgs = noBundle || hasTarget ? [] : ['--target', releaseTarget];
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawnSync(
npmCommand,
@@ -197,7 +172,9 @@ export function runTauriBuild(args = []) {
'exec',
'tauri',
'--',
...buildTauriBuildArguments(args),
'build',
...targetArgs,
...args,
],
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
);
@@ -1,24 +0,0 @@
/** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */
export function withDefaultCargoFeatures(argv, features) {
const separator = argv.indexOf('--');
const cargoArgs = separator < 0 ? argv : argv.slice(0, separator);
if (
!features.length ||
cargoArgs.some(
(value) =>
value === '--features' ||
value === '-f' ||
value.startsWith('--features=') ||
/^-f.+/u.test(value),
)
) {
return argv;
}
return [`--features=${features.join(',')}`, ...argv];
}
export function defaultEditorFeatures(target) {
return target === 'win32' || target.includes('windows')
? ['cocos-editor-execute']
: [];
}
@@ -1,49 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildTauriBuildArguments } from './build-release.mjs';
import { withDefaultCargoFeatures } from './cargo-features.mjs';
test('Windows release includes the same editor feature as development', () => {
assert.deepEqual(
buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'),
[
'build',
'--features=cocos-editor-execute',
'--target',
'x86_64-pc-windows-msvc',
],
);
assert.deepEqual(
buildTauriBuildArguments(
['--no-bundle'],
'x86_64-pc-windows-msvc',
'linux',
),
['build', '--no-bundle'],
);
assert.deepEqual(
buildTauriBuildArguments(['--target=aarch64-apple-darwin']),
['build', '--target=aarch64-apple-darwin'],
);
});
test('explicit Cargo features override defaults in every supported spelling', () => {
for (const args of [
['--features', 'custom'],
['--features=custom'],
['-f', 'custom'],
['-fcustom'],
]) {
assert.deepEqual(
withDefaultCargoFeatures(args, ['cocos-editor-execute']),
args,
);
}
assert.deepEqual(
withDefaultCargoFeatures(
['--', '--features=app'],
['cocos-editor-execute'],
),
['--features=cocos-editor-execute', '--', '--features=app'],
);
});

Some files were not shown because too many files have changed in this diff Show More