Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96b44d8660 | |||
| 33308a93e8 | |||
| f6dad1950f | |||
| a4b103a1b0 | |||
| 05d455d4ef | |||
| d8b37e0da9 | |||
| 21c3bd7a2b | |||
| 756297d2df | |||
| e02811588c | |||
| e1780b21b0 | |||
| 6ed1fd26ed | |||
| 4912aa4df0 |
@@ -145,15 +145,6 @@ ELEVENLABS_BASE_URL="https://api.elevenlabs.io"
|
||||
ELEVENLABS_API_KEY=""
|
||||
ELEVENLABS_REQUEST_TIMEOUT_MS="180000"
|
||||
|
||||
# Tripo 3D 生成:地址与密钥必须显式给出,没有内置默认值。
|
||||
# 缺任意一个,会提交或会执行 3D job 的进程(api / external-generation-worker / all)
|
||||
# 在启动期直接拒绝启动——不会带着未知网关或空密钥把进程跑起来。
|
||||
TRIPO_BASE_URL="https://openapi.tripo3d.com/v3"
|
||||
TRIPO_API_KEY=""
|
||||
# 两个运行旋钮可缺省(沿用内置值);显式给出时会校验,非法即拒绝启动。
|
||||
TRIPO_REQUEST_TIMEOUT_MS="60000"
|
||||
TRIPO_RETRIES="2"
|
||||
|
||||
# 阿里云 OSS 配置。
|
||||
# Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量,
|
||||
# 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。
|
||||
|
||||
@@ -57,6 +57,8 @@ temp*build*/
|
||||
/apps/ai-game-creator-shell/logs/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/
|
||||
/apps/ai-game-creator-shell/.llm-drafts/
|
||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||
/apps/mobile-shell/.expo/
|
||||
|
||||
-84
@@ -67,90 +67,6 @@ _Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛
|
||||
|
||||
## Language
|
||||
|
||||
### 3D Model Generation
|
||||
|
||||
**3D 模型生成操作**:
|
||||
由文本提示或平台图片引用驱动、可异步查询并最终产生模型文件的 provider-neutral 操作;操作状态和产品资源结果分开建模。
|
||||
_Avoid_: 直接把第三方 provider task 当作产品资源、把模型 URL 当作永久资源地址
|
||||
|
||||
**Provider task**:
|
||||
第三方 3D 生成平台返回的任务引用与任务快照,只存在于 provider adapter 的 Rust 边界内;它不是 Genarrative 的内部操作 ID。
|
||||
_Avoid_: 把 Tripo task ID 当作稳定产品 operationId、让 SDK 类型穿透 api-server
|
||||
|
||||
**临时 provider URL**:
|
||||
第三方任务完成后返回的短时模型下载地址,必须通过显式下载流程转换为本地或持久化资源,不能直接当作长期资产地址。
|
||||
_Avoid_: 永久 OSS URL、资源 ID
|
||||
|
||||
**模型 artifact**:
|
||||
由 text-to-model、image-to-model 或 multiview-to-model 任务产生、待应用层登记和持久化的模型文件结果;provider adapter 只提供可下载结果,不负责生成 resourceId 或 assetId。
|
||||
_Avoid_: 仅 UI 中展示的模型链接、没有来源任务的静态文件
|
||||
|
||||
**Tripo API facade**:
|
||||
面向站内已认证调用方的全新 Tripo 3D 生成 API 上下文,负责把 API operation、用户归属、异步状态、产物持久化和正式资源引用连接起来;它独立于历史 Hyper3D adapter,不复用其路由、契约或实现。
|
||||
_Avoid_: 把 Tripo API 当成 Hyper3D 的 provider 替换、让 Hyper3D 路由悄悄切换到 Tripo
|
||||
|
||||
**Tripo operation**:
|
||||
Genarrative 为一次 Tripo 生成请求分配的内部异步操作标识;客户端只看到 operationId,Tripo provider task 只作为服务端 checkpoint 保存,二者不是同一个 ID。
|
||||
_Avoid_: 将 Tripo taskId 直接当公开 operationId、把 SDK task 状态模型穿透到 API
|
||||
|
||||
**模型产物持久化**:
|
||||
Tripo 任务完成后,服务端下载模型与预览、写入受控对象存储并登记资源元数据,成功后才把 Tripo operation 置为 completed;本期按完整字节写入,流式上传是后续目标。
|
||||
_Avoid_: 返回临时签名 URL作为永久资源、在 API handler 中把完整模型读成 Vec<u8>
|
||||
|
||||
**3D provider 配置门禁**:
|
||||
Tripo 网关与密钥的可用性在「会提交或会执行 3D job 的进程」(`api` / `external-generation-worker` / `all`)启动期判定:`TRIPO_BASE_URL` 与 `TRIPO_API_KEY` 没有内置默认值,缺失、空白或网关非法即拒绝启动;请求期返回 503 只是第二道防线,不承担发现部署缺失的职责。
|
||||
_Avoid_: 内置默认网关、缺配置时静默沿用某个地址或兜底密钥、把「少配置」留到用户提交后才发现
|
||||
|
||||
**3D 生成底价**:
|
||||
一次 3D 生成在指定端点和模型版本下、按是否带贴图区分的基准泥点价,不包含任何叠加项,也不随请求的其它参数变化。
|
||||
_Avoid_: 把底价与加价项合并成一个档位价、把 provider 的 credit 数值当底价
|
||||
|
||||
**3D 生成加价项**:
|
||||
叠加在底价之上、由请求参数判定的可选泥点加价;每个端点各自持有一份加价项价目,同一加价项在不同端点可以是不同价。
|
||||
_Avoid_: 全局唯一一份加价项价目、按用途而不是按请求参数判定加价
|
||||
|
||||
**定价配置归一化**:
|
||||
读取持久化定价时把它与当前契约对齐的过程:缺的键用受控默认值补齐,契约之外的遗留键剔除并告警;它只发生在读取方向,写入方向仍要求完整合法。
|
||||
_Avoid_: 把「省略即沿用」当作保存语义、把归一化当成静默改价
|
||||
|
||||
**模型生成落点**:
|
||||
3D 生成结果要进入的平台容器,调用方必须在工程资源与素材库资产之间显式二选一;工程分支可附带画布占位框回填,素材库分支必须给出目录与标签。
|
||||
_Avoid_: 两个都不给就默认落素材库、把落点混进 provider 请求参数
|
||||
|
||||
**模型输入资源引用**:
|
||||
image-to-model 请求对平台已有图片的明确 tagged 引用,可指向项目资源或素材库资产;服务端负责 owner 校验和 OSS 对象解析,不接受把多种来源塞进裸 `input` 字符串。
|
||||
_Avoid_: 任意远程 URL、data URL、未区分语义的 resourceId/assetId 字符串
|
||||
|
||||
**3D 模型生成工具**:
|
||||
图片画布工程里从底部工具栏打开、给出「文生 3D 模型」与「图生 3D 模型」两个子项的画布生成工具;提交后按画布既有生成链路的占位框、任务侧栏与失败退款口径推进,产物落成 **3D 模型资源**。
|
||||
_Avoid_: 独立 3D 生成页、把 3D 生成做成玩法类型或作品、把 3D 生成接进画布 Agent 对话
|
||||
|
||||
**3D 生成定价**:
|
||||
3D 模型生成工具在提交前展示的泥点价格,来自编辑器生成定价查询的 `model3d` 段;该段缺失即入口不可提交,前端不内置兜底数值。该段的权威事实源是 SpacetimeDB 定价表里的两段强类型列,公开读取时投影回迁移前的旧形状。
|
||||
_Avoid_: 前端写死 3D 价格、借用图片模型的兜底定价配置、按 0 元放行、把内部两段结构直接暴露给画布
|
||||
|
||||
**定价版本(乐观锁)**:
|
||||
后台读取完整模型定价时一并拿到的版本号(SpacetimeDB 行的更新时间微秒值),保存时必须原样回传;与服务端当前版本不一致即整笔拒绝并返回 409,提示重新读取,避免静默覆盖他人的改价。
|
||||
_Avoid_: 在前端自造或缓存该版本、把版本不匹配当成可重试的普通失败、把「省略字段」当成「沿用当前值」
|
||||
|
||||
### 3D 模型资源预览
|
||||
|
||||
**3D 模型资源**:
|
||||
类别为 `model3d` 的平台资源 / 素材,一个资源同时持有模型本体与模型预览图两个对象;客户端默认消费预览图,模型本体只在 3D 查看器里加载。
|
||||
_Avoid_: 只有模型文件的资源、把模型本体当图片素材、为模型新增并列媒体类型
|
||||
|
||||
**模型预览图**:
|
||||
3D 模型资源的静态代表图,落在资源投影的 `imageSrc` 与 `thumbnailSrc` 上,供画布卡片、素材缩略图、后台列表和精选卡片使用。
|
||||
_Avoid_: 用模型本体充当预览、按需实时渲染缩略图、把预览图当模型真相
|
||||
|
||||
**模型本体**:
|
||||
3D 模型资源里的可下载模型文件,只由资源的 `objectKey` 指向,格式以对象内容类型为准。
|
||||
_Avoid_: 把模型本体当图片地址、用预览图字段承载模型文件、按文件扩展名推断格式
|
||||
|
||||
**3D 查看器预览**:
|
||||
在资源上打开模态把模型本体交给 3D 查看器渲染的只读能力,不修改资源、不产出新资源,也不是画布图层本身的编辑操作。
|
||||
_Avoid_: 在画布里内联渲染模型、把预览当编辑、为预览产出新资源
|
||||
|
||||
### Puzzle Clear
|
||||
|
||||
**拼消消**:
|
||||
|
||||
@@ -60,7 +60,6 @@ import type {
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorGenerationPricingRequest,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
@@ -520,7 +519,7 @@ export function getAdminEditorGenerationPricing(token: string) {
|
||||
|
||||
export function upsertAdminEditorGenerationPricing(
|
||||
token: string,
|
||||
payload: AdminUpsertEditorGenerationPricingRequest,
|
||||
payload: EditorGenerationPricingConfigPayload,
|
||||
) {
|
||||
return request<EditorGenerationPricingConfigPayload>(
|
||||
'/admin/api/editor-generation-pricing',
|
||||
|
||||
@@ -431,43 +431,8 @@ export interface EditorGenerationModelPricingPayload {
|
||||
prices?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 某个 3D 模型版本在「无贴图 / 带贴图」两种形态下的泥点底价。 */
|
||||
export interface EditorGenerationModel3dVersionPricePayload {
|
||||
noTexture: number;
|
||||
texture: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个 3D 生成端点的整套定价:模型版本底价 + 该端点自己的加价项价目。
|
||||
*
|
||||
* 键集合由后端契约决定,后台只改数值:模型版本键与加价项键都直接来自接口返回,
|
||||
* 不在前端硬编码清单,也不能新增、删除或停用。
|
||||
*/
|
||||
export interface EditorGenerationModel3dEndpointPricingPayload {
|
||||
versionPrices: Record<string, EditorGenerationModel3dVersionPricePayload>;
|
||||
addOnPrices: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 3D 生成定价段:两个端点并列,各持自己的设置。 */
|
||||
export interface EditorGenerationModel3dPricingPayload {
|
||||
textToModelPricing: EditorGenerationModel3dEndpointPricingPayload;
|
||||
imageToModelPricing: EditorGenerationModel3dEndpointPricingPayload;
|
||||
}
|
||||
|
||||
export interface EditorGenerationPricingConfigPayload {
|
||||
models: Record<string, EditorGenerationModelPricingPayload>;
|
||||
/** 后端尚未配置 3D 段时缺省;缺失即 3D 生成不可提交,后台也不能新建整段。 */
|
||||
model3d?: EditorGenerationModel3dPricingPayload | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出。
|
||||
*
|
||||
* 不带定价版本:保存即整段覆盖,服务端不比对版本(后台同时只有一个管理员在操作)。
|
||||
*/
|
||||
export interface AdminUpsertEditorGenerationPricingRequest {
|
||||
models: Record<string, EditorGenerationModelPricingPayload>;
|
||||
model3d: EditorGenerationModel3dPricingPayload | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListQuery {
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import type {
|
||||
EditorGenerationModel3dPricingPayload,
|
||||
EditorGenerationModel3dVersionPricePayload,
|
||||
} from '../api/adminApiTypes';
|
||||
import {
|
||||
EDITOR_MODEL3D_ENDPOINT_SECTIONS,
|
||||
editorModel3dAddOnLabel,
|
||||
updateEditorModel3dEndpointPricing,
|
||||
} from './adminEditorGenerationPricing';
|
||||
import { parsePositiveInteger } from './pageUtils';
|
||||
|
||||
/**
|
||||
* 一个模型版本的两个底价字段。两栏只有字段键与展示名不同,用描述表驱动渲染,
|
||||
* 避免两段近乎相同的 JSX 各自演化。
|
||||
*/
|
||||
const VERSION_PRICE_FIELDS = [
|
||||
{ key: 'noTexture', label: '无贴图' },
|
||||
{ key: 'texture', label: '带贴图' },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: keyof EditorGenerationModel3dVersionPricePayload;
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
interface AdminEditorGenerationModel3dPricingSectionProps {
|
||||
model3d: EditorGenerationModel3dPricingPayload;
|
||||
onChange: (next: EditorGenerationModel3dPricingPayload) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D 生成定价区块:按端点分成「文生 3D」与「图生 3D」两组,每组是该端点自己的
|
||||
* 「模型版本 × {无贴图, 带贴图}」底价表与加价项单值输入。
|
||||
*
|
||||
* 键集合全部来自接口返回,后台只能改数值:不能新增、删除键,也不能停用整段。
|
||||
*/
|
||||
export function AdminEditorGenerationModel3dPricingSection({
|
||||
model3d,
|
||||
onChange,
|
||||
}: AdminEditorGenerationModel3dPricingSectionProps) {
|
||||
function updateEndpoint(
|
||||
section: (typeof EDITOR_MODEL3D_ENDPOINT_SECTIONS)[number]['key'],
|
||||
updater: Parameters<typeof updateEditorModel3dEndpointPricing>[2],
|
||||
) {
|
||||
onChange(updateEditorModel3dEndpointPricing(model3d, section, updater));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-panel admin-pricing-model-card">
|
||||
<div className="admin-pricing-model-heading">
|
||||
<strong>3D 生成定价</strong>
|
||||
<span className="admin-pricing-unit">按次</span>
|
||||
</div>
|
||||
<p className="admin-pricing-model3d-hint">
|
||||
模型版本与加价项由后端契约给出,只能改数值;两段必须完整给出,缺失即 3D
|
||||
生成不可提交。
|
||||
</p>
|
||||
<div className="admin-pricing-grid">
|
||||
{EDITOR_MODEL3D_ENDPOINT_SECTIONS.map(({ key, label }) => (
|
||||
<section className="admin-panel admin-pricing-model-card" key={key}>
|
||||
<div className="admin-pricing-model-heading">
|
||||
<strong>{label}</strong>
|
||||
</div>
|
||||
<div className="admin-pricing-model3d-list">
|
||||
{Object.entries(model3d[key].versionPrices).map(
|
||||
([version, price]) => (
|
||||
<div className="admin-pricing-model3d-row" key={version}>
|
||||
<span className="admin-pricing-model3d-row-label">
|
||||
{version}
|
||||
</span>
|
||||
{VERSION_PRICE_FIELDS.map(
|
||||
({ key: field, label: fieldLabel }) => (
|
||||
<label className="admin-field" key={field}>
|
||||
<span>{fieldLabel}</span>
|
||||
<input
|
||||
aria-label={`${label} ${version} ${fieldLabel}`}
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={price[field]}
|
||||
onChange={(event) =>
|
||||
updateEndpoint(key, (current) => ({
|
||||
...current,
|
||||
versionPrices: {
|
||||
...current.versionPrices,
|
||||
[version]: {
|
||||
...price,
|
||||
[field]: parsePositiveInteger(
|
||||
event.target.value,
|
||||
),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-form-row">
|
||||
{Object.entries(model3d[key].addOnPrices).map(
|
||||
([addOn, price]) => (
|
||||
<label className="admin-field" key={addOn}>
|
||||
<span>{editorModel3dAddOnLabel(addOn)}</span>
|
||||
<input
|
||||
aria-label={`${label} ${editorModel3dAddOnLabel(addOn)}`}
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={price}
|
||||
onChange={(event) =>
|
||||
updateEndpoint(key, (current) => ({
|
||||
...current,
|
||||
addOnPrices: {
|
||||
...current.addOnPrices,
|
||||
[addOn]: parsePositiveInteger(event.target.value),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,16 +11,14 @@ import {
|
||||
import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes';
|
||||
import { AdminEditorGenerationPricingPage } from './AdminEditorGenerationPricingPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('../api/adminApiClient')>(
|
||||
'../api/adminApiClient',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getAdminEditorGenerationPricing: vi.fn(),
|
||||
upsertAdminEditorGenerationPricing: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
getAdminEditorGenerationPricing: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
upsertAdminEditorGenerationPricing: vi.fn(),
|
||||
}));
|
||||
|
||||
const pricing: EditorGenerationPricingConfigPayload = {
|
||||
models: {
|
||||
@@ -43,78 +41,36 @@ const pricing: EditorGenerationPricingConfigPayload = {
|
||||
'audio1.0': { unit: 'perGeneration', price: 5 },
|
||||
'chirp-v5': { unit: 'perGeneration', price: 12 },
|
||||
},
|
||||
model3d: {
|
||||
textToModelPricing: {
|
||||
versionPrices: {
|
||||
'v3.1-20260211': { noTexture: 8, texture: 16 },
|
||||
'P2-20260801': { noTexture: 80, texture: 88 },
|
||||
},
|
||||
addOnPrices: {
|
||||
hdTexture: 8,
|
||||
ultraTexture: 16,
|
||||
quadMesh: 4,
|
||||
generateParts: 16,
|
||||
},
|
||||
},
|
||||
imageToModelPricing: {
|
||||
versionPrices: {
|
||||
'v3.1-20260211': { noTexture: 16, texture: 24 },
|
||||
'P2-20260801': { noTexture: 80, texture: 88 },
|
||||
},
|
||||
addOnPrices: {
|
||||
hdTexture: 8,
|
||||
ultraTexture: 16,
|
||||
quadMesh: 4,
|
||||
generateParts: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function savedPricing(
|
||||
overrides: Partial<EditorGenerationPricingConfigPayload> = {},
|
||||
) {
|
||||
return {
|
||||
...pricing,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue(pricing);
|
||||
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue(
|
||||
savedPricing({
|
||||
models: {
|
||||
...pricing.models,
|
||||
'gpt-image-2': {
|
||||
unit: 'perGeneration',
|
||||
prices: { '1K': 20, '2K': 58 },
|
||||
},
|
||||
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue({
|
||||
...pricing,
|
||||
models: {
|
||||
...pricing.models,
|
||||
'gpt-image-2': {
|
||||
unit: 'perGeneration',
|
||||
prices: { '1K': 20, '2K': 58 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
async function renderPage() {
|
||||
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminEditorGenerationPricingPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('3D 生成定价');
|
||||
}
|
||||
|
||||
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
|
||||
expect((await screen.findAllByText('按次')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('按秒').length).toBeGreaterThan(0);
|
||||
fireEvent.change(screen.getByLabelText('gpt-image-2 2K'), {
|
||||
target: { value: '58' },
|
||||
});
|
||||
const gptImage2kInput = screen.getByLabelText('gpt-image-2 2K');
|
||||
fireEvent.change(gptImage2kInput, { target: { value: '58' } });
|
||||
await user.click(screen.getByRole('button', { name: '保存定价' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
@@ -134,82 +90,3 @@ test('模型定价后台按模型展示单位并保存尺寸定价', async () =>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('3D 区块按端点渲染版本底价与加价项,并随保存回传整段与定价版本', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
|
||||
expect(screen.getByText('文生 3D')).toBeTruthy();
|
||||
expect(screen.getByText('图生 3D')).toBeTruthy();
|
||||
expect(screen.getAllByText('高清贴图').length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('文生 3D v3.1-20260211 带贴图'), {
|
||||
target: { value: '18' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('图生 3D P2-20260801 无贴图'), {
|
||||
target: { value: '86' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), {
|
||||
target: { value: '6' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存定价' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminEditorGenerationPricing).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({
|
||||
model3d: {
|
||||
textToModelPricing: expect.objectContaining({
|
||||
versionPrices: expect.objectContaining({
|
||||
'v3.1-20260211': { noTexture: 8, texture: 18 },
|
||||
}),
|
||||
addOnPrices: expect.objectContaining({ quadMesh: 6 }),
|
||||
}),
|
||||
imageToModelPricing: expect.objectContaining({
|
||||
versionPrices: expect.objectContaining({
|
||||
'P2-20260801': { noTexture: 86, texture: 88 },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('数值小于 1 时禁用保存并给出中文提示', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), {
|
||||
target: { value: '0' },
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/以下价格必须是不小于 1 的整数/)).toBeTruthy();
|
||||
const saveButton = screen.getByRole('button', {
|
||||
name: '保存定价',
|
||||
}) as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
await user.click(saveButton);
|
||||
expect(upsertAdminEditorGenerationPricing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('后端未配置 3D 段时给出提示并禁止保存', async () => {
|
||||
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue({
|
||||
...pricing,
|
||||
model3d: null,
|
||||
});
|
||||
render(
|
||||
<AdminEditorGenerationPricingPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/当前部署未配置 3D 定价段/)).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '保存定价' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,18 +6,12 @@ import {
|
||||
upsertAdminEditorGenerationPricing,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
EditorGenerationModel3dPricingPayload,
|
||||
EditorGenerationModelPricingPayload,
|
||||
EditorGenerationPricingConfigPayload,
|
||||
EditorGenerationPricingUnitPayload,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { AdminEditorGenerationModel3dPricingSection } from './AdminEditorGenerationModel3dPricingSection';
|
||||
import {
|
||||
buildAdminEditorGenerationPricingRequest,
|
||||
collectInvalidEditorGenerationPricingLabels,
|
||||
} from './adminEditorGenerationPricing';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminEditorGenerationPricingPageProps {
|
||||
token: string;
|
||||
@@ -26,7 +20,6 @@ interface AdminEditorGenerationPricingPageProps {
|
||||
|
||||
const emptyPricing: EditorGenerationPricingConfigPayload = {
|
||||
models: {},
|
||||
model3d: null,
|
||||
};
|
||||
|
||||
const unitLabels: Record<EditorGenerationPricingUnitPayload, string> = {
|
||||
@@ -64,7 +57,7 @@ export function AdminEditorGenerationPricingPage({
|
||||
|
||||
async function handleSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isSaving || !canSave) {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage('');
|
||||
@@ -78,12 +71,7 @@ export function AdminEditorGenerationPricingPage({
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
setPricing(
|
||||
await upsertAdminEditorGenerationPricing(
|
||||
token,
|
||||
buildAdminEditorGenerationPricingRequest(pricing),
|
||||
),
|
||||
);
|
||||
setPricing(await upsertAdminEditorGenerationPricing(token, pricing));
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -132,15 +120,6 @@ export function AdminEditorGenerationPricingPage({
|
||||
});
|
||||
}
|
||||
|
||||
function updateModel3dPrice(next: EditorGenerationModel3dPricingPayload) {
|
||||
setPricing((current) => ({ ...current, model3d: next }));
|
||||
}
|
||||
|
||||
const invalidPriceLabels =
|
||||
collectInvalidEditorGenerationPricingLabels(pricing);
|
||||
const isModel3dMissing = !pricing.model3d;
|
||||
const canSave = invalidPriceLabels.length === 0 && !isModel3dMissing;
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
@@ -165,20 +144,6 @@ export function AdminEditorGenerationPricingPage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isModel3dMissing ? (
|
||||
<div className="admin-alert" role="status">
|
||||
当前部署未配置 3D 定价段(缺失即 3D
|
||||
生成不可提交):需先由受控默认配置提供两段价格,
|
||||
后台不在页面里新建或停用整段。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{invalidPriceLabels.length > 0 ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{`以下价格必须是不小于 1 的整数:${invalidPriceLabels.join('、')}`}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="admin-stack" onSubmit={handleSave}>
|
||||
<div className="admin-pricing-model-list admin-pricing-model-list--single">
|
||||
{Object.entries(pricing.models).map(([model, modelPricing]) =>
|
||||
@@ -191,16 +156,9 @@ export function AdminEditorGenerationPricingPage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pricing.model3d ? (
|
||||
<AdminEditorGenerationModel3dPricingSection
|
||||
model3d={pricing.model3d}
|
||||
onChange={updateModel3dPrice}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={isSaving || !canSave}
|
||||
disabled={isSaving}
|
||||
type="submit"
|
||||
>
|
||||
<Save size={17} aria-hidden="true" />
|
||||
@@ -265,3 +223,8 @@ function renderModelPricingCard({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '../api/adminApiClient';
|
||||
import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminProfileWalletConfigPageProps {
|
||||
token: string;
|
||||
@@ -197,3 +197,8 @@ export function AdminProfileWalletConfigPage({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
ProfileRechargeProductKind,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminRechargeProductPageProps {
|
||||
token: string;
|
||||
@@ -546,6 +546,11 @@ function formatPrice(priceCents: number) {
|
||||
return `¥${(priceCents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = parseInteger(value);
|
||||
return parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value: string) {
|
||||
const parsed = parseInteger(value);
|
||||
return parsed > 0 ? parsed : 0;
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
ProfileRedeemCodeMode,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError, parsePositiveInteger, splitLines } from './pageUtils';
|
||||
import { handlePageError, splitLines } from './pageUtils';
|
||||
|
||||
interface AdminRedeemCodePageProps {
|
||||
token: string;
|
||||
@@ -422,6 +422,11 @@ export function AdminRedeemCodePage({
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function redeemModeLabel(value: ProfileRedeemCodeMode) {
|
||||
return redeemModes.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
filterAdminTrackingEventKeyOptions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from '../config/trackingEventDefinitions';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminTaskConfigPageProps {
|
||||
token: string;
|
||||
@@ -571,6 +571,11 @@ export function AdminTaskConfigPage({
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function parseInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes';
|
||||
import {
|
||||
buildAdminEditorGenerationPricingRequest,
|
||||
collectInvalidEditorGenerationPricingLabels,
|
||||
editorModel3dAddOnLabel,
|
||||
} from './adminEditorGenerationPricing';
|
||||
import { parsePositiveInteger } from './pageUtils';
|
||||
|
||||
function pricingFixture(): EditorGenerationPricingConfigPayload {
|
||||
return {
|
||||
models: {
|
||||
'gpt-image-2': { unit: 'perGeneration', prices: { '1K': 3, '2K': 5 } },
|
||||
'audio1.0': { unit: 'perGeneration', price: 5 },
|
||||
},
|
||||
model3d: {
|
||||
textToModelPricing: {
|
||||
versionPrices: { 'v3.1-20260211': { noTexture: 8, texture: 16 } },
|
||||
addOnPrices: { hdTexture: 8, unknownAddOn: 7 },
|
||||
},
|
||||
imageToModelPricing: {
|
||||
versionPrices: { 'v3.1-20260211': { noTexture: 16, texture: 24 } },
|
||||
addOnPrices: { hdTexture: 8 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('数值解析把空串、非数字、小数与 0 收敛成 0', () => {
|
||||
expect(parsePositiveInteger('12')).toBe(12);
|
||||
expect(parsePositiveInteger('')).toBe(0);
|
||||
expect(parsePositiveInteger('abc')).toBe(0);
|
||||
expect(parsePositiveInteger('12abc')).toBe(0);
|
||||
expect(parsePositiveInteger('3.9')).toBe(0);
|
||||
expect(parsePositiveInteger('0')).toBe(0);
|
||||
expect(parsePositiveInteger('-3')).toBe(0);
|
||||
});
|
||||
|
||||
test('未登记的 3D 加价项键回退显示原键名', () => {
|
||||
expect(editorModel3dAddOnLabel('hdTexture')).toBe('高清贴图');
|
||||
expect(editorModel3dAddOnLabel('unknownAddOn')).toBe('unknownAddOn');
|
||||
});
|
||||
|
||||
test('校验覆盖模型档位与 3D 两段的全部数值', () => {
|
||||
expect(collectInvalidEditorGenerationPricingLabels(pricingFixture())).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
const invalid = pricingFixture();
|
||||
const invalidImagePricing = invalid.models['gpt-image-2'];
|
||||
const invalidTextEndpoint = invalid.model3d?.textToModelPricing;
|
||||
const invalidImageEndpoint = invalid.model3d?.imageToModelPricing;
|
||||
if (
|
||||
!invalidImagePricing?.prices ||
|
||||
!invalidTextEndpoint ||
|
||||
!invalidImageEndpoint
|
||||
) {
|
||||
throw new Error('夹具必须包含图片档位与 3D 两段');
|
||||
}
|
||||
const imageVersionPrice = invalidImageEndpoint.versionPrices['v3.1-20260211'];
|
||||
if (!imageVersionPrice) {
|
||||
throw new Error('夹具必须包含图生 3D 的版本底价');
|
||||
}
|
||||
invalidImagePricing.prices['2K'] = 0;
|
||||
invalidTextEndpoint.addOnPrices.hdTexture = 0;
|
||||
imageVersionPrice.noTexture = 0;
|
||||
expect(collectInvalidEditorGenerationPricingLabels(invalid)).toEqual([
|
||||
'gpt-image-2 2K',
|
||||
'文生 3D 高清贴图',
|
||||
'图生 3D v3.1-20260211 无贴图',
|
||||
]);
|
||||
});
|
||||
|
||||
test('保存请求整段回传两段且不带定价版本,3D 缺失时回传 null', () => {
|
||||
const pricing = pricingFixture();
|
||||
expect(buildAdminEditorGenerationPricingRequest(pricing)).toEqual({
|
||||
models: pricing.models,
|
||||
model3d: pricing.model3d,
|
||||
});
|
||||
expect(
|
||||
buildAdminEditorGenerationPricingRequest({ ...pricing, model3d: null }),
|
||||
).toEqual({
|
||||
models: pricing.models,
|
||||
model3d: null,
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import type {
|
||||
AdminUpsertEditorGenerationPricingRequest,
|
||||
EditorGenerationModel3dEndpointPricingPayload,
|
||||
EditorGenerationModel3dPricingPayload,
|
||||
EditorGenerationPricingConfigPayload,
|
||||
} from '../api/adminApiTypes';
|
||||
|
||||
/** 3D 端点在两个并列段里的字段名与后台展示名。 */
|
||||
export const EDITOR_MODEL3D_ENDPOINT_SECTIONS = [
|
||||
{ key: 'textToModelPricing', label: '文生 3D' },
|
||||
{ key: 'imageToModelPricing', label: '图生 3D' },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: keyof EditorGenerationModel3dPricingPayload;
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 3D 加价项键到中文标签。键来自接口返回,这里只负责展示;遇到未登记的键回退显示原键名,
|
||||
* 不因为标签缺失就把该项漏掉。
|
||||
*/
|
||||
const EDITOR_MODEL3D_ADD_ON_LABELS: Record<string, string> = {
|
||||
hdTexture: '高清贴图',
|
||||
ultraTexture: '超高清贴图',
|
||||
hdGeometry: '高清几何',
|
||||
quadMesh: '四边形网格',
|
||||
smartLowPoly: '智能低面',
|
||||
generateParts: '分件生成',
|
||||
};
|
||||
|
||||
export function editorModel3dAddOnLabel(addOn: string) {
|
||||
return EDITOR_MODEL3D_ADD_ON_LABELS[addOn] ?? addOn;
|
||||
}
|
||||
|
||||
export function isPositiveMudPoints(value: number | null | undefined) {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交前校验:所有泥点数值都必须是不小于 1 的整数,返回不合法项的展示名。
|
||||
*
|
||||
* 后端仍会独立校验一遍;这里只是不把明显非法的 payload 发出去,避免保存后才报错。
|
||||
*/
|
||||
export function collectInvalidEditorGenerationPricingLabels(
|
||||
pricing: EditorGenerationPricingConfigPayload,
|
||||
) {
|
||||
const labels: string[] = [];
|
||||
for (const [model, modelPricing] of Object.entries(pricing.models)) {
|
||||
const tieredPrices = modelPricing.prices;
|
||||
// `prices: {}` 没有档位可校验,不能算「这一项走分层计价」——否则底价会被顺手跳过校验。
|
||||
if (tieredPrices && Object.keys(tieredPrices).length > 0) {
|
||||
for (const [tier, price] of Object.entries(tieredPrices)) {
|
||||
if (!isPositiveMudPoints(price)) {
|
||||
labels.push(`${model} ${tier}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isPositiveMudPoints(modelPricing.price)) {
|
||||
labels.push(`${model} 泥点`);
|
||||
}
|
||||
}
|
||||
|
||||
const model3d = pricing.model3d;
|
||||
if (model3d) {
|
||||
for (const { key, label } of EDITOR_MODEL3D_ENDPOINT_SECTIONS) {
|
||||
// 段与子表都来自接口返回:缺段或缺子表时只跳过这一段,不能让整页校验崩掉。
|
||||
const endpointPricing = model3d[key];
|
||||
if (!endpointPricing) {
|
||||
continue;
|
||||
}
|
||||
for (const [version, price] of Object.entries(
|
||||
endpointPricing.versionPrices ?? {},
|
||||
)) {
|
||||
if (!isPositiveMudPoints(price.noTexture)) {
|
||||
labels.push(`${label} ${version} 无贴图`);
|
||||
}
|
||||
if (!isPositiveMudPoints(price.texture)) {
|
||||
labels.push(`${label} ${version} 带贴图`);
|
||||
}
|
||||
}
|
||||
for (const [addOn, price] of Object.entries(
|
||||
endpointPricing.addOnPrices ?? {},
|
||||
)) {
|
||||
if (!isPositiveMudPoints(price)) {
|
||||
labels.push(`${label} ${editorModel3dAddOnLabel(addOn)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装保存请求:`models` 与 3D 两段整段回传,不带定价版本(保存即覆盖,服务端不比对版本)。
|
||||
* 3D 段缺失时回传 `null`——服务端会以 400 拒绝,后台不允许在这里静默新建或丢弃整段。
|
||||
*/
|
||||
export function buildAdminEditorGenerationPricingRequest(
|
||||
pricing: EditorGenerationPricingConfigPayload,
|
||||
): AdminUpsertEditorGenerationPricingRequest {
|
||||
return {
|
||||
models: pricing.models,
|
||||
model3d: pricing.model3d ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 单个端点段的不可变更新:底价与加价项各改一处,其余原样保留。 */
|
||||
export function updateEditorModel3dEndpointPricing(
|
||||
model3d: EditorGenerationModel3dPricingPayload,
|
||||
section: keyof EditorGenerationModel3dPricingPayload,
|
||||
updater: (
|
||||
current: EditorGenerationModel3dEndpointPricingPayload,
|
||||
) => EditorGenerationModel3dEndpointPricingPayload,
|
||||
): EditorGenerationModel3dPricingPayload {
|
||||
return { ...model3d, [section]: updater(model3d[section]) };
|
||||
}
|
||||
@@ -1,18 +1,5 @@
|
||||
import { formatAdminApiError, isAdminApiError } from '../api/adminApiClient';
|
||||
|
||||
/**
|
||||
* 泥点 / 积分类输入框文本转正整数:空串、非数字、小数、0 与负数一律收敛成 0,
|
||||
* 由各页的提交前校验拦住。
|
||||
*
|
||||
* 用 `Number` 而不是 `parseInt`:`parseInt` 会把 `3.9` 截成 3、`12abc` 截成 12,
|
||||
* 等于把编辑中的半截输入静默当成合法值存下去;再用 `isSafeInteger` 挡掉超出安全整数
|
||||
* 范围的值(那种值在别处会被静默改写)。后台各页统一用这一份,不再各写一遍。
|
||||
*/
|
||||
export function parsePositiveInteger(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
export function handlePageError(
|
||||
error: unknown,
|
||||
onUnauthorized: (message?: string) => void,
|
||||
|
||||
@@ -2972,34 +2972,6 @@ button:disabled {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) repeat(2, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-row-label {
|
||||
color: #5c3a26;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-hint {
|
||||
color: #7b5a45;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-pricing-unit {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
@@ -3050,7 +3022,6 @@ button:disabled {
|
||||
.admin-two-column-wide,
|
||||
.admin-accounts-layout,
|
||||
.admin-pricing-grid,
|
||||
.admin-pricing-model3d-row,
|
||||
.admin-form-row,
|
||||
.admin-filter-grid,
|
||||
.admin-table-query-grid,
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
"skill-pack:check": "node scripts/check-skill-pack.mjs",
|
||||
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
||||
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
||||
"bundled-resources:check": "node scripts/check-package-layout.mjs",
|
||||
"bundled-resources:sync": "node scripts/check-package-layout.mjs --write",
|
||||
"bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs",
|
||||
"bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs",
|
||||
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
|
||||
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
|
||||
"config": "node scripts/game-creator-config-wizard.mjs",
|
||||
@@ -24,7 +28,7 @@
|
||||
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
|
||||
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
|
||||
"agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs"
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cubone/react-file-manager": "^1.35.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveReleaseChannel,
|
||||
} from './channel-identity.mjs';
|
||||
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import { prepareBundledResources } from './prepare-bundled-resources.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -431,10 +432,29 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。
|
||||
export function stageBundledResources(
|
||||
target,
|
||||
{ prepare = prepareBundledResources } = {},
|
||||
) {
|
||||
const summaries = prepare({
|
||||
target,
|
||||
features: new Set(defaultEditorFeatures(target)),
|
||||
log: (line) => console.log(`[ai-game-creator-shell] ${line}`),
|
||||
});
|
||||
for (const summary of summaries) {
|
||||
console.log(`[ai-game-creator-shell] ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function runTauriBuild(
|
||||
args = [],
|
||||
context = resolveReleaseContext(args),
|
||||
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
|
||||
{
|
||||
spawn = spawnSync,
|
||||
stageRuntime = stageNodeRuntime,
|
||||
stageBundled = stageBundledResources,
|
||||
} = {},
|
||||
) {
|
||||
if (
|
||||
explicitBuildTarget(args) &&
|
||||
@@ -444,7 +464,10 @@ export function runTauriBuild(
|
||||
}
|
||||
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
||||
const { channel, target } = context;
|
||||
if (!args.includes('--no-bundle')) stageRuntime(target);
|
||||
if (!args.includes('--no-bundle')) {
|
||||
stageRuntime(target);
|
||||
stageBundled(target);
|
||||
}
|
||||
const configPath = writeChannelConfigFile(
|
||||
channel,
|
||||
target,
|
||||
|
||||
@@ -363,6 +363,8 @@ test('packaged renderer receives the same channel as the updater manifest', () =
|
||||
// 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的
|
||||
// Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。
|
||||
stageRuntime: () => {},
|
||||
// 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, _args, options) => {
|
||||
spawnOptions = options;
|
||||
return { status: 0 };
|
||||
@@ -456,6 +458,8 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
|
||||
seenContexts.push(context);
|
||||
runTauriBuild(args, context, {
|
||||
stageRuntime: () => {},
|
||||
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const configIndex = command.lastIndexOf('--config');
|
||||
const config = JSON.parse(
|
||||
@@ -705,6 +709,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
||||
context,
|
||||
{
|
||||
stageRuntime: () => {},
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
assert.ok(
|
||||
command.includes(
|
||||
@@ -818,6 +823,10 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
assert.equal(target, windowsTarget);
|
||||
events.push('stage');
|
||||
},
|
||||
stageBundled(target) {
|
||||
assert.equal(target, windowsTarget);
|
||||
events.push('bundled');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
events.push('build');
|
||||
const config = JSON.parse(
|
||||
@@ -829,11 +838,14 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, ['stage', 'build']);
|
||||
assert.deepEqual(events, ['stage', 'bundled', 'build']);
|
||||
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
|
||||
stageRuntime() {
|
||||
assert.fail('no-bundle must not stage resources');
|
||||
},
|
||||
stageBundled() {
|
||||
assert.fail('no-bundle must not stage bundled resources');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
const config = JSON.parse(
|
||||
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
|
||||
@@ -848,6 +860,9 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
stageRuntime() {
|
||||
throw new Error('missing runtime');
|
||||
},
|
||||
stageBundled() {
|
||||
assert.fail('invalid runtime must prevent bundled staging');
|
||||
},
|
||||
spawn() {
|
||||
assert.fail('invalid runtime must prevent build');
|
||||
},
|
||||
@@ -960,6 +975,8 @@ for (const channel of ['release', 'beta-2']) {
|
||||
);
|
||||
runTauriBuild([`--target=${target}`], context, {
|
||||
stageRuntime: () => {},
|
||||
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(
|
||||
|
||||
+512
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
DECLARATION_PATH,
|
||||
findCodexSource,
|
||||
pluginDirectories,
|
||||
prepareBundledResources,
|
||||
readDeclaration,
|
||||
resolveHostTarget,
|
||||
stagingUnit,
|
||||
} from './prepare-bundled-resources.mjs';
|
||||
|
||||
const WINDOWS_TARGET = 'x86_64-pc-windows-msvc';
|
||||
const MAC_TARGET = 'aarch64-apple-darwin';
|
||||
|
||||
function sha256File(file) {
|
||||
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
/// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。
|
||||
function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-'));
|
||||
const appRoot = path.join(root, 'app');
|
||||
const repoRoot = path.join(root, 'repo');
|
||||
const destinationRoot = path.join(appRoot, 'src-tauri');
|
||||
const declaration = readDeclaration(DECLARATION_PATH);
|
||||
const lockfile = { packages: {} };
|
||||
|
||||
for (const target of targets) {
|
||||
const layout = declaration.codex.targets.find(
|
||||
(entry) => entry.target === target,
|
||||
);
|
||||
assert.ok(layout, `声明缺少目标 ${target}`);
|
||||
const vendor = path.join(
|
||||
appRoot,
|
||||
`node_modules/@openai/codex-${layout.platform}/vendor/${target}`,
|
||||
);
|
||||
for (const relative of layout.files) {
|
||||
const file = path.join(vendor, relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
relative === declaration.codex.packageMetadataFileName
|
||||
? `${JSON.stringify(
|
||||
{
|
||||
layoutVersion: declaration.codex.packageMetadata.layoutVersion,
|
||||
version: declaration.codex.version,
|
||||
target,
|
||||
entrypoint: layout.executable,
|
||||
resourcesDir: declaration.codex.packageMetadata.resourcesDir,
|
||||
pathDir: declaration.codex.packageMetadata.pathDir,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`
|
||||
: `component ${target} ${relative}\n`,
|
||||
);
|
||||
if (relative === layout.executable) {
|
||||
fs.chmodSync(file, 0o755);
|
||||
}
|
||||
}
|
||||
lockfile.packages[`node_modules/@openai/codex-${layout.platform}`] = {
|
||||
resolved: `https://registry.npmjs.org/@openai/codex-${layout.platform}/-/${layout.platform}.tgz`,
|
||||
integrity: `sha512-${target}`,
|
||||
};
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.join(destinationRoot, 'resources/codex'), {
|
||||
recursive: true,
|
||||
});
|
||||
for (const entry of declaration.codex.noticeSources) {
|
||||
if (entry.preserve) {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(destinationRoot, entry.source);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, 'mac codex notice\n');
|
||||
}
|
||||
if (targets.includes(WINDOWS_TARGET)) {
|
||||
const tracked = path.join(
|
||||
destinationRoot,
|
||||
'resources/codex/win-x64/NOTICE.md',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(tracked), { recursive: true });
|
||||
fs.writeFileSync(tracked, 'windows codex notice\n');
|
||||
}
|
||||
|
||||
if (plugins) {
|
||||
const pluginRoot = path.join(repoRoot, 'plugins/agc-demo-editor');
|
||||
fs.mkdirSync(path.join(pluginRoot, 'src'), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginRoot, 'panels'), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginRoot, 'target'), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginRoot, '.git'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, 'plugin.json'),
|
||||
'{"name":"agc-demo-editor"}\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, 'src/entry.mjs'),
|
||||
'export const entry = 1;\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, 'panels/panel.html'),
|
||||
'<html></html>\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginRoot, 'panels/panel.test.mjs'), 'test\n');
|
||||
fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n');
|
||||
fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n');
|
||||
fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n');
|
||||
}
|
||||
|
||||
const lockfilePath = path.join(root, 'package-lock.json');
|
||||
fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2));
|
||||
return {
|
||||
root,
|
||||
appRoot,
|
||||
repoRoot,
|
||||
destinationRoot,
|
||||
lockfilePath,
|
||||
recordPath: path.join(root, 'record.json'),
|
||||
declaration,
|
||||
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(directory) {
|
||||
const entries = [];
|
||||
const stack = [['', directory]];
|
||||
while (stack.length > 0) {
|
||||
const [prefix, current] = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push([relative, full]);
|
||||
} else {
|
||||
const info = fs.statSync(full);
|
||||
entries.push({
|
||||
relative,
|
||||
size: info.size,
|
||||
mtimeMs: info.mtimeMs,
|
||||
mode: info.mode & 0o777,
|
||||
sha256: sha256File(full),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries.sort((left, right) =>
|
||||
left.relative.localeCompare(right.relative),
|
||||
);
|
||||
}
|
||||
|
||||
function prepare(fixture, overrides = {}) {
|
||||
return prepareBundledResources({
|
||||
target: WINDOWS_TARGET,
|
||||
destinationRoot: fixture.destinationRoot,
|
||||
declarationPath: DECLARATION_PATH,
|
||||
recordPath: fixture.recordPath,
|
||||
lockfilePath: fixture.lockfilePath,
|
||||
repoRoot: fixture.repoRoot,
|
||||
appRoot: fixture.appRoot,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('stages declared codex components with manifest and preserved notice', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const summaries = prepare(fixture);
|
||||
assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/);
|
||||
|
||||
const declaration = fixture.declaration;
|
||||
const layout = declaration.codex.targets.find(
|
||||
(entry) => entry.target === WINDOWS_TARGET,
|
||||
);
|
||||
const unit = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex',
|
||||
layout.directory,
|
||||
);
|
||||
for (const relative of layout.files) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(unit, relative)),
|
||||
`缺少组件 ${relative}`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(unit, 'NOTICE.md'), 'utf8'),
|
||||
'windows codex notice\n',
|
||||
'受版本控制的第三方声明必须原地保留',
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(unit, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
assert.deepEqual(Object.keys(manifest), [
|
||||
'files',
|
||||
'platform',
|
||||
'schemaVersion',
|
||||
'version',
|
||||
]);
|
||||
assert.equal(manifest.platform, layout.platform);
|
||||
assert.equal(manifest.schemaVersion, declaration.codex.manifestSchema);
|
||||
assert.equal(
|
||||
manifest.version,
|
||||
`${declaration.codex.cliVersionPrefix}${declaration.codex.version}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.keys(manifest.files).sort(),
|
||||
[...layout.files].sort(),
|
||||
'清单文件集合必须等于组件白名单',
|
||||
);
|
||||
for (const relative of layout.files) {
|
||||
assert.equal(
|
||||
manifest.files[relative],
|
||||
sha256File(path.join(unit, relative)),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('second run is a no-op: identical content and timestamps', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
prepare(fixture);
|
||||
const unit = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex',
|
||||
'win-x64',
|
||||
);
|
||||
const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
|
||||
const before = { codex: snapshot(unit), plugins: snapshot(plugins) };
|
||||
const summaries = prepare(fixture);
|
||||
assert.match(summaries[0], /命中缓存/);
|
||||
assert.match(summaries[1], /命中缓存/);
|
||||
assert.deepEqual(
|
||||
snapshot(unit),
|
||||
before.codex,
|
||||
'codex 产物内容与时间戳必须不变',
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshot(plugins),
|
||||
before.plugins,
|
||||
'插件产物内容与时间戳必须不变',
|
||||
);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('stages the macOS universal group with both architectures', () => {
|
||||
const fixture = buildFixture({
|
||||
targets: [MAC_TARGET, 'x86_64-apple-darwin'],
|
||||
});
|
||||
try {
|
||||
const summaries = prepare(fixture, { target: MAC_TARGET });
|
||||
assert.match(summaries[0], /mac-native/);
|
||||
const unit = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex/mac-native',
|
||||
);
|
||||
for (const directory of ['darwin-arm64', 'darwin-x64']) {
|
||||
for (const file of ['bin/codex', 'manifest.json', 'NOTICE.md']) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(unit, directory, file)),
|
||||
`缺少 ${directory}/${file}`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(unit, directory, 'NOTICE.md'), 'utf8'),
|
||||
'mac codex notice\n',
|
||||
);
|
||||
}
|
||||
assert.deepEqual(fs.readdirSync(unit).sort(), [
|
||||
'darwin-arm64',
|
||||
'darwin-x64',
|
||||
]);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('copies only whitelisted plugin subdirectories', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
prepare(fixture);
|
||||
const staged = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/plugins/agc-demo-editor',
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(staged, 'plugin.json')));
|
||||
assert.ok(fs.existsSync(path.join(staged, 'src/entry.mjs')));
|
||||
assert.ok(fs.existsSync(path.join(staged, 'panels/panel.html')));
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(staged, 'panels/panel.test.mjs')),
|
||||
'测试文件不随包',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(staged, 'target')),
|
||||
'构建产物目录不随包',
|
||||
);
|
||||
assert.ok(!fs.existsSync(path.join(staged, '.git')), '隐藏目录不随包');
|
||||
assert.ok(!fs.existsSync(path.join(staged, '.env')), '隐藏文件不随包');
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when the upstream package metadata drifts from the declaration', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const declaration = fixture.declaration;
|
||||
const layout = declaration.codex.targets.find(
|
||||
(entry) => entry.target === WINDOWS_TARGET,
|
||||
);
|
||||
const metadataFile = path.join(
|
||||
fixture.appRoot,
|
||||
`node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}/codex-package.json`,
|
||||
);
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
|
||||
assert.equal(metadata.version, declaration.codex.version);
|
||||
for (const [key, value] of [
|
||||
['version', '0.0.0'],
|
||||
['layoutVersion', 2],
|
||||
['entrypoint', 'bin/other.exe'],
|
||||
['resourcesDir', '../private'],
|
||||
]) {
|
||||
fs.writeFileSync(
|
||||
metadataFile,
|
||||
`${JSON.stringify({ ...metadata, [key]: value }, null, 2)}\n`,
|
||||
);
|
||||
assert.throws(
|
||||
() => prepare(fixture),
|
||||
/上游包元数据与声明不一致/,
|
||||
`${key} 漂移必须被拒绝`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
!fs.existsSync(
|
||||
path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex/win-x64/manifest.json',
|
||||
),
|
||||
),
|
||||
'拒绝时不得留下产物',
|
||||
);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when the upstream package is missing', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
fs.rmSync(path.join(fixture.appRoot, 'node_modules'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
assert.throws(() => prepare(fixture), /npm ci/);
|
||||
assert.ok(
|
||||
!fs.existsSync(
|
||||
path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex/win-x64/manifest.json',
|
||||
),
|
||||
),
|
||||
'失败时不得留下半成品清单',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
|
||||
);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed for unsupported targets', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
assert.throws(
|
||||
() => prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }),
|
||||
/声明不含目标/,
|
||||
);
|
||||
assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when the destination is owned by something else', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
|
||||
fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n');
|
||||
assert.throws(() => prepare(fixture), /被非本工具内容占用/);
|
||||
|
||||
const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
|
||||
fs.rmSync(path.join(unit, 'foreign.bin'), { force: true });
|
||||
fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), {
|
||||
recursive: true,
|
||||
});
|
||||
assert.throws(() => prepare(fixture), /插件随包目录被非本工具内容占用/);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dry run writes nothing', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const summaries = prepare(fixture, { dryRun: true });
|
||||
assert.match(summaries[0], /需要重新生成(dry-run 未写入)/);
|
||||
const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(unit),
|
||||
['NOTICE.md'],
|
||||
'dry-run 不得写入任何组件或清单',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
|
||||
);
|
||||
assert.ok(!fs.existsSync(fixture.recordPath));
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('declaration drives source lookup and staging units', () => {
|
||||
const declaration = readDeclaration(DECLARATION_PATH);
|
||||
const windows = stagingUnit(declaration, WINDOWS_TARGET);
|
||||
assert.equal(windows.directory, 'win-x64');
|
||||
assert.deepEqual(
|
||||
windows.targets.map((member) => member.target),
|
||||
[WINDOWS_TARGET],
|
||||
);
|
||||
const mac = stagingUnit(declaration, MAC_TARGET);
|
||||
assert.equal(mac.directory, 'mac-native');
|
||||
assert.deepEqual(
|
||||
mac.targets.map((member) => member.target),
|
||||
['aarch64-apple-darwin', 'x86_64-apple-darwin'],
|
||||
);
|
||||
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const source = findCodexSource(declaration, WINDOWS_TARGET, {
|
||||
app: fixture.appRoot,
|
||||
repo: fixture.repoRoot,
|
||||
});
|
||||
assert.match(source, /codex-win32-x64\/vendor\/x86_64-pc-windows-msvc$/);
|
||||
assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 1);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs';
|
||||
import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
@@ -10,6 +11,10 @@ import {
|
||||
resolveAgcDevEndpoint,
|
||||
withAgcDevEndpointEnv,
|
||||
} from './dev-port.mjs';
|
||||
import {
|
||||
prepareBundledResources,
|
||||
supportedHostTarget,
|
||||
} from './prepare-bundled-resources.mjs';
|
||||
import {
|
||||
isAiGameCreatorServer,
|
||||
preflightExistingVite,
|
||||
@@ -67,6 +72,29 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
|
||||
return withDefaultCargoFeatures(argv, features);
|
||||
}
|
||||
|
||||
/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。
|
||||
/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。
|
||||
function prepareBundledResourcesBeforeTauri(
|
||||
features = readDevCargoFeatures(),
|
||||
{ prepare = prepareBundledResources, log = console.log } = {},
|
||||
) {
|
||||
const target = supportedHostTarget();
|
||||
if (!target) {
|
||||
log(
|
||||
'[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const summaries = prepare({
|
||||
target,
|
||||
features: new Set(features),
|
||||
log: (line) => log(`[ai-game-creator-shell] ${line}`),
|
||||
});
|
||||
for (const summary of summaries) {
|
||||
log(`[ai-game-creator-shell] ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
function spawnTauriCli(argv, { env = process.env } = {}) {
|
||||
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
|
||||
cwd: appRoot,
|
||||
@@ -75,6 +103,17 @@ function spawnTauriCli(argv, { env = process.env } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的
|
||||
/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态
|
||||
/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用
|
||||
/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。
|
||||
function buildTauriDevProcessEnv(endpoint, env = process.env) {
|
||||
return buildLocalRustProcessEnv({
|
||||
...withAgcDevEndpointEnv(endpoint, env),
|
||||
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
async function runTauriDev(
|
||||
argv = process.argv.slice(2),
|
||||
{
|
||||
@@ -84,6 +123,7 @@ async function runTauriDev(
|
||||
spawnCli = spawnTauriCli,
|
||||
waitForCli = waitForChildTermination,
|
||||
terminateTree = terminateChildTree,
|
||||
prepareResources = prepareBundledResourcesBeforeTauri,
|
||||
} = {},
|
||||
) {
|
||||
const endpoint = await resolveDevEndpoint();
|
||||
@@ -130,15 +170,14 @@ async function runTauriDev(
|
||||
shutdownRequested.then(() => false),
|
||||
]);
|
||||
if (!prepared || shutdownSignal) return 1;
|
||||
const devFeatures = readDevCargoFeatures();
|
||||
prepareResources(devFeatures);
|
||||
const tauriArguments = buildTauriArguments(
|
||||
withDevCargoFeatures(argv),
|
||||
withDevCargoFeatures(argv, devFeatures),
|
||||
endpoint.url,
|
||||
);
|
||||
child = spawnCli(tauriArguments, {
|
||||
env: {
|
||||
...withAgcDevEndpointEnv(endpoint),
|
||||
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
|
||||
},
|
||||
env: buildTauriDevProcessEnv(endpoint),
|
||||
});
|
||||
const childResult = waitForCli(child);
|
||||
const outcome = await Promise.race([
|
||||
@@ -225,7 +264,9 @@ function isDirectModuleExecution() {
|
||||
|
||||
export {
|
||||
buildTauriArguments,
|
||||
buildTauriDevProcessEnv,
|
||||
isDirectModuleExecution,
|
||||
prepareBundledResourcesBeforeTauri,
|
||||
runTauriDev,
|
||||
spawnTauriCli,
|
||||
withDevCargoFeatures,
|
||||
|
||||
+4
-3
@@ -6312,7 +6312,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"ts-rs-macros 12.0.1",
|
||||
@@ -6333,9 +6334,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "12.0.1"
|
||||
source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
|
||||
@@ -29,10 +29,7 @@ tauri-build = { version = "2.6.2", features = [] }
|
||||
[dependencies]
|
||||
codex-patch-parser = { path = "vendor/codex-patch-parser" }
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
# 与 server-rs 同一口径:已发布的 ts-rs 12.0.1 解析不了 enum 级 `serde(deny_unknown_fields)`
|
||||
# (见 agent/direct_codex_user_item/model.rs),上游修复 #491 未发版,故固定到该 commit;
|
||||
# 等 crates.io 发布 > 12.0.1 后改回 registry 版本。不要改用 `no-serde-warnings` 关告警。
|
||||
ts-rs = { git = "https://github.com/Aleph-Alpha/ts-rs", rev = "fd8679ef20f866f747c046b30bd29c15e3d7a003" }
|
||||
ts-rs = "12.0.1"
|
||||
typed_floats = { version = "1.0.7", features = ["serde"] }
|
||||
nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
|
||||
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
|
||||
@@ -69,7 +66,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" }
|
||||
portable-pty = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user