Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d6e7057bc | |||
| 6daff74c85 | |||
| ad49056d36 | |||
| 28af87e3ad | |||
| d994ccaa78 | |||
| 42f1109b90 | |||
| b772ec3efb | |||
| d014cb1d06 | |||
| e34ae89db3 | |||
| 88853d0a30 | |||
| fabc64df6c | |||
| 27803a6e78 | |||
| 828b3e1173 | |||
| deb60065a5 | |||
| 5c6a940b9c | |||
| 8df5ebff69 | |||
| 041b51adc1 | |||
| 5a31809e21 | |||
| 44bebae0a9 | |||
| 62793ac445 | |||
| f630c518f3 |
@@ -8,11 +8,6 @@ LLM_BASE_URL="https://api.vectorengine.cn/v1"
|
||||
# but it should not be relied on by browser code.
|
||||
LLM_API_KEY=""
|
||||
|
||||
# Router account provisioning secret (server-side only). Prefer the protected
|
||||
# file form in production; never expose either value to clients or commit it.
|
||||
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET=""
|
||||
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=""
|
||||
|
||||
# Optional frontend override for the local proxy path.
|
||||
VITE_LLM_PROXY_BASE_URL="/api/llm"
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ import type {
|
||||
AdminErrorReportDetail,
|
||||
AdminErrorReportEntry,
|
||||
AdminErrorReportListResponse,
|
||||
AdminExternalApiKeyListQuery,
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
@@ -251,16 +249,6 @@ export function getAdminDatabaseTableRows(
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminExternalApiKeys(
|
||||
token: string,
|
||||
query: AdminExternalApiKeyListQuery = {},
|
||||
) {
|
||||
return request<AdminExternalApiKeyListResponse>(
|
||||
`/admin/api/external-api-keys${buildExternalApiKeyQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
|
||||
return request<AdminDebugHttpResponse>('/admin/api/debug/http', {
|
||||
method: 'POST',
|
||||
@@ -940,28 +928,6 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildExternalApiKeyQuery(query: AdminExternalApiKeyListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'ownerUserId', query.ownerUserId);
|
||||
appendQueryParam(params, 'publicUserCode', query.publicUserCode);
|
||||
appendQueryParam(params, 'keyId', query.keyId);
|
||||
appendQueryParam(params, 'name', query.name);
|
||||
appendQueryParam(params, 'keyPrefix', query.keyPrefix);
|
||||
appendQueryParam(params, 'createdAfter', query.createdAfter);
|
||||
appendQueryParam(params, 'createdBefore', query.createdBefore);
|
||||
appendQueryParam(params, 'status', query.status);
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(Math.floor(query.limit)));
|
||||
}
|
||||
if (typeof query.offset === 'number' && Number.isFinite(query.offset)) {
|
||||
params.set('offset', String(Math.floor(query.offset)));
|
||||
}
|
||||
appendQueryParam(params, 'sortColumn', query.sortColumn);
|
||||
appendQueryParam(params, 'sortDirection', query.sortDirection);
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildEditorAssetListQuery(query: AdminEditorAssetListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'cursor', query.cursor);
|
||||
@@ -1072,19 +1038,3 @@ function buildAdminApiError(
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
export function getAgcModelCatalog(token: string) {
|
||||
return request<import('./adminApiTypes').AdminAgcModelCatalog>(
|
||||
'/admin/api/agc-models',
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function saveAgcModelCatalog(
|
||||
token: string,
|
||||
body: import('./adminApiTypes').AdminAgcModelCatalog,
|
||||
) {
|
||||
return request<import('./adminApiTypes').AdminAgcModelCatalog>(
|
||||
'/admin/api/agc-models',
|
||||
{ token, method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -275,51 +275,6 @@ export interface AdminDatabaseTableStatPayload {
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface AdminExternalApiKeyListQuery {
|
||||
ownerUserId?: string;
|
||||
publicUserCode?: string;
|
||||
keyId?: string;
|
||||
name?: string;
|
||||
keyPrefix?: string;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
status?: 'active' | 'revoked';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortColumn?:
|
||||
| 'keyId'
|
||||
| 'ownerUserId'
|
||||
| 'name'
|
||||
| 'keyPrefix'
|
||||
| 'createdAt'
|
||||
| 'lastUsedAt'
|
||||
| 'updatedAt';
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AdminExternalApiKeyPayload {
|
||||
keyId: string;
|
||||
ownerUserId: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
scopes: string[];
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
updatedAt: string;
|
||||
status: 'active' | 'revoked';
|
||||
}
|
||||
|
||||
export interface AdminExternalApiKeyListResponse {
|
||||
keys: AdminExternalApiKeyPayload[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
scannedCount: number;
|
||||
scanLimit: number;
|
||||
scanLimitReached: boolean;
|
||||
}
|
||||
|
||||
export interface AdminDebugHeaderInput {
|
||||
name: string;
|
||||
value: string;
|
||||
@@ -998,15 +953,3 @@ export interface AdminRechargeRefundActionResponse {
|
||||
export interface AdminWalletRestrictionResponse {
|
||||
wallet: AdminProfileWalletPayload;
|
||||
}
|
||||
export interface AdminAgcModel {
|
||||
id: string;
|
||||
alias: string;
|
||||
modelId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AdminAgcModelCatalog {
|
||||
revision: number;
|
||||
defaultModelId: string;
|
||||
models: AdminAgcModel[];
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
setStoredAdminToken,
|
||||
} from '../auth/adminAuthStore';
|
||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||
@@ -290,9 +289,6 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'agc-models' ? (
|
||||
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{activeRouteId === 'editor-showcase' ? (
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token={token}
|
||||
|
||||
@@ -50,7 +50,6 @@ const routeIcons = {
|
||||
'editor-showcase': Star,
|
||||
'editor-assets': Images,
|
||||
accounts: Users,
|
||||
'agc-models': ListChecks,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
export function AdminShell({
|
||||
|
||||
@@ -16,13 +16,9 @@ export type AdminRouteId =
|
||||
| 'editor-generation-pricing'
|
||||
| 'editor-showcase'
|
||||
| 'editor-assets'
|
||||
| 'agc-models'
|
||||
| 'accounts';
|
||||
|
||||
export type AdminTabPermission = Exclude<
|
||||
AdminRouteId,
|
||||
'accounts' | 'agc-models'
|
||||
>;
|
||||
export type AdminTabPermission = Exclude<AdminRouteId, 'accounts'>;
|
||||
|
||||
/** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */
|
||||
export interface AdminRouteDefinition {
|
||||
@@ -51,7 +47,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
label: '模型定价',
|
||||
hash: '#editor-generation-pricing',
|
||||
},
|
||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient';
|
||||
import { AdminAgcModelsPage } from './AdminAgcModelsPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
getAgcModelCatalog: vi.fn(),
|
||||
saveAgcModelCatalog: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
formatAdminApiError: vi.fn(() => '保存失败'),
|
||||
}));
|
||||
vi.mock('../components/useAdminWriteConfirm', () => ({
|
||||
useAdminWriteConfirm: () => ({
|
||||
confirmWrite: async () => true,
|
||||
confirmDialog: null,
|
||||
}),
|
||||
}));
|
||||
afterEach(cleanup);
|
||||
|
||||
test('edits alias and upstream model without changing the stable identifier or revision', async () => {
|
||||
const catalog = {
|
||||
revision: 3,
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{ id: 'quality', alias: '高质量', modelId: 'gpt-6-astra', enabled: true },
|
||||
],
|
||||
};
|
||||
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
|
||||
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
|
||||
...input,
|
||||
revision: 4,
|
||||
}));
|
||||
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByDisplayValue('gpt-6-astra');
|
||||
fireEvent.change(screen.getByLabelText('模型 1 别名'), {
|
||||
target: { value: '精细创作' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
await waitFor(() =>
|
||||
expect(saveAgcModelCatalog).toHaveBeenCalledWith('test', {
|
||||
...catalog,
|
||||
models: [{ ...catalog.models[0], alias: '精细创作' }],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('已保存').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -1,255 +0,0 @@
|
||||
import { CircleHelp, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient';
|
||||
import type { AdminAgcModel, AdminAgcModelCatalog } from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
export function AdminAgcModelsPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<AdminAgcModelCatalog | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
|
||||
|
||||
async function refresh() {
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSaved(false);
|
||||
try {
|
||||
setCatalog(await getAgcModelCatalog(token));
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
function update(id: string, patch: Partial<AdminAgcModel>) {
|
||||
setSaved(false);
|
||||
setCatalog(
|
||||
(current) =>
|
||||
current && {
|
||||
...current,
|
||||
models: current.models.map((model) =>
|
||||
model.id === id ? { ...model, ...patch } : model,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!catalog || busy) return;
|
||||
if (
|
||||
!(await confirmWrite({
|
||||
action: '保存 AGC 模型目录',
|
||||
target: `${catalog.models.length} 个模型`,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSaved(false);
|
||||
try {
|
||||
setCatalog(await saveAgcModelCatalog(token, catalog));
|
||||
setSaved(true);
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide admin-agc-models">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>AGC 模型</h2>
|
||||
<p>管理客户端可用模型与用户看到的名称</p>
|
||||
</div>
|
||||
<span className="admin-agc-models-revision">
|
||||
版本 v{catalog?.revision ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-agc-models-summary">
|
||||
<div>
|
||||
<span>已启用</span>
|
||||
<strong>
|
||||
{catalog?.models.filter((model) => model.enabled).length ?? 0}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>默认模型</span>
|
||||
<strong>
|
||||
{catalog
|
||||
? (catalog.models.find(
|
||||
(model) => model.id === catalog.defaultModelId,
|
||||
)?.alias ?? '未设置')
|
||||
: '未设置'}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发布状态</span>
|
||||
<strong>{busy ? '处理中' : saved ? '已保存' : '待修改'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<section className="admin-panel admin-agc-models-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<div>
|
||||
<h3>模型目录</h3>
|
||||
<span>客户端仅显示别名,实际模型名仅在这里维护</span>
|
||||
</div>
|
||||
<CircleHelp size={17} aria-label="模型目录帮助" />
|
||||
</div>
|
||||
<div className="admin-agc-models-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
title="重新读取"
|
||||
aria-label="重新读取模型"
|
||||
disabled={busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="添加模型"
|
||||
aria-label="添加模型"
|
||||
disabled={busy || !catalog || catalog.models.length >= 32}
|
||||
onClick={() => {
|
||||
setSaved(false);
|
||||
setCatalog(
|
||||
(current) =>
|
||||
current && {
|
||||
...current,
|
||||
models: [
|
||||
...current.models,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
alias: '',
|
||||
modelId: '',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !catalog}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
<Save size={16} />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{saved ? <p role="status">已保存</p> : null}
|
||||
{busy ? <p role="status">正在处理</p> : null}
|
||||
<div className="admin-table-wrap admin-agc-models-table">
|
||||
<table className="admin-table admin-agc-models-table-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>别名</th>
|
||||
<th>实际模型名</th>
|
||||
<th>启用</th>
|
||||
<th>默认</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{catalog?.models.map((model, index) => (
|
||||
<tr key={model.id}>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 别名`}
|
||||
maxLength={40}
|
||||
required
|
||||
value={model.alias}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, { alias: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 实际模型名`}
|
||||
maxLength={200}
|
||||
required
|
||||
value={model.modelId}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, { modelId: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 启用`}
|
||||
type="checkbox"
|
||||
checked={model.enabled}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onChange={(e) =>
|
||||
update(model.id, { enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 默认`}
|
||||
name="agc-default-model"
|
||||
type="radio"
|
||||
checked={model.id === catalog.defaultModelId}
|
||||
disabled={busy || !model.enabled}
|
||||
onChange={() => {
|
||||
setSaved(false);
|
||||
setCatalog({ ...catalog, defaultModelId: model.id });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
title="删除模型"
|
||||
aria-label={`删除模型 ${index + 1}`}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onClick={() => {
|
||||
setSaved(false);
|
||||
setCatalog({
|
||||
...catalog,
|
||||
models: catalog.models.filter(
|
||||
(candidate) => candidate.id !== model.id,
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{confirmDialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
getAdminDatabaseTableRows,
|
||||
getAdminDatabaseTables,
|
||||
getAdminExternalApiKeys,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminDatabaseTableRowsResponse } from '../api/adminApiTypes';
|
||||
import {
|
||||
@@ -21,7 +20,6 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
),
|
||||
getAdminDatabaseTableRows: vi.fn(),
|
||||
getAdminDatabaseTables: vi.fn(),
|
||||
getAdminExternalApiKeys: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
@@ -76,7 +74,6 @@ const referralRows = [
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.location.hash = '#tables?table=profile_referral_relation';
|
||||
vi.mocked(getAdminExternalApiKeys).mockReset();
|
||||
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
|
||||
fetchErrors: [],
|
||||
tables: ['profile_referral_relation'],
|
||||
@@ -95,55 +92,6 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('external_api_key 使用专用安全查询且详情不展示原始 JSON', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.location.hash = '#tables?table=external_api_key';
|
||||
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
|
||||
fetchErrors: [],
|
||||
tables: ['external_api_key'],
|
||||
});
|
||||
vi.mocked(getAdminExternalApiKeys).mockResolvedValue({
|
||||
keys: [
|
||||
{
|
||||
keyId: 'external-api-key-1',
|
||||
ownerUserId: 'user-1',
|
||||
name: 'agc_auto_generate',
|
||||
keyPrefix: 'tnr_sk_fixture',
|
||||
scopes: ['llm:responses'],
|
||||
createdAt: '2026-08-29T00:00:00Z',
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
updatedAt: '2026-08-29T00:00:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
scannedCount: 1,
|
||||
scanLimit: 5000,
|
||||
scanLimitReached: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('精确 ownerUserId'), 'user-1');
|
||||
await user.click(screen.getByRole('button', { name: '安全查询' }));
|
||||
await waitFor(() => {
|
||||
expect(getAdminExternalApiKeys).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({ ownerUserId: 'user-1' }),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText('tnr_sk_fixture')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '详情' }));
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
expect(screen.queryByText('复制 JSON')).toBeNull();
|
||||
expect(screen.queryByText('key_hash')).toBeNull();
|
||||
});
|
||||
|
||||
test('后台表查询页通过页面级固定栏翻页并提示扫描结果可能不完整', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3093,140 +3093,5 @@ button:disabled {
|
||||
background: var(--admin-surface, #fff);
|
||||
}
|
||||
.admin-detail-modal__panel header,
|
||||
.admin-detail-modal__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-detail-modal__panel pre {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
background: #f8fafc;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.admin-agc-models {
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-agc-models-revision {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e7d9cc;
|
||||
border-radius: 999px;
|
||||
background: #fffaf6;
|
||||
color: #9a8170;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.admin-agc-models-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-agc-models-summary > div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #eadfd6;
|
||||
border-radius: 10px;
|
||||
background: #fffdfa;
|
||||
}
|
||||
.admin-agc-models-summary span,
|
||||
.admin-agc-models-panel > .admin-panel-heading span {
|
||||
color: #9a8170;
|
||||
font-size: 12px;
|
||||
}
|
||||
.admin-agc-models-summary strong {
|
||||
overflow: hidden;
|
||||
color: #3d2a20;
|
||||
font-size: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-agc-models-panel {
|
||||
border: 1px solid #eadfd6;
|
||||
border-radius: 10px;
|
||||
background: #fffdfa;
|
||||
box-shadow: 0 10px 30px rgb(78 48 28 / 6%);
|
||||
}
|
||||
.admin-agc-models-panel > .admin-panel-heading > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.admin-agc-models-panel > .admin-panel-heading > svg {
|
||||
color: #b9947a;
|
||||
}
|
||||
.admin-agc-models-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
.admin-agc-models-toolbar button,
|
||||
.admin-agc-models-table-grid button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 34px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #e2d3c7;
|
||||
border-radius: 8px;
|
||||
background: #fffaf6;
|
||||
color: #684d3d;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-agc-models-toolbar button:hover,
|
||||
.admin-agc-models-toolbar button:focus-visible,
|
||||
.admin-agc-models-table-grid button:hover,
|
||||
.admin-agc-models-table-grid button:focus-visible {
|
||||
border-color: #c99d80;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.admin-agc-models-toolbar button:last-child {
|
||||
border-color: #a96442;
|
||||
background: #a96442;
|
||||
color: #fff;
|
||||
}
|
||||
.admin-agc-models-table-grid {
|
||||
min-width: 720px;
|
||||
}
|
||||
.admin-agc-models-table-grid th {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
background: #fcf7f2;
|
||||
}
|
||||
.admin-agc-models-table-grid td {
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.admin-agc-models-table-grid td input:not([type]) {
|
||||
width: 100%;
|
||||
min-width: 180px;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #e1d3c8;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: #3d2a20;
|
||||
}
|
||||
.admin-agc-models-table-grid td input:not([type]):focus-visible {
|
||||
border-color: #b97854;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgb(185 120 84 / 14%);
|
||||
}
|
||||
.admin-agc-models-table-grid td:has(input[type='checkbox']),
|
||||
.admin-agc-models-table-grid td:has(input[type='radio']) {
|
||||
width: 72px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.admin-agc-models-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.admin-detail-modal__actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.admin-detail-modal__panel pre { max-height: 360px; overflow: auto; white-space: pre-wrap; background: #f8fafc; padding: 12px; border-radius: 8px; }
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"schemaVersion": "game-creator-config.v2",
|
||||
"agentMode": "codex_app_server",
|
||||
"llm": {
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||
"model": "gpt-6-astra",
|
||||
"model": "gpt-5.6-sol",
|
||||
"apiKind": "openai_responses",
|
||||
"reasoningEffort": "max",
|
||||
"stream": true,
|
||||
"webSearchEnabled": true,
|
||||
"webSearchEnabled": false,
|
||||
"contextWindowTokens": 128000,
|
||||
"autoCompactTokenLimit": 64000,
|
||||
"toolOutputTokenLimit": 12000,
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"focus-trap-react": "^12.0.3",
|
||||
"lexical": "^0.47.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"phaser": "^4.2.1",
|
||||
"react": "^19.0.0",
|
||||
"react-arborist": "^3.16.0",
|
||||
"react-colorful": "^5.8.0",
|
||||
|
||||
@@ -1948,7 +1948,6 @@ async function runE2e(options) {
|
||||
...process.env,
|
||||
NO_COLOR: '1',
|
||||
[platformSessionFixtureEnv]: fixturePath,
|
||||
GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1',
|
||||
}),
|
||||
);
|
||||
childReport = parseChildReport(childResult);
|
||||
|
||||
@@ -773,8 +773,7 @@ export async function prepareIsolatedSuiteAppData({
|
||||
isScopedAgentsSuite() ||
|
||||
isProjectSkillSuite() ||
|
||||
isParallelReadSuite() ||
|
||||
isSupervisorSwarmSuite() ||
|
||||
isSupervisorAutonomousPlayableLaneDefenseSuite()
|
||||
isSupervisorSwarmSuite()
|
||||
? 'private-copy'
|
||||
: 'hardlink';
|
||||
try {
|
||||
@@ -797,7 +796,7 @@ export async function prepareIsolatedSuiteAppData({
|
||||
storageMode === 'private-copy' &&
|
||||
(linkedMetadata.dev !== source.metadata.dev ||
|
||||
linkedMetadata.ino !== source.metadata.ino) &&
|
||||
(process.platform === 'win32' || (linkedMetadata.mode & 0o077) === 0);
|
||||
(linkedMetadata.mode & 0o077) === 0;
|
||||
const hardlinkValid =
|
||||
storageMode === 'hardlink' &&
|
||||
linkedMetadata.dev === source.metadata.dev &&
|
||||
@@ -1977,7 +1976,7 @@ export async function verifyIsolatedSuiteConfigLinksUnchanged() {
|
||||
linkedMetadata.ino === link.linkedIno &&
|
||||
(linkedMetadata.dev !== sourceMetadata.dev ||
|
||||
linkedMetadata.ino !== sourceMetadata.ino) &&
|
||||
(process.platform === 'win32' || (linkedMetadata.mode & 0o077) === 0)
|
||||
(linkedMetadata.mode & 0o077) === 0
|
||||
: linkedMetadata.dev === link.dev && linkedMetadata.ino === link.ino;
|
||||
const sourceMetadataStable =
|
||||
sourceMetadata.mode === link.sourceMode &&
|
||||
|
||||
@@ -107,18 +107,12 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'append_direct_project_conversation_message',
|
||||
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||||
'bind_components',
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_direct_project_conversation',
|
||||
'stop_local_game_preview_if_matches',
|
||||
'start_game_creator_external_mcp',
|
||||
'stop_game_creator_external_mcp',
|
||||
];
|
||||
const sourceExtensions = new Set([
|
||||
'.json',
|
||||
@@ -1759,8 +1753,9 @@ for (const snippet of [
|
||||
"'read_game_creator_app_config'",
|
||||
"'write_game_creator_app_config'",
|
||||
'aria-label="运行时配置"',
|
||||
'陶泥儿智能创作(固定)',
|
||||
'官方账号服务(固定)',
|
||||
'LLM API Key',
|
||||
'External Editor Base URL',
|
||||
'External Editor API Key',
|
||||
'runtime_config.save',
|
||||
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
||||
"'activate_local_game_preview'",
|
||||
|
||||
@@ -26,7 +26,6 @@ const repositoryRoot = path.resolve(appRoot, '..', '..');
|
||||
const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml');
|
||||
const defaultConfigPath = path.join(appRoot, configFileName);
|
||||
const localConfigFileName = 'game-creator.config.local.json';
|
||||
const gameCreatorConfigSchemaVersion = 'game-creator-config.v2';
|
||||
const cargoCommand = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
|
||||
@@ -212,7 +211,6 @@ export function buildGameCreatorWizardConfig(existingConfig, llmInput) {
|
||||
}
|
||||
return {
|
||||
...source,
|
||||
schemaVersion: gameCreatorConfigSchemaVersion,
|
||||
agentMode: 'provider',
|
||||
llm: {
|
||||
...previousLlm,
|
||||
|
||||
@@ -7,7 +7,6 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node: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 projectRoot = path.join(
|
||||
os.tmpdir(),
|
||||
@@ -672,11 +671,6 @@ function runAgent() {
|
||||
{
|
||||
cwd: appRoot,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...inheritedChildEnvironment,
|
||||
// 该 smoke 只使用一次性 loopback Provider;生产路由仍保持锁定。
|
||||
GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1',
|
||||
},
|
||||
},
|
||||
);
|
||||
let stdout = '';
|
||||
|
||||
@@ -127,39 +127,6 @@ 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;
|
||||
}
|
||||
|
||||
async function isBackendReady({
|
||||
state = readJson(devStackStatePath),
|
||||
isReady = isHttpReady,
|
||||
@@ -538,29 +505,11 @@ async function terminateChildTree(
|
||||
return { stopped, forced: true };
|
||||
}
|
||||
|
||||
async function waitForBackendReady(
|
||||
backendChild,
|
||||
timeoutMs = 600_000,
|
||||
{
|
||||
checkBackendReady = isBackendReady,
|
||||
readState = () => readJson(devStackStatePath),
|
||||
resolveTargets = readBackendTargets,
|
||||
} = {},
|
||||
) {
|
||||
const initialStateUpdatedAt = readState()?.updatedAt ?? '';
|
||||
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await checkBackendReady()) {
|
||||
return resolveTargets();
|
||||
}
|
||||
const state = readState();
|
||||
if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) {
|
||||
const serviceFailure = readBackendServiceFailure(state);
|
||||
if (serviceFailure) {
|
||||
throw new Error(
|
||||
`配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`,
|
||||
);
|
||||
}
|
||||
if (await isBackendReady()) {
|
||||
return readBackendTargets();
|
||||
}
|
||||
const failure = readChildFailure(backendChild);
|
||||
if (failure) {
|
||||
@@ -591,7 +540,6 @@ async function ensureBackend({
|
||||
backendDatabase,
|
||||
'--spacetime-data-dir',
|
||||
backendSpacetimeDataDir,
|
||||
'--preserve-database',
|
||||
'--no-interactive',
|
||||
],
|
||||
{ cwd: appRoot },
|
||||
@@ -732,14 +680,11 @@ function isDirectModuleExecution() {
|
||||
export {
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
isAiGameCreatorServer,
|
||||
isBackendReady,
|
||||
isDirectModuleExecution,
|
||||
isProcessGroupAlive,
|
||||
preflightExistingVite,
|
||||
readBackendServiceFailure,
|
||||
readChildFailure,
|
||||
readExistingViteServer,
|
||||
readLinuxProcessGroupAlive,
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
withAgcDevEndpointEnv,
|
||||
} from './dev-port.mjs';
|
||||
import {
|
||||
isAiGameCreatorServer,
|
||||
preflightExistingVite,
|
||||
readChildFailure,
|
||||
readExistingViteServer,
|
||||
spawnChild,
|
||||
stopChild,
|
||||
terminateChildTree,
|
||||
@@ -23,9 +20,7 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
|
||||
|
||||
function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
|
||||
const args = [...argv];
|
||||
const configOverride = JSON.stringify({
|
||||
build: { devUrl, beforeDevCommand: '' },
|
||||
});
|
||||
const configOverride = JSON.stringify({ build: { devUrl } });
|
||||
const separatorIndex = args.indexOf('--');
|
||||
if (separatorIndex < 0) {
|
||||
return ['dev', ...args, '--config', configOverride];
|
||||
@@ -56,7 +51,6 @@ async function runTauriDev(
|
||||
{
|
||||
resolveDevEndpoint = resolveAgcDevEndpoint,
|
||||
preflight = preflightExistingVite,
|
||||
prepareFrontend = prepareFrontendDev,
|
||||
spawnCli = spawnTauriCli,
|
||||
waitForCli = waitForChildTermination,
|
||||
terminateTree = terminateChildTree,
|
||||
@@ -65,9 +59,10 @@ async function runTauriDev(
|
||||
const endpoint = await resolveDevEndpoint();
|
||||
await preflight({ endpoint });
|
||||
|
||||
let child = null;
|
||||
let frontendChild = null;
|
||||
const preparationAbort = new AbortController();
|
||||
const tauriArguments = buildTauriArguments(argv, endpoint.url);
|
||||
const child = spawnCli(tauriArguments, {
|
||||
env: withAgcDevEndpointEnv(endpoint),
|
||||
});
|
||||
let resolveShutdown;
|
||||
let shutdownSignal = '';
|
||||
let repeatedSignal = false;
|
||||
@@ -81,47 +76,21 @@ async function runTauriDev(
|
||||
if (!shutdownSignal) {
|
||||
shutdownSignal = signal;
|
||||
stopChild(child, 'SIGTERM');
|
||||
stopChild(frontendChild, 'SIGTERM');
|
||||
preparationAbort.abort();
|
||||
resolveShutdown(signal);
|
||||
return;
|
||||
}
|
||||
repeatedSignal = true;
|
||||
stopChild(child, 'SIGKILL');
|
||||
stopChild(frontendChild, 'SIGKILL');
|
||||
};
|
||||
signalHandlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
|
||||
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(argv, endpoint.url);
|
||||
child = spawnCli(tauriArguments, {
|
||||
env: withAgcDevEndpointEnv(endpoint),
|
||||
});
|
||||
const childResult = waitForCli(child);
|
||||
const outcome = await Promise.race([
|
||||
childResult.then((failure) => ({ type: 'exit', failure })),
|
||||
shutdownRequested.then((signal) => ({ type: 'signal', signal })),
|
||||
...(frontendChild
|
||||
? [
|
||||
waitForChildTermination(frontendChild).then((failure) => ({
|
||||
type: 'frontend-exit',
|
||||
failure,
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
const cleanup = await terminateTree(child, {
|
||||
gracefulTimeoutMs: repeatedSignal ? 0 : 2500,
|
||||
@@ -136,51 +105,15 @@ async function runTauriDev(
|
||||
if (outcome.type === 'signal') {
|
||||
return 1;
|
||||
}
|
||||
if (outcome.type === 'frontend-exit') return 1;
|
||||
const { failure } = outcome;
|
||||
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
|
||||
} finally {
|
||||
for (const [signal, handler] of signalHandlers) {
|
||||
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() {
|
||||
return Boolean(
|
||||
process.argv[1] &&
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+9
-10
@@ -1,22 +1,21 @@
|
||||
---
|
||||
name: agc-web-game-development
|
||||
description: Build or modify a playable npm-managed Phaser 4 web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work.
|
||||
description: Build or modify a playable web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work.
|
||||
---
|
||||
|
||||
# AGC Web Game Development
|
||||
|
||||
Implement the user's actual game request in the current project as an npm-managed Phaser 4.2.1 game. Use Phaser scenes for gameplay and DOM only for deliberately external UI.
|
||||
Implement the user's actual game request in the current project. Choose DOM, Canvas, WebGL, or a combination based on the game rather than a fixed code template.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the existing package and source files before editing. New projects place `package.json`, `index.html`, `style.css`, and `game.js` under `game/`; existing root packages retain their layout. Run npm in that package directory (for example `npm --prefix game ci` and `npm --prefix game run build`).
|
||||
2. Keep `package.json` and `package-lock.json` authoritative. Import Phaser with `import Phaser from 'phaser'`; do not copy a bundle, add an import map, or use a CDN. Other npm dependencies are allowed when the game needs them.
|
||||
3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it.
|
||||
4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
|
||||
5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
|
||||
6. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art.
|
||||
7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally.
|
||||
8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
|
||||
1. Read the existing `index.html`, `style.css`, and `game.js` before modifying an existing game.
|
||||
2. Keep the entry self-contained and runnable from the AGC loopback preview. Avoid CDN-only dependencies and network-required runtime assets.
|
||||
3. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
|
||||
4. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
|
||||
5. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art.
|
||||
6. Avoid undefined animation callbacks, duplicate loops, stale event listeners, and state that survives restart unintentionally.
|
||||
7. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
|
||||
|
||||
When implementing a new game loop or a broad gameplay revision, read `references/game-quality-checklist.md`.
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "Web 游戏实现"
|
||||
short_description: "在当前项目内设计、实现并验证 npm 管理的 Phaser 4 游戏"
|
||||
short_description: "在当前项目内设计、实现并验证可玩的 HTML、CSS 与 JavaScript 游戏"
|
||||
default_prompt: "Use $agc-web-game-development to build or modify the current playable web game."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.12",
|
||||
"version": "2026-08-26.10",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-project-structure",
|
||||
@@ -57,7 +57,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/game-quality-checklist.md"
|
||||
],
|
||||
"sha256": "0649c72dd53e05ad7c87b28def1397c2badf61b0c308091196c40f7c48a8b36a"
|
||||
"sha256": "d7748d9ebf4324add0541daf16a2bbec09c4862b85af55bfb369c7f3b99aedff"
|
||||
},
|
||||
{
|
||||
"name": "agc-browser-playtest",
|
||||
|
||||
@@ -14,8 +14,6 @@ mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tools_mcp;
|
||||
@@ -40,8 +38,6 @@ pub(crate) use codex_cli::{
|
||||
pub(crate) use codex_provider_proxy::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_audit::*;
|
||||
pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user