Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b1e06b50a | |||
| 0b6efd98fe | |||
| 41b660bdc4 | |||
| da1759322c | |||
| ff1f77f88a | |||
| e73d04db00 | |||
| 8a679d7011 | |||
| 456762f569 | |||
| 48a2723527 | |||
| 1132fe5325 | |||
| ea3c34beef | |||
| 877724d7bc | |||
| ae8d51500b | |||
| 8ed2f42b52 | |||
| 300ea76780 | |||
| b136a7c346 | |||
| b1a9a296e8 | |||
| 75875a0fad | |||
| 1b12b7b98e | |||
| bb51c2716b | |||
| 9e8b7d880e | |||
| 609efe805c |
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 审批卡自动应答动作,默认 approve;revise/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 永远不 settle,CLI 也就永远
|
||||||
|
// 不退出。所以审批必须在 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
@@ -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
Reference in New Issue
Block a user