Compare commits

..

22 Commits

Author SHA1 Message Date
kdletters 9b1e06b50a Merge branch 'master' into fix/codex-session-management
Project CI / Frontend tests (pull_request) Successful in 3m41s
Project CI / Backend tests (pull_request) Successful in 8m6s
Project CI / Repository checks (pull_request) Successful in 3m22s
Project CI / Native shell tests (pull_request) Successful in 21m49s
2026-09-03 19:30:13 +08:00
k88936 0b6efd98fe Merge branch 'master' into fix/codex-session-management
Project CI / Backend tests (pull_request) Successful in 7m57s
Project CI / Native shell tests (pull_request) Successful in 21m28s
Project CI / Repository checks (pull_request) Successful in 2m41s
Project CI / Frontend tests (pull_request) Successful in 2m57s
2026-09-03 16:08:17 +08:00
k88936 41b660bdc4 Merge branch 'master' into fix/codex-session-management
Project CI / Backend tests (pull_request) Successful in 7m28s
Project CI / Repository checks (pull_request) Failing after 1m8s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Failing after 18m11s
2026-09-03 13:31:41 +08:00
k88936 da1759322c 修复 DirectProject 回合上下文持久化
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m19s
Project CI / Native shell tests (pull_request) Has been cancelled
回合开始前幂等持久化规范化 user prompt
附件回合统一由 Rust 写入规范化历史,避免原始消息冲突
补充重启后多轮 user/assistant 历史恢复测试
同步 DirectProject replay 文档合同
2026-09-03 13:21:27 +08:00
k88936 ff1f77f88a Merge remote-tracking branch 'origin/master' into fix/codex-session-management
Project CI / Backend tests (pull_request) Successful in 6m8s
Project CI / Repository checks (pull_request) Successful in 2m55s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Successful in 17m5s
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
2026-09-03 11:51:28 +08:00
k88936 e73d04db00 补充 Direct replay 有界历史设计
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 2m45s
Project CI / Native shell tests (pull_request) Successful in 19m23s
记录不可变 project.jsonl 与滑动窗口恢复约定

说明 system 标记、预算边界和超限失败语义
2026-09-03 11:44:38 +08:00
k88936 8a679d7011 实现 Direct replay 有界滑动窗口
DirectProject replay 按上下文预算保留最新连续历史

超预算时仅派生裁剪 prompt,不修改 project.jsonl

补充超大历史与当前请求超限测试
2026-09-03 11:44:29 +08:00
k88936 456762f569 Merge remote-tracking branch 'origin/fix/codex-session-management' into fix/codex-session-management 2026-09-03 10:44:36 +08:00
k88936 48a2723527 支持自定义Direct历史根路径 2026-09-03 10:42:29 +08:00
k88936 1132fe5325 Merge branch 'master' into fix/codex-session-management
Project CI / Repository checks (pull_request) Successful in 2m42s
Project CI / Frontend tests (pull_request) Successful in 3m11s
Project CI / Backend tests (pull_request) Successful in 5m58s
Project CI / Native shell tests (pull_request) Successful in 17m9s
2026-09-02 20:21:20 +08:00
k88936 ea3c34beef Merge remote-tracking branch 'origin/fix/codex-session-management' into fix/codex-session-management
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 3m0s
Project CI / Native shell tests (pull_request) Successful in 18m39s
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
2026-09-02 20:16:30 +08:00
k88936 877724d7bc 保持Direct观察器调用接口稳定
保留原有run_turn_with_direct_observer参数形状。

将历史回放参数收口到内部专用入口。
2026-09-02 20:14:53 +08:00
k88936 ae8d51500b 记录Direct回放有意设计取舍
在partial持久化处说明普通assistant与中断标记语义。

在技术方案和决策日志记录全量回放与简单前缀的设计原因。
2026-09-02 20:12:56 +08:00
k88936 8ed2f42b52 原子化Direct线程与历史回放判定
让thread_for在同一锁内返回线程是否新建。

仅新建Direct线程时读取project.jsonl并注入历史。

保持已有线程只发送当前用户消息。
2026-09-02 20:12:41 +08:00
k88936 300ea76780 Merge branch 'master' into fix/codex-session-management
Project CI / Repository checks (pull_request) Successful in 2m26s
Project CI / Frontend tests (pull_request) Successful in 3m8s
Project CI / Backend tests (pull_request) Successful in 7m12s
Project CI / Native shell tests (pull_request) Failing after 12m58s
2026-09-02 20:03:19 +08:00
k88936 b136a7c346 删除Direct打开项目自动重发测试
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
移除已废弃的hydrated Direct回合自动恢复场景。

保留当前新发送与历史回放语义的测试覆盖。
2026-09-02 19:58:00 +08:00
k88936 b1a9a296e8 恢复Direct失败态临时回复清理
失败事件到达时清空界面中的临时回复。

保留ref中的partial供中断持久化流程使用。
2026-09-02 19:58:00 +08:00
k88936 75875a0fad 删除失效的Direct重试单测
移除已删除helper的测试导入。

删除已废弃的user-only自动重发用例。
2026-09-02 19:58:00 +08:00
k88936 1b12b7b98e 限制Direct历史回放到新线程
保留进程内thread的正常连续对话
补充中断恢复与线程重建文档
2026-09-02 19:58:00 +08:00
k88936 bb51c2716b 修正Direct聊天中断上下文恢复
删除打开项目自动重发旧消息
持久化partial并在新消息前回放完整历史
2026-09-02 19:58:00 +08:00
k88936 9e8b7d880e 让Direct Codex请求携带项目聊天历史
读取project.jsonl完整user assistant tool记录
新请求覆盖当前消息并保留ephemeral线程
2026-09-02 19:57:59 +08:00
k88936 609efe805c 补充DirectProject会话恢复约定
明确ephemeral线程与AGC聊天事实源
记录partial中断标记和取消自动重发
2026-09-02 17:47:32 +08:00
426 changed files with 38999 additions and 43590 deletions
-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. # but it should not be relied on by browser code.
LLM_API_KEY="" 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. # Optional frontend override for the local proxy path.
VITE_LLM_PROXY_BASE_URL="/api/llm" VITE_LLM_PROXY_BASE_URL="/api/llm"
-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-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-resources/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json /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/logs/
/apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json /apps/ai-game-creator-shell/game-creator.config.local.json
-50
View File
@@ -26,8 +26,6 @@ import type {
AdminErrorReportDetail, AdminErrorReportDetail,
AdminErrorReportEntry, AdminErrorReportEntry,
AdminErrorReportListResponse, AdminErrorReportListResponse,
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse, AdminFeatureGateConfigResponse,
AdminLoginResponse, AdminLoginResponse,
AdminMeResponse, 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) { export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
return request<AdminDebugHttpResponse>('/admin/api/debug/http', { return request<AdminDebugHttpResponse>('/admin/api/debug/http', {
method: 'POST', method: 'POST',
@@ -940,28 +928,6 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
return queryString ? `?${queryString}` : ''; 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) { function buildEditorAssetListQuery(query: AdminEditorAssetListQuery) {
const params = new URLSearchParams(); const params = new URLSearchParams();
appendQueryParam(params, 'cursor', query.cursor); appendQueryParam(params, 'cursor', query.cursor);
@@ -1072,19 +1038,3 @@ function buildAdminApiError(
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); 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; 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 { export interface AdminDebugHeaderInput {
name: string; name: string;
value: string; value: string;
@@ -998,15 +953,3 @@ export interface AdminRechargeRefundActionResponse {
export interface AdminWalletRestrictionResponse { export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload; 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, setStoredAdminToken,
} from '../auth/adminAuthStore'; } from '../auth/adminAuthStore';
import { AdminAccountsPage } from '../pages/AdminAccountsPage'; import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage'; import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage'; import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage'; import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -290,9 +289,6 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized} onUnauthorized={handleUnauthorized}
/> />
) : null} ) : null}
{activeRouteId === 'agc-models' ? (
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{activeRouteId === 'editor-showcase' ? ( {activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage <AdminEditorShowcaseReviewPage
token={token} token={token}
-1
View File
@@ -50,7 +50,6 @@ const routeIcons = {
'editor-showcase': Star, 'editor-showcase': Star,
'editor-assets': Images, 'editor-assets': Images,
accounts: Users, accounts: Users,
'agc-models': ListChecks,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>; } satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({ export function AdminShell({
+1 -6
View File
@@ -16,13 +16,9 @@ export type AdminRouteId =
| 'editor-generation-pricing' | 'editor-generation-pricing'
| 'editor-showcase' | 'editor-showcase'
| 'editor-assets' | 'editor-assets'
| 'agc-models'
| 'accounts'; | 'accounts';
export type AdminTabPermission = Exclude< export type AdminTabPermission = Exclude<AdminRouteId, 'accounts'>;
AdminRouteId,
'accounts' | 'agc-models'
>;
/** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */ /** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */
export interface AdminRouteDefinition { export interface AdminRouteDefinition {
@@ -51,7 +47,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
label: '模型定价', label: '模型定价',
hash: '#editor-generation-pricing', hash: '#editor-generation-pricing',
}, },
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, { 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 { import {
getAdminDatabaseTableRows, getAdminDatabaseTableRows,
getAdminDatabaseTables, getAdminDatabaseTables,
getAdminExternalApiKeys,
} from '../api/adminApiClient'; } from '../api/adminApiClient';
import type { AdminDatabaseTableRowsResponse } from '../api/adminApiTypes'; import type { AdminDatabaseTableRowsResponse } from '../api/adminApiTypes';
import { import {
@@ -21,7 +20,6 @@ vi.mock('../api/adminApiClient', () => ({
), ),
getAdminDatabaseTableRows: vi.fn(), getAdminDatabaseTableRows: vi.fn(),
getAdminDatabaseTables: vi.fn(), getAdminDatabaseTables: vi.fn(),
getAdminExternalApiKeys: vi.fn(),
isAdminApiError: vi.fn(() => false), isAdminApiError: vi.fn(() => false),
})); }));
@@ -76,7 +74,6 @@ const referralRows = [
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
window.location.hash = '#tables?table=profile_referral_relation'; window.location.hash = '#tables?table=profile_referral_relation';
vi.mocked(getAdminExternalApiKeys).mockReset();
vi.mocked(getAdminDatabaseTables).mockResolvedValue({ vi.mocked(getAdminDatabaseTables).mockResolvedValue({
fetchErrors: [], fetchErrors: [],
tables: ['profile_referral_relation'], 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 () => { test('后台表查询页通过页面级固定栏翻页并提示扫描结果可能不完整', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({ 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); background: var(--admin-surface, #fff);
} }
.admin-detail-modal__panel header, .admin-detail-modal__panel header,
.admin-detail-modal__actions { .admin-detail-modal__actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
display: flex; .admin-detail-modal__panel pre { max-height: 360px; overflow: auto; white-space: pre-wrap; background: #f8fafc; padding: 12px; border-radius: 8px; }
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;
}
}
-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", "agentMode": "codex_app_server",
"llm": { "llm": {
"apiKey": "", "apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1", "baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra", "model": "gpt-5.6-sol",
"apiKind": "openai_responses", "apiKind": "openai_responses",
"reasoningEffort": "max", "reasoningEffort": "max",
"stream": true, "stream": true,
"webSearchEnabled": true, "webSearchEnabled": false,
"contextWindowTokens": 128000, "contextWindowTokens": 128000,
"autoCompactTokenLimit": 64000, "autoCompactTokenLimit": 64000,
"toolOutputTokenLimit": 12000, "toolOutputTokenLimit": 12000,
@@ -16,5 +15,8 @@
"maxRetries": 2, "maxRetries": 2,
"retryBackoffMs": 500 "retryBackoffMs": 500
}, },
"agentLlm": {} "agentLlm": {},
"planning": {
"capabilityEnabled": true
}
} }
+2 -1
View File
@@ -19,6 +19,8 @@
"config": "node scripts/game-creator-config-wizard.mjs", "config": "node scripts/game-creator-config-wizard.mjs",
"test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open", "test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open",
"test:chat:manual": "node scripts/agent-swarm-test-chat.mjs", "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": "node scripts/run-cli-with-config.mjs --agent-run",
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs", "agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
"agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs", "agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs",
@@ -51,7 +53,6 @@
"focus-trap-react": "^12.0.3", "focus-trap-react": "^12.0.3",
"lexical": "^0.47.0", "lexical": "^0.47.0",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"phaser": "^4.2.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-arborist": "^3.16.0", "react-arborist": "^3.16.0",
"react-colorful": "^5.8.0", "react-colorful": "^5.8.0",
@@ -1948,7 +1948,6 @@ async function runE2e(options) {
...process.env, ...process.env,
NO_COLOR: '1', NO_COLOR: '1',
[platformSessionFixtureEnv]: fixturePath, [platformSessionFixtureEnv]: fixturePath,
GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1',
}), }),
); );
childReport = parseChildReport(childResult); childReport = parseChildReport(childResult);
@@ -773,8 +773,7 @@ export async function prepareIsolatedSuiteAppData({
isScopedAgentsSuite() || isScopedAgentsSuite() ||
isProjectSkillSuite() || isProjectSkillSuite() ||
isParallelReadSuite() || isParallelReadSuite() ||
isSupervisorSwarmSuite() || isSupervisorSwarmSuite()
isSupervisorAutonomousPlayableLaneDefenseSuite()
? 'private-copy' ? 'private-copy'
: 'hardlink'; : 'hardlink';
try { try {
@@ -797,7 +796,7 @@ export async function prepareIsolatedSuiteAppData({
storageMode === 'private-copy' && storageMode === 'private-copy' &&
(linkedMetadata.dev !== source.metadata.dev || (linkedMetadata.dev !== source.metadata.dev ||
linkedMetadata.ino !== source.metadata.ino) && linkedMetadata.ino !== source.metadata.ino) &&
(process.platform === 'win32' || (linkedMetadata.mode & 0o077) === 0); (linkedMetadata.mode & 0o077) === 0;
const hardlinkValid = const hardlinkValid =
storageMode === 'hardlink' && storageMode === 'hardlink' &&
linkedMetadata.dev === source.metadata.dev && linkedMetadata.dev === source.metadata.dev &&
@@ -1977,7 +1976,7 @@ export async function verifyIsolatedSuiteConfigLinksUnchanged() {
linkedMetadata.ino === link.linkedIno && linkedMetadata.ino === link.linkedIno &&
(linkedMetadata.dev !== sourceMetadata.dev || (linkedMetadata.dev !== sourceMetadata.dev ||
linkedMetadata.ino !== sourceMetadata.ino) && linkedMetadata.ino !== sourceMetadata.ino) &&
(process.platform === 'win32' || (linkedMetadata.mode & 0o077) === 0) (linkedMetadata.mode & 0o077) === 0
: linkedMetadata.dev === link.dev && linkedMetadata.ino === link.ino; : linkedMetadata.dev === link.dev && linkedMetadata.ino === link.ino;
const sourceMetadataStable = const sourceMetadataStable =
sourceMetadata.mode === link.sourceMode && sourceMetadata.mode === link.sourceMode &&
@@ -36,6 +36,8 @@ export const ungeneratedGameEntryMarker =
'还没有生成游戏。回到聊天输入创意并确认生成后'; '还没有生成游戏。回到聊天输入创意并确认生成后';
export const defaultRealSwarmTestTask = export const defaultRealSwarmTestTask =
'制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。'; '制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。';
export const defaultRealSwarmPlanTask =
'我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。';
export const swarmTurnReportPrefix = '[turn.report] '; export const swarmTurnReportPrefix = '[turn.report] ';
export const swarmTurnReportSchema = 'game-creator-swarm-turn-report.v1'; export const swarmTurnReportSchema = 'game-creator-swarm-turn-report.v1';
@@ -138,10 +140,15 @@ export const usage = `用法:
--keep-project 保留自动创建的一次性项目 --keep-project 保留自动创建的一次性项目
--no-open 手工模式启动预览但不自动打开浏览器 --no-open 手工模式启动预览但不自动打开浏览器
--task <需求> 通过 manual 入口非交互提交自定义需求 --task <需求> 通过 manual 入口非交互提交自定义需求
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,手工模式默认不限时 --plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时
--dry-run 只检查目录发现和项目准备,不启动 LLM --dry-run 只检查目录发现和项目准备,不启动 LLM
-h, --help 显示帮助 -h, --help 显示帮助
`;
环境变量:
AGC_PLAN_GDD_DECISION 审批卡自动应答动作,默认 approverevise/reject 必须
同时用 AGC_PLAN_GDD_COMMENT 给出真实修改意见
AGC_PLAN_GDD_COMMENT revise/reject 的意见原文`;
function readOptionValue(args, index, option) { function readOptionValue(args, index, option) {
const value = args[index + 1]?.trim(); const value = args[index + 1]?.trim();
@@ -170,6 +177,7 @@ export function parseSwarmTestArguments(args) {
keepProject: false, keepProject: false,
openBrowser: true, openBrowser: true,
task: null, task: null,
plan: false,
timeoutMinutes: null, timeoutMinutes: null,
dryRun: false, dryRun: false,
help: false, help: false,
@@ -194,6 +202,8 @@ export function parseSwarmTestArguments(args) {
if (task.length > 4_000) throw new Error('--task 不能超过 4000 字符'); if (task.length > 4_000) throw new Error('--task 不能超过 4000 字符');
options.task = task; options.task = task;
index += 1; index += 1;
} else if (argument === '--plan') {
options.plan = true;
} else if (argument === '--timeout-minutes') { } else if (argument === '--timeout-minutes') {
if (options.timeoutMinutes !== null) { if (options.timeoutMinutes !== null) {
throw new Error('--timeout-minutes 只能指定一次'); throw new Error('--timeout-minutes 只能指定一次');
@@ -212,11 +222,16 @@ export function parseSwarmTestArguments(args) {
} }
export function shouldStartPersistentPreview(options) { export function shouldStartPersistentPreview(options) {
return !options.task; // 立项策划链路只出 GDD,没有可试玩产物,任何模式都不该起预览。
return !options.task && !options.plan;
} }
export function resolveSwarmTestTimeoutMs(options) { 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; return minutes === null ? null : minutes * 60_000;
} }
@@ -964,12 +979,25 @@ export function swarmAutoPilotShouldCloseInput(output, promptsAfterSubmit) {
return swarmAutoPilotSitsAtPrompt(output) && promptsAfterSubmit >= 1; 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( async function runTaskCargo(
cliArguments, cliArguments,
task, task,
setActiveChild, setActiveChild,
timeoutMs, timeoutMs,
autoPilot = false, autoPilot = false,
onPlanGddApprovalWait = null,
) { ) {
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), { const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
stdio: ['pipe', 'pipe', 'inherit'], stdio: ['pipe', 'pipe', 'inherit'],
@@ -981,6 +1009,25 @@ async function runTaskCargo(
let taskSubmitted = false; let taskSubmitted = false;
let promptsSeen = 0; let promptsSeen = 0;
let sittingAtPrompt = false; 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.setEncoding('utf8');
child.stdout.on('data', (chunk) => { child.stdout.on('data', (chunk) => {
process.stdout.write(chunk); process.stdout.write(chunk);
@@ -993,6 +1040,13 @@ async function runTaskCargo(
reportLines.push(normalizedLine); reportLines.push(normalizedLine);
settled = true; settled = true;
} }
if (
onPlanGddApprovalWait &&
!planGddApprovalStarted &&
swarmOutputAwaitsPlanGddApproval(normalizedLine)
) {
startPlanGddApproval();
}
} }
if (!autoPilot || child.stdin.writableEnded) return; if (!autoPilot || child.stdin.writableEnded) return;
const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine); const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine);
@@ -1031,9 +1085,12 @@ async function runTaskCargo(
if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) { if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) {
reportLines.push(normalizedPendingLine); reportLines.push(normalizedPendingLine);
} }
await planGddApprovalPromise;
if (planGddApprovalError) throw planGddApprovalError;
return { return {
...result, ...result,
turnReportOutput: reportLines.join('\n'), turnReportOutput: reportLines.join('\n'),
planGddApproval,
}; };
} finally { } finally {
setActiveChild(null); setActiveChild(null);
@@ -1723,6 +1780,196 @@ export async function validateSwarmProjectArtifacts(projectPath, options) {
return inspection; 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) { export async function hasConfiguredEditorApiKey(configDir) {
let configured = false; let configured = false;
for (const fileName of [configFileName, localConfigFileName]) { for (const fileName of [configFileName, localConfigFileName]) {
@@ -1875,7 +2122,13 @@ export async function runSwarmTestChat(options) {
const setActiveChild = (child) => { const setActiveChild = (child) => {
activeChild = child; activeChild = child;
}; };
// GDD 审批要和 swarm CLI 并发跑,两者不能共用 activeChild 这一个槽位:审批子进程
// 结束时的 setActiveChild(null) 会把 CLI 从槽里抹掉,Ctrl-C 就杀不到它了。
const concurrentChildren = new Set(); const concurrentChildren = new Set();
const setConcurrentChild = (child) => {
if (child) concurrentChildren.add(child);
else concurrentChildren.clear();
};
const stopRequested = () => receivedSignal !== null; const stopRequested = () => receivedSignal !== null;
const handleSignal = (signal) => { const handleSignal = (signal) => {
const repeatedSignal = receivedSignal !== null; const repeatedSignal = receivedSignal !== null;
@@ -1953,10 +2206,11 @@ export async function runSwarmTestChat(options) {
); );
} }
console.log('LLM 配置已就绪。'); console.log('LLM 配置已就绪。');
const requirementNoun = options.plan ? '立项策划需求' : '游戏需求';
console.log( console.log(
options.task options.task
? '已提交一条非交互游戏需求,正在等待 Swarm 自主完成。\n' ? `已提交一条非交互${requirementNoun},正在等待 Swarm 自主完成。\n`
: '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n', : `输入一条${requirementNoun}并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n`,
); );
phase = 'chat'; phase = 'chat';
@@ -1966,7 +2220,8 @@ export async function runSwarmTestChat(options) {
runtimeConfig.path, runtimeConfig.path,
'--swarm-chat', '--swarm-chat',
'--init', '--init',
'--autonomous-game-build', // 做方案链路只能跑 standard 档,后端对 plan + autonomous 是硬否决。
options.plan ? '--plan' : '--autonomous-game-build',
project.path, project.path,
]; ];
let chat; let chat;
@@ -1979,6 +2234,15 @@ export async function runSwarmTestChat(options) {
timeoutDeadline === null timeoutDeadline === null
? null ? null
: Math.max(1, timeoutDeadline - Date.now()), : Math.max(1, timeoutDeadline - Date.now()),
options.plan,
options.plan
? () =>
settlePlanGddApproval(
project.path,
runtimeConfig.path,
setConcurrentChild,
)
: null,
) )
: await runInteractiveCargo(chatArguments, setActiveChild); : await runInteractiveCargo(chatArguments, setActiveChild);
} catch (error) { } catch (error) {
@@ -1994,6 +2258,32 @@ export async function runSwarmTestChat(options) {
if (options.task) { if (options.task) {
turnReport = parseSettledSwarmTurnReport(chat.turnReportOutput); 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( const requireEditorImages = await hasConfiguredEditorApiKey(
runtimeConfig.path, runtimeConfig.path,
); );
@@ -107,26 +107,12 @@ const rustSharedContractSource = fs.readFileSync(
'utf8', 'utf8',
); );
const allowedUncalledTauriCommands = [ const allowedUncalledTauriCommands = [
'append_direct_project_conversation_message',
'chat_with_game_creator_agent', 'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage', 'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource', 'create_ui_design_resource',
'open_game_creator_launcher_window', 'open_game_creator_launcher_window',
'open_game_creator_workspace_window', 'open_game_creator_workspace_window',
'read_direct_project_conversation',
'stop_local_game_preview_if_matches', 'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
'stop_game_creator_external_mcp',
'list_agc_plugins',
'list_agc_extensions',
'refresh_agc_plugins',
'start_agc_plugin',
'stop_agc_plugin',
'reload_agc_plugin',
'call_agc_plugin',
'read_agc_plugin_panel',
'set_agc_plugin_project_path',
'set_agc_plugin_enabled',
]; ];
const sourceExtensions = new Set([ const sourceExtensions = new Set([
'.json', '.json',
@@ -1295,7 +1281,7 @@ if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
throw new Error('AI game creator shell identifier drifted'); throw new Error('AI game creator shell identifier drifted');
} }
const expectedBundledWindowsResources = { const expectedBundledCodexResources = {
'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe', 'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe',
'resources/codex/win-x64/bin/codex-code-mode-host.exe': 'resources/codex/win-x64/bin/codex-code-mode-host.exe':
'codex/win-x64/bin/codex-code-mode-host.exe', 'codex/win-x64/bin/codex-code-mode-host.exe',
@@ -1309,7 +1295,6 @@ const expectedBundledWindowsResources = {
'codex/win-x64/codex-package.json', 'codex/win-x64/codex-package.json',
'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md', 'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md',
'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json', 'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json',
'resources/plugins': 'plugins',
}; };
if (tauriConfig.bundle?.resources !== undefined) { if (tauriConfig.bundle?.resources !== undefined) {
throw new Error( throw new Error(
@@ -1318,8 +1303,8 @@ if (tauriConfig.bundle?.resources !== undefined) {
} }
assert.deepEqual( assert.deepEqual(
windowsTauriConfig.bundle?.resources, windowsTauriConfig.bundle?.resources,
expectedBundledWindowsResources, expectedBundledCodexResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set and Cocos bridge payload directory', 'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set',
); );
if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) { if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
throw new Error( throw new Error(
@@ -1716,16 +1701,10 @@ if (
runtimeConfigSetupStart === -1 || runtimeConfigSetupStart === -1 ||
runtimeConfigSetupEnd === -1 || runtimeConfigSetupEnd === -1 ||
!runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') || !runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') ||
!runtimeConfigSetupSource.includes('setup_log.fail(') || !runtimeConfigSetupSource.includes('append_bounded_diagnostic_line(') ||
!runtimeConfigSetupSource.includes( !runtimeConfigSetupSource.includes(
'startup.appdata.configure.failed details={details}', 'startup.appdata.configure.failed details={details}',
) || )
!tauriHandlerSource.includes('impl StartupLogSlot {') ||
!tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') ||
!tauriHandlerSource.includes(
'self.append(line);\n show_startup_error_dialog(self.path().as_deref());',
) ||
!tauriHandlerSource.includes('early_startup_log_path(')
) { ) {
throw new Error( throw new Error(
'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures', 'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures',
@@ -1774,8 +1753,9 @@ for (const snippet of [
"'read_game_creator_app_config'", "'read_game_creator_app_config'",
"'write_game_creator_app_config'", "'write_game_creator_app_config'",
'aria-label="运行时配置"', 'aria-label="运行时配置"',
'陶泥儿智能创作(固定)', 'LLM API Key',
'官方账号服务(固定)', 'External Editor Base URL',
'External Editor API Key',
'runtime_config.save', 'runtime_config.save',
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'", "'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
"'activate_local_game_preview'", "'activate_local_game_preview'",
@@ -26,7 +26,6 @@ const repositoryRoot = path.resolve(appRoot, '..', '..');
const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml'); const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml');
const defaultConfigPath = path.join(appRoot, configFileName); const defaultConfigPath = path.join(appRoot, configFileName);
const localConfigFileName = 'game-creator.config.local.json'; const localConfigFileName = 'game-creator.config.local.json';
const gameCreatorConfigSchemaVersion = 'game-creator-config.v2';
const cargoCommand = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; const cargoCommand = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
@@ -212,7 +211,6 @@ export function buildGameCreatorWizardConfig(existingConfig, llmInput) {
} }
return { return {
...source, ...source,
schemaVersion: gameCreatorConfigSchemaVersion,
agentMode: 'provider', agentMode: 'provider',
llm: { llm: {
...previousLlm, ...previousLlm,
@@ -7,7 +7,6 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url)); const appRoot = fileURLToPath(new URL('..', import.meta.url));
const inheritedChildEnvironment = { ...globalThis['process']['env'] };
const localConfigPath = path.join(appRoot, 'game-creator.config.local.json'); const localConfigPath = path.join(appRoot, 'game-creator.config.local.json');
const projectRoot = path.join( const projectRoot = path.join(
os.tmpdir(), os.tmpdir(),
@@ -672,11 +671,6 @@ function runAgent() {
{ {
cwd: appRoot, cwd: appRoot,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env: {
...inheritedChildEnvironment,
// 该 smoke 只使用一次性 loopback Provider;生产路由仍保持锁定。
GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1',
},
}, },
); );
let stdout = ''; let stdout = '';
@@ -722,7 +716,7 @@ function runAgent() {
stderr += chunk.toString(); stderr += chunk.toString();
}); });
child.on('error', reject); child.on('error', reject);
child.on('close', (code, signal) => { child.on('close', (code) => {
const output = `${stdout}${stderr}`; const output = `${stdout}${stderr}`;
if (previewReadError) { if (previewReadError) {
reject(previewReadError); reject(previewReadError);
@@ -740,24 +734,13 @@ function runAgent() {
previewDom, previewDom,
}); });
} else { } else {
reject( reject(new Error(output || `agent run exited with ${code}`));
new Error(
`agent run failed: exitCode=${code}, signal=${signal ?? 'none'}\n` +
`stderr tail (last 8000 characters):\n${stderr.slice(-8000)}\n` +
`stdout tail (last 4000 characters):\n${stdout.slice(-4000)}`,
),
);
} }
}); });
}); });
} }
async function seedLocalAsset() { async function seedLocalAsset() {
await fs.mkdir(path.join(projectRoot, 'game'), { recursive: true });
await fs.writeFile(
path.join(projectRoot, 'game/index.html'),
'<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><body>还没有生成游戏</body></html>',
);
await fs.mkdir(path.join(projectRoot, 'assets/uploads'), { recursive: true }); await fs.mkdir(path.join(projectRoot, 'assets/uploads'), { recursive: true });
await fs.mkdir(path.join(projectRoot, '.agent'), { recursive: true }); await fs.mkdir(path.join(projectRoot, '.agent'), { recursive: true });
await seedConversationContext(); await seedConversationContext();
@@ -1,16 +1,10 @@
import { spawn, spawnSync } from 'node:child_process'; import { spawn } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync } from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import net from 'node:net'; import net from 'node:net';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import {
normalizeWindowsPath,
parseWindowsProcessSnapshot,
stopWindowsProcessTree,
stopWindowsWorktreeProcesses,
} from '../../../scripts/dev-windows-process.mjs';
import { import {
agcVitePortEnvKey, agcVitePortEnvKey,
readAgcDevEndpoint, readAgcDevEndpoint,
@@ -21,10 +15,6 @@ import {
const appRoot = fileURLToPath(new URL('..', import.meta.url)); const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..'); const repoRoot = resolve(appRoot, '../..');
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json'); const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const apiServerExePath = resolve(
repoRoot,
'server-rs/target/debug/api-server.exe',
);
const defaultApiTarget = const defaultApiTarget =
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082'; process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
const backendDatabase = 'genarrative-game-creator-dev'; const backendDatabase = 'genarrative-game-creator-dev';
@@ -137,217 +127,19 @@ function readBackendTargets({ requireAgcBackend = false } = {}) {
}); });
} }
function readBackendServiceFailure(
state,
{
expectedDatabase = backendDatabase,
expectedSpacetimeDataDir = backendSpacetimeDataDir,
} = {},
) {
const targets = resolveBackendTargetsFromState(state, {
requireAgcBackend: true,
expectedDatabase,
expectedSpacetimeDataDir,
});
if (!targets.hasMatchingBackend) {
return null;
}
for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) {
const service = state?.services?.[serviceName];
if (service?.status !== 'failed') {
continue;
}
return {
serviceName,
failure: service.signal
? `signal=${service.signal}`
: `code=${service.exitCode ?? 1}`,
};
}
return null;
}
function urlPort(url) {
try {
const port = Number(new URL(url).port);
return Number.isInteger(port) && port > 0 ? port : 0;
} catch {
return 0;
}
}
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少
// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。
function readWindowsPortOwnerIdentities(
ports,
{ spawnImpl = spawnSync, env = process.env } = {},
) {
const uniquePorts = [...new Set(ports.filter((port) => port > 0))];
if (uniquePorts.length === 0) {
return null;
}
const command = [
'$ErrorActionPreference = "SilentlyContinue"',
'$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }',
'$result = @()',
'foreach ($port in $ports) {',
' $connection = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue | Select-Object -First 1',
' if (-not $connection) { continue }',
' $owner = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $connection.OwningProcess) -ErrorAction SilentlyContinue',
' $result += [pscustomobject]@{ port = [int]$port; processId = [int]$connection.OwningProcess; name = $owner.Name; executablePath = $owner.ExecutablePath; commandLine = $owner.CommandLine }',
'}',
'ConvertTo-Json -InputObject @($result) -Compress',
].join('\n');
const result = spawnImpl(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
{
encoding: 'utf8',
env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') },
maxBuffer: 8 * 1024 * 1024,
},
);
if (result?.error || result?.status !== 0) {
return null;
}
const owners = new Map();
for (const entry of parseWindowsProcessSnapshot(result.stdout)) {
const port = Number(entry?.port);
if (Number.isInteger(port) && port > 0) {
owners.set(port, entry);
}
}
return owners;
}
function isWorktreeApiServerOwner(
owner,
{ expectedExePath = apiServerExePath } = {},
) {
if (!owner) {
return false;
}
const expected = normalizeWindowsPath(expectedExePath);
const actual = normalizeWindowsPath(owner.executablePath);
return Boolean(expected) && actual === expected;
}
function isWorktreeSpacetimeOwner(
owner,
{ expectedDataDir = backendSpacetimeDataDir } = {},
) {
if (!owner) {
return false;
}
const expected = normalizeWindowsPath(expectedDataDir);
if (!expected) {
return false;
}
const name = String(owner.name ?? '').toLowerCase();
if (!name.startsWith('spacetime')) {
return false;
}
return normalizeWindowsPath(owner.commandLine).includes(expected);
}
// 端口健康不代表后端属于当前工作树:上个工作树 Ctrl+C 残留的 api-server 仍会
// 应答 /healthz。复用前必须证明端口上的进程就是本工作树的可执行文件与数据目录。
function verifyAgcBackendOwnership({
apiUrl,
spacetimeUrl,
bgfilterWorkerUrl,
platform = process.platform,
expectedExePath = apiServerExePath,
expectedDataDir = backendSpacetimeDataDir,
readPortOwners = readWindowsPortOwnerIdentities,
} = {}) {
if (platform !== 'win32') {
return { ok: true, reason: 'platform-unsupported', owners: new Map() };
}
const ports = [
urlPort(apiUrl),
urlPort(bgfilterWorkerUrl),
urlPort(spacetimeUrl),
];
const owners = readPortOwners(ports);
if (!owners) {
return { ok: true, reason: 'owner-probe-unavailable', owners: new Map() };
}
const apiOwner = owners.get(urlPort(apiUrl));
if (!isWorktreeApiServerOwner(apiOwner, { expectedExePath })) {
return { ok: false, reason: 'api-server-owner-mismatch', owners, apiOwner };
}
const workerOwner = owners.get(urlPort(bgfilterWorkerUrl));
if (!isWorktreeApiServerOwner(workerOwner, { expectedExePath })) {
return {
ok: false,
reason: 'bgfilter-worker-owner-mismatch',
owners,
workerOwner,
};
}
const spacetimeOwner = owners.get(urlPort(spacetimeUrl));
if (!isWorktreeSpacetimeOwner(spacetimeOwner, { expectedDataDir })) {
return {
ok: false,
reason: 'spacetime-owner-mismatch',
owners,
spacetimeOwner,
};
}
return { ok: true, reason: 'owned', owners };
}
function formatOwnerLabel(owner) {
if (!owner) {
return '未知进程';
}
const pid = Number(owner.processId);
const label = owner.executablePath || owner.commandLine || owner.name || '';
return `${Number.isInteger(pid) ? `pid=${pid} ` : ''}${String(label).trim()}`.trim();
}
async function isBackendReady({ async function isBackendReady({
state = readJson(devStackStatePath), state = readJson(devStackStatePath),
isReady = isHttpReady, isReady = isHttpReady,
verifyOwnership = verifyAgcBackendOwnership,
onOwnershipRejected = null,
} = {}) { } = {}) {
const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } = const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } =
resolveBackendTargetsFromState(state, { resolveBackendTargetsFromState(state, {
requireAgcBackend: true, requireAgcBackend: true,
}); });
if (!hasMatchingBackend || !apiUrl || !spacetimeUrl || !bgfilterWorkerUrl) {
return false;
}
const ownership = await verifyOwnership({
apiUrl,
spacetimeUrl,
bgfilterWorkerUrl,
});
if (!ownership?.ok) {
onOwnershipRejected?.(ownership);
return false;
}
if (ownership.reason === 'owner-probe-unavailable') {
console.warn(
'[ai-game-creator-shell] 无法读取端口监听进程归属,本次按旧行为复用配套后端。',
);
}
return ( return (
hasMatchingBackend &&
Boolean(apiUrl) &&
Boolean(spacetimeUrl) &&
Boolean(bgfilterWorkerUrl) &&
(await isReady(`${apiUrl}/healthz`)) && (await isReady(`${apiUrl}/healthz`)) &&
(await isReady(`${spacetimeUrl}/v1/ping`)) && (await isReady(`${spacetimeUrl}/v1/ping`)) &&
(await isReady(`${bgfilterWorkerUrl}/readyz`)) (await isReady(`${bgfilterWorkerUrl}/readyz`))
@@ -436,10 +228,6 @@ function spawnChild(command, args, options, spawnImpl = spawn) {
const child = spawnImpl(command, args, { const child = spawnImpl(command, args, {
...options, ...options,
shell: useShell, shell: useShell,
// npm.cmd and the Windows shell otherwise create a visible console for
// every service in the dev stack. Their stdout/stderr is already inherited
// by the launcher, so no separate terminal window is useful.
windowsHide: process.platform === 'win32' ? true : options.windowsHide,
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、 // POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。 // Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
detached: isPosix, detached: isPosix,
@@ -664,18 +452,14 @@ async function terminateChildTree(
return { stopped: true, forced: false }; return { stopped: true, forced: false };
} }
const result = await taskkillImpl(child.pid); const result = await taskkillImpl(child.pid);
const taskkillStopped = return {
!result?.timedOut && stopped:
!result?.error && !result?.timedOut &&
[0, 128].includes(result?.code ?? 0); !result?.error &&
if (taskkillStopped) { [0, 128].includes(result?.code ?? 0),
return { stopped: true, forced: true, result }; forced: true,
} result,
};
// 包装层(cmd.exe / npm.cmd)先被 Ctrl+C 杀掉时 taskkill 拿不到活着的 PID
// 这里继续按记录下来的根 PID 遍历,尽量收掉更深的后端进程。
const treeStopped = stopWindowsProcessTree(child.pid);
return { stopped: treeStopped.length > 0, forced: true, result };
} }
const processGroupId = childLifecycles.get(child)?.processGroupId; const processGroupId = childLifecycles.get(child)?.processGroupId;
@@ -721,43 +505,11 @@ async function terminateChildTree(
return { stopped, forced: true }; return { stopped, forced: true };
} }
async function waitForBackendReady( async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
backendChild,
timeoutMs = 600_000,
{
checkBackendReady = (onOwnershipRejected) =>
isBackendReady({ onOwnershipRejected }),
readState = () => readJson(devStackStatePath),
resolveTargets = readBackendTargets,
} = {},
) {
const initialStateUpdatedAt = readState()?.updatedAt ?? '';
const startedAt = Date.now(); const startedAt = Date.now();
let lastOwnershipReason = '';
while (Date.now() - startedAt < timeoutMs) { while (Date.now() - startedAt < timeoutMs) {
if ( if (await isBackendReady()) {
await checkBackendReady((ownership) => { return readBackendTargets();
if (ownership.reason === lastOwnershipReason) {
return;
}
lastOwnershipReason = ownership.reason;
// 本次自己拉起的后端如果归属校验一直不通过,必须把原因打出来,
// 否则只会表现为等待 600 秒后超时。
console.warn(
`[ai-game-creator-shell] 等待配套后端就绪时归属校验未通过(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)})。`,
);
})
) {
return resolveTargets();
}
const state = readState();
if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) {
const serviceFailure = readBackendServiceFailure(state);
if (serviceFailure) {
throw new Error(
`配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`,
);
}
} }
const failure = readChildFailure(backendChild); const failure = readChildFailure(backendChild);
if (failure) { if (failure) {
@@ -773,14 +525,7 @@ async function waitForBackendReady(
async function ensureBackend({ async function ensureBackend({
onBackendChild = () => {}, onBackendChild = () => {},
checkBackendReady = () => checkBackendReady = isBackendReady,
isBackendReady({
onOwnershipRejected(ownership) {
console.warn(
`[ai-game-creator-shell] 端口上的配套后端不属于当前工作树(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)}),改为启动本工作树自己的后端。`,
);
},
}),
resolveTargets = readBackendTargets, resolveTargets = readBackendTargets,
spawnBackend = () => spawnBackend = () =>
spawnChild( spawnChild(
@@ -795,7 +540,6 @@ async function ensureBackend({
backendDatabase, backendDatabase,
'--spacetime-data-dir', '--spacetime-data-dir',
backendSpacetimeDataDir, backendSpacetimeDataDir,
'--preserve-database',
'--no-interactive', '--no-interactive',
], ],
{ cwd: appRoot }, { cwd: appRoot },
@@ -860,33 +604,15 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
async function main() { async function main() {
let backendChild = null; let backendChild = null;
let startedBackend = false;
let viteChild = null; let viteChild = null;
let shutdownSignal = ''; let shutdownSignal = '';
const signalHandlers = new Map(); const signalHandlers = new Map();
// 只有本次会话真正拉起过配套后端时才做兜底清扫:复用别人后端时不能连带
// 杀掉对方的进程。dev.mjs 的清理依赖它的 shell 包装层仍然活着,而 Ctrl+C
// 往往先杀掉包装层,所以这里必须按本工作树 api-server.exe 的身份再收一次。
const sweepStartedBackend = () => {
if (!startedBackend || process.platform !== 'win32') {
return;
}
const stopped = stopWindowsWorktreeProcesses({ apiServerExePath });
if (stopped.length > 0) {
console.log(
`[ai-game-creator-shell] 已清理残留后端进程: ${stopped.join(', ')}`,
);
}
};
for (const signal of ['SIGINT', 'SIGTERM']) { for (const signal of ['SIGINT', 'SIGTERM']) {
const handler = () => { const handler = () => {
shutdownSignal = signal; shutdownSignal = signal;
stopChild(viteChild, signal); stopChild(viteChild, signal);
stopChild(backendChild, signal); stopChild(backendChild, signal);
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
sweepStartedBackend();
}; };
signalHandlers.set(signal, handler); signalHandlers.set(signal, handler);
process.on(signal, handler); process.on(signal, handler);
@@ -905,7 +631,6 @@ async function main() {
}, },
}); });
backendChild = backend.backendChild; backendChild = backend.backendChild;
startedBackend = Boolean(backendChild);
if (shutdownSignal) { if (shutdownSignal) {
throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`); throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`);
} }
@@ -939,7 +664,6 @@ async function main() {
terminateChildTree(viteChild), terminateChildTree(viteChild),
terminateChildTree(backendChild), terminateChildTree(backendChild),
]); ]);
sweepStartedBackend();
for (const [signal, handler] of signalHandlers) { for (const [signal, handler] of signalHandlers) {
process.off(signal, handler); process.off(signal, handler);
} }
@@ -956,25 +680,17 @@ function isDirectModuleExecution() {
export { export {
ensureBackend, ensureBackend,
formatChildFailure, formatChildFailure,
formatOwnerLabel,
isAiGameCreatorServer,
isBackendReady, isBackendReady,
isDirectModuleExecution, isDirectModuleExecution,
isProcessGroupAlive, isProcessGroupAlive,
isWorktreeApiServerOwner,
isWorktreeSpacetimeOwner,
preflightExistingVite, preflightExistingVite,
readBackendServiceFailure,
readChildFailure, readChildFailure,
readExistingViteServer,
readLinuxProcessGroupAlive, readLinuxProcessGroupAlive,
readWindowsPortOwnerIdentities,
resolveBackendTargetsFromState, resolveBackendTargetsFromState,
runWindowsTaskkill, runWindowsTaskkill,
spawnChild, spawnChild,
stopChild, stopChild,
terminateChildTree, terminateChildTree,
verifyAgcBackendOwnership,
waitForBackendReady, waitForBackendReady,
waitForChildTermination, waitForChildTermination,
}; };
@@ -7,10 +7,7 @@ import {
withAgcDevEndpointEnv, withAgcDevEndpointEnv,
} from './dev-port.mjs'; } from './dev-port.mjs';
import { import {
isAiGameCreatorServer,
preflightExistingVite, preflightExistingVite,
readChildFailure,
readExistingViteServer,
spawnChild, spawnChild,
stopChild, stopChild,
terminateChildTree, terminateChildTree,
@@ -23,9 +20,7 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
const args = [...argv]; const args = [...argv];
const configOverride = JSON.stringify({ const configOverride = JSON.stringify({ build: { devUrl } });
build: { devUrl, beforeDevCommand: '' },
});
const separatorIndex = args.indexOf('--'); const separatorIndex = args.indexOf('--');
if (separatorIndex < 0) { if (separatorIndex < 0) {
return ['dev', ...args, '--config', configOverride]; return ['dev', ...args, '--config', configOverride];
@@ -43,28 +38,6 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
]; ];
} }
// `agc_cocos_execute` 与 Cocos 编辑器适配器只在 `cocos-editor-execute` feature 下
// 注册。开发构建默认在 Windows 打开它,否则 Agent 的工具清单里根本没有该工具,
// 只能退化成改写脚本。可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
function readDevCargoFeatures(env = process.env) {
const override = env.AGC_DEV_CARGO_FEATURES;
if (override !== undefined) {
return override
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
return process.platform === 'win32' ? ['cocos-editor-execute'] : [];
}
function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
if (features.length === 0) return argv;
if (argv.some((value) => value === '--features' || value === '-f')) {
return argv;
}
return [`--features=${features.join(',')}`, ...argv];
}
function spawnTauriCli(argv, { env = process.env } = {}) { function spawnTauriCli(argv, { env = process.env } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], { return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot, cwd: appRoot,
@@ -78,7 +51,6 @@ async function runTauriDev(
{ {
resolveDevEndpoint = resolveAgcDevEndpoint, resolveDevEndpoint = resolveAgcDevEndpoint,
preflight = preflightExistingVite, preflight = preflightExistingVite,
prepareFrontend = prepareFrontendDev,
spawnCli = spawnTauriCli, spawnCli = spawnTauriCli,
waitForCli = waitForChildTermination, waitForCli = waitForChildTermination,
terminateTree = terminateChildTree, terminateTree = terminateChildTree,
@@ -87,9 +59,10 @@ async function runTauriDev(
const endpoint = await resolveDevEndpoint(); const endpoint = await resolveDevEndpoint();
await preflight({ endpoint }); await preflight({ endpoint });
let child = null; const tauriArguments = buildTauriArguments(argv, endpoint.url);
let frontendChild = null; const child = spawnCli(tauriArguments, {
const preparationAbort = new AbortController(); env: withAgcDevEndpointEnv(endpoint),
});
let resolveShutdown; let resolveShutdown;
let shutdownSignal = ''; let shutdownSignal = '';
let repeatedSignal = false; let repeatedSignal = false;
@@ -103,50 +76,21 @@ async function runTauriDev(
if (!shutdownSignal) { if (!shutdownSignal) {
shutdownSignal = signal; shutdownSignal = signal;
stopChild(child, 'SIGTERM'); stopChild(child, 'SIGTERM');
stopChild(frontendChild, 'SIGTERM');
preparationAbort.abort();
resolveShutdown(signal); resolveShutdown(signal);
return; return;
} }
repeatedSignal = true; repeatedSignal = true;
stopChild(child, 'SIGKILL'); stopChild(child, 'SIGKILL');
stopChild(frontendChild, 'SIGKILL');
}; };
signalHandlers.set(signal, handler); signalHandlers.set(signal, handler);
process.on(signal, handler); process.on(signal, handler);
} }
try { try {
const preparation = prepareFrontend(endpoint, {
signal: preparationAbort.signal,
onChild(frontend) {
frontendChild = frontend;
},
});
const prepared = await Promise.race([
preparation.then(() => true),
shutdownRequested.then(() => false),
]);
if (!prepared || shutdownSignal) return 1;
const tauriArguments = buildTauriArguments(
withDevCargoFeatures(argv),
endpoint.url,
);
child = spawnCli(tauriArguments, {
env: withAgcDevEndpointEnv(endpoint),
});
const childResult = waitForCli(child); const childResult = waitForCli(child);
const outcome = await Promise.race([ const outcome = await Promise.race([
childResult.then((failure) => ({ type: 'exit', failure })), childResult.then((failure) => ({ type: 'exit', failure })),
shutdownRequested.then((signal) => ({ type: 'signal', signal })), shutdownRequested.then((signal) => ({ type: 'signal', signal })),
...(frontendChild
? [
waitForChildTermination(frontendChild).then((failure) => ({
type: 'frontend-exit',
failure,
})),
]
: []),
]); ]);
const cleanup = await terminateTree(child, { const cleanup = await terminateTree(child, {
gracefulTimeoutMs: repeatedSignal ? 0 : 2500, gracefulTimeoutMs: repeatedSignal ? 0 : 2500,
@@ -161,51 +105,15 @@ async function runTauriDev(
if (outcome.type === 'signal') { if (outcome.type === 'signal') {
return 1; return 1;
} }
if (outcome.type === 'frontend-exit') return 1;
const { failure } = outcome; const { failure } = outcome;
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0); return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
} finally { } finally {
for (const [signal, handler] of signalHandlers) { for (const [signal, handler] of signalHandlers) {
process.off(signal, handler); process.off(signal, handler);
} }
preparationAbort.abort();
if (frontendChild) {
const cleanup = await terminateTree(frontendChild);
if (!cleanup.stopped) {
console.error('[ai-game-creator-shell] 配套开发服务未能完全停止。');
}
}
} }
} }
async function prepareFrontendDev(endpoint, { onChild, signal }) {
const frontend = spawnChild(
process.platform === 'win32' ? 'npm.cmd' : 'npm',
['run', 'agc:serve'],
{ cwd: repoRoot, env: withAgcDevEndpointEnv(endpoint) },
);
onChild(frontend);
console.log(
'[ai-game-creator-shell] 正在准备前端与配套后端,完成后启动 Tauri',
);
const deadline = Date.now() + 660_000;
while (Date.now() < deadline) {
signal.throwIfAborted();
const failure = readChildFailure(frontend);
if (failure) {
throw new Error(
`配套开发服务退出,前端未就绪:${failure.error?.message ?? failure.signal ?? failure.code}`,
);
}
if (isAiGameCreatorServer(await readExistingViteServer(endpoint))) return;
await Promise.race([
new Promise((resolveWait) => setTimeout(resolveWait, 1000)),
waitForChildTermination(frontend),
]);
}
throw new Error(`等待前端与配套后端就绪超时:${endpoint.url}`);
}
function isDirectModuleExecution() { function isDirectModuleExecution() {
return Boolean( return Boolean(
process.argv[1] && process.argv[1] &&
@@ -218,7 +126,6 @@ export {
isDirectModuleExecution, isDirectModuleExecution,
runTauriDev, runTauriDev,
spawnTauriCli, spawnTauriCli,
withDevCargoFeatures,
}; };
if (isDirectModuleExecution()) { if (isDirectModuleExecution()) {
@@ -1,4 +0,0 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
-22
View File
@@ -733,18 +733,6 @@ dependencies = [
"error-code", "error-code",
] ]
[[package]]
name = "cocos-editor-bridge"
version = "0.1.0"
dependencies = [
"cc",
"editor-adapter-api",
"serde",
"serde_json",
"sha2",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -1217,14 +1205,6 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "editor-adapter-api"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.16.0" version = "1.16.0"
@@ -1729,8 +1709,6 @@ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"chromiumoxide", "chromiumoxide",
"cocos-editor-bridge",
"editor-adapter-api",
"futures", "futures",
"getrandom 0.3.4", "getrandom 0.3.4",
"http", "http",

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