合并主线更新并保留双方排障记录
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled

合并 origin/master 的上传续传、标题栏弹窗及测试更新。

解决排障文档冲突,保留策划回复与标题栏问题记录。
This commit is contained in:
2026-09-23 11:18:46 +00:00
34 changed files with 1993 additions and 197 deletions
@@ -5935,14 +5935,81 @@ pub(crate) async fn export_local_project_package(
export_local_project_package_for_publish_at(root).await
}
/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。
///
/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过
/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。
#[tauri::command]
pub(crate) fn read_local_project_export_package(
pub(crate) fn prepare_local_project_game_package(
app: tauri::AppHandle,
project_path: String,
package_relative_path: String,
) -> Result<LocalProjectExportPackagePayload, String> {
) -> Result<crate::game_package_upload::StagedGamePackage, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
read_local_project_export_package_at(root, package_relative_path.trim())
let payload = read_local_project_export_package_at(root, package_relative_path.trim())?;
let staging_dir = game_package_upload_staging_dir(&app)?;
let mut staged = crate::game_package_upload::stage_game_package_bytes(
&staging_dir,
&payload.package_sha256,
&payload.package_bytes,
)?;
staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX);
Ok(staged)
}
/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。
#[tauri::command]
pub(crate) async fn upload_local_project_game_package(
app: tauri::AppHandle,
staging_path: String,
version_id: String,
api_base_url: String,
access_token: String,
idempotency_key: String,
) -> Result<crate::game_package_upload::GamePackageUploadOutcome, String> {
let staging_dir = game_package_upload_staging_dir(&app)?;
let resolved_path =
crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?;
let client = reqwest::Client::builder()
.build()
.map_err(|error| format!("创建上传客户端失败:{error}"))?;
let version_id = version_id.trim().to_string();
if version_id.is_empty() {
return Err("缺少发行版本标识".to_string());
}
let emit_handle = app.clone();
let progress_version_id = version_id.clone();
crate::game_package_upload::upload_staged_game_package(
&client,
crate::game_package_upload::GamePackageUploadRequest {
staging_path: &resolved_path,
version_id: &version_id,
api_base_url: api_base_url.trim(),
access_token: access_token.trim(),
idempotency_key: idempotency_key.trim(),
},
move |received_bytes, total_bytes| {
let _ = emit_handle.emit(
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
crate::game_package_upload::progress_event_payload(
&progress_version_id,
received_bytes,
total_bytes,
),
);
},
)
.await
}
fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map(|app_data_root| {
crate::game_package_upload::game_package_upload_staging_dir(&app_data_root)
})
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
}
#[tauri::command]
File diff suppressed because it is too large Load Diff
@@ -132,6 +132,7 @@ mod editor_adapter;
mod editor_adapters;
mod environment_check;
pub mod error_report;
mod game_package_upload;
mod git_inspect;
mod goal;
mod http_client;
@@ -2757,7 +2758,8 @@ fn main() {
build_local_project_index,
create_local_project_checkpoint,
export_local_project_package,
read_local_project_export_package,
prepare_local_project_game_package,
upload_local_project_game_package,
list_local_project_export_packages,
diff_local_project_checkpoint,
restore_local_project_checkpoint,
+19 -25
View File
@@ -459,11 +459,7 @@ export interface AgentRuntimeResult {
}
export type AgentRuntimeResponseStreamStatus =
| 'streaming'
| 'ready'
| 'committed'
| 'discarded'
| 'failed';
'streaming' | 'ready' | 'committed' | 'discarded' | 'failed';
export interface AgentRuntimeResponseStream {
schemaVersion: string;
@@ -569,22 +565,13 @@ export interface GameCreatorAgentLlmConfigStatus {
}
export type GameCreatorLlmApiKind =
| 'openai_responses'
| 'openai_chat'
| 'anthropic';
'openai_responses' | 'openai_chat' | 'anthropic';
export type GameCreatorAgentMode =
| 'codex_app_server'
| 'codex_cli'
| 'provider';
'codex_app_server' | 'codex_cli' | 'provider';
export type RuntimeLlmProviderPresetId =
| 'custom'
| 'openai'
| 'deepseek'
| 'anthropic'
| 'ark';
'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark';
export type RuntimeAgentLlmProviderPresetId =
| 'inherit'
| RuntimeLlmProviderPresetId;
'inherit' | RuntimeLlmProviderPresetId;
export interface GameCreatorLlmConfig {
customEnabled?: boolean;
@@ -796,12 +783,21 @@ export interface LocalProjectExportPackageFileDigest {
sha256: string;
}
export interface LocalProjectExportPackagePayload {
packageRelativePath: string;
packageBytes: number[];
/**
* 已暂存的归一化发行包:发布链路只传递这个摘要与路径,整包字节留在原生进程里,
* 不再经过 WebView IPC。
*/
export interface StagedGamePackage {
stagingPath: string;
packageSha256: string;
packageSizeBytes: number;
files: LocalProjectExportPackageFileDigest[];
packageFileCount: number;
}
export interface GamePackageUploadOutcome {
versionId: string;
status: string;
uploadedBytes: number;
}
export interface LocalProjectExportPackageSummary {
@@ -935,9 +931,7 @@ export type GameCreatorDirectToolCallKind =
| 'other';
export type GameCreatorDirectToolCallStatus =
| 'running'
| 'completed'
| 'failed';
'running' | 'completed' | 'failed';
export interface GameCreatorDirectToolCallChange {
path: string;
@@ -149,7 +149,11 @@ export function WindowChrome({ children }: WindowChromeProps) {
<WindowChromeContext.Provider value={contextValue}>
<div className="window-chrome">
{appUpdateCheckEnabled ? <AppUpdateNotice /> : null}
<header className="window-chrome__bar" aria-label="窗口标题栏">
<header
className="window-chrome__bar"
data-window-chrome-bar
aria-label="窗口标题栏"
>
<div className="window-chrome__leading">
<div
className="window-chrome__brand"
@@ -4,6 +4,23 @@ import { createPortal } from 'react-dom';
type ThemedModalTheme = 'light' | 'dark';
/**
* 自绘标题栏的标记:它是窗口边框,不属于模态内容。
*
* focus-trap 默认会拦下模态之外的所有点击(`click` 事件在 document 捕获阶段直接
* `stopImmediatePropagation`),所以任何弹窗打开时「最小化 / 最大化 / 关闭」和标题栏
* 拖拽都会静默失效。这里只对落在标题栏内的目标放行;页面内容仍然由遮罩和焦点陷阱
* 挡在模态之外,点空白处不会误触底层界面。
*/
const WINDOW_CHROME_BAR_SELECTOR = '[data-window-chrome-bar]';
function isWindowChromeBarTarget(target: EventTarget | null) {
return (
target instanceof Element &&
target.closest(WINDOW_CHROME_BAR_SELECTOR) !== null
);
}
export type ThemedModalProps = {
open: boolean;
ariaLabel: string;
@@ -55,6 +72,7 @@ export function ThemedModal({
escapeDeactivates: false,
fallbackFocus: () => panelRef.current!,
returnFocusOnDeactivate: true,
allowOutsideClick: (event) => isWindowChromeBarTarget(event.target),
}}
>
<div
@@ -7,10 +7,12 @@ import type {
GameDistributionOrientation,
} from '../../../../packages/shared/src/contracts/gameDistribution';
import type {
LocalProjectExportPackagePayload,
GamePackageUploadOutcome,
StagedGamePackage,
TauriInvoke,
} from '../app/types';
import { requestClientApi } from './clientApi';
import { getStoredAuthAccessToken, requestClientApi } from './clientApi';
import { getClientServerBaseUrl } from './clientHttp';
export type GameDistributionPublishMetadata = {
title: string;
@@ -328,20 +330,20 @@ export async function publishLocalProjectGame(args: {
if (!projectPath || !packageRelativePath) {
throw new Error('发布需要绑定本地项目和试玩包');
}
const payload = await args.invoke<LocalProjectExportPackagePayload>(
'read_local_project_export_package',
// 整包字节只留在原生进程:这里拿到的是归一化后的摘要与内容寻址暂存路径,
// 上传由原生侧按服务端分片大小完成,中断后同一暂存文件可直接续传。
const staged = await args.invoke<StagedGamePackage>(
'prepare_local_project_game_package',
{ projectPath, packageRelativePath },
);
if (
!payload.packageBytes.length ||
payload.packageSizeBytes !== payload.packageBytes.length ||
payload.files.length === 0
!staged.stagingPath.trim() ||
staged.packageSha256.length !== 64 ||
staged.packageSizeBytes <= 0 ||
staged.packageFileCount <= 0
) {
throw new Error('本地发行包摘要无效,请重新导出试玩包');
}
if (payload.packageRelativePath !== packageRelativePath) {
throw new Error('本地发行包路径已变化,请重新导出试玩包');
}
const metadata = normalizeMetadata(args.manifest, args.metadata);
const localProjectId = args.manifest.projectId.trim();
@@ -369,9 +371,9 @@ export async function publishLocalProjectGame(args: {
const versionRequest: GameDistributionCreateVersionRequest = {
localProjectId,
packageSha256: payload.packageSha256,
packageBytes: payload.packageSizeBytes,
packageFileCount: payload.files.length,
packageSha256: staged.packageSha256,
packageBytes: staged.packageSizeBytes,
packageFileCount: staged.packageFileCount,
packageEntryPath: 'index.html',
gameMetadata,
};
@@ -391,23 +393,19 @@ export async function publishLocalProjectGame(args: {
throw new Error('创建发行版本未返回版本 ID');
}
const packageBody = new Blob([new Uint8Array(payload.packageBytes)], {
type: 'application/zip',
});
const uploaded = await requestClientApi<{
versionId: string;
status: string;
}>(
`/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/package`,
const accessToken = getStoredAuthAccessToken();
if (!accessToken) {
throw new Error('陶泥儿登录凭据缺失,请重新登录');
}
const uploaded = await args.invoke<GamePackageUploadOutcome>(
'upload_local_project_game_package',
{
method: 'PUT',
headers: {
'Content-Type': 'application/zip',
'Idempotency-Key': `${rootKey}:upload`,
},
body: packageBody,
stagingPath: staged.stagingPath,
versionId: version.versionId,
apiBaseUrl: getClientServerBaseUrl(),
accessToken,
idempotencyKey: `${rootKey}:upload`,
},
'上传游戏发行包失败',
);
const submitted = await requestClientApi<{
game?: { publicationRevision?: number };
@@ -431,8 +429,8 @@ export async function publishLocalProjectGame(args: {
versionId: version.versionId,
versionNumber: version.versionNumber,
status: submitted?.version?.status ?? uploaded?.status ?? 'pending_review',
packageSha256: payload.packageSha256,
packageSizeBytes: payload.packageSizeBytes,
fileCount: payload.files.length,
packageSha256: staged.packageSha256,
packageSizeBytes: staged.packageSizeBytes,
fileCount: staged.packageFileCount,
};
}
+17 -3
View File
@@ -117,7 +117,11 @@ body {
.app-update-overlay {
position: fixed;
z-index: 260;
inset: 0;
/* 中文注释:全屏弹层一律从自绘标题栏下方开始,标题栏的最小化 / 最大化 / 关闭必须始终可用。 */
top: var(--window-chrome-height);
right: 0;
bottom: 0;
left: 0;
display: grid;
padding: 24px;
background: rgb(35 20 12 / 48%);
@@ -209,7 +213,13 @@ body {
}
:root {
/* 网页内自绘标题栏占用的顶部高度;portal 到 body 的固定弹层也要从它下方开始。 */
/*
* 网页内自绘标题栏占用的顶部高度
*
* 约定portal body 的全屏固定弹层一律从标题栏下方开始`top: var(--window-chrome-height)`
* 标题栏是窗口边框不是弹层内容 弹出任何面板时最小化 / 最大化 / 关闭和拖拽都必须
* 保持可用模态内部的焦点陷阱也必须放行落在标题栏上的点击 `ThemedModal`
*/
--window-chrome-height: 50px;
}
@@ -9744,7 +9754,11 @@ iframe.preview-frame {
.game-publish-progress-overlay {
position: fixed;
z-index: 500;
inset: 0;
/* 中文注释:发布进行中仍然要能最小化 / 关闭窗口,遮罩只压住工作区。 */
top: var(--window-chrome-height);
right: 0;
bottom: 0;
left: 0;
background: rgb(35 24 19 / 62%);
backdrop-filter: blur(3px);
pointer-events: auto;
@@ -2,12 +2,26 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { APP_NAME } from '../src/app/appMetadata';
import type { GameCreatorDirectActiveTurn } from '../src/app/types';
import { ThemedModal } from '../src/components/modal/ThemedModal';
import { WindowChrome } from '../src/components/WindowChrome';
import { useWindowChrome } from '../src/components/windowChromeContext';
const nativeWindow = vi.hoisted(() => ({
minimize: vi.fn(),
toggleMaximize: vi.fn(),
isMaximized: vi.fn(),
close: vi.fn(),
label: 'client',
}));
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => nativeWindow,
}));
function TitleSetter({ value }: { value: string }) {
const { setTitle } = useWindowChrome();
return (
@@ -36,6 +50,14 @@ function ActiveRunsSetter({
}
describe('WindowChrome', () => {
beforeEach(() => {
nativeWindow.minimize.mockReset();
nativeWindow.toggleMaximize.mockReset();
nativeWindow.isMaximized.mockReset();
nativeWindow.close.mockReset();
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
});
it('renders the陶泥儿 brand, default title, and controls', async () => {
const user = userEvent.setup();
render(
@@ -45,7 +67,7 @@ describe('WindowChrome', () => {
);
expect(screen.getByRole('banner', { name: '窗口标题栏' })).toBeTruthy();
expect(screen.getByLabelText('陶泥儿 GameAgent')).toBeTruthy();
expect(screen.getByLabelText(`${APP_NAME} GameAgent`)).toBeTruthy();
expect(screen.queryByLabelText('本地工作区')).toBeNull();
expect(screen.getByText('创作工作台')).toBeTruthy();
expect(screen.getByText('工作区内容')).toBeTruthy();
@@ -141,4 +163,35 @@ describe('WindowChrome', () => {
);
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
});
/**
* 回归:发布面板等 ThemedModal 弹窗打开时,标题栏在模态之外,焦点陷阱曾把
* 标题栏上的点击一起拦下 —— 三个窗口按钮看着正常但点不动。
*/
it('keeps the window controls working while a modal covers the workspace', async () => {
const user = userEvent.setup();
nativeWindow.minimize.mockResolvedValue(undefined);
nativeWindow.toggleMaximize.mockResolvedValue(undefined);
nativeWindow.close.mockResolvedValue(undefined);
nativeWindow.isMaximized.mockResolvedValue(false);
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
render(
<WindowChrome>
<ThemedModal open onClose={() => undefined} ariaLabel="测试弹窗">
<button type="button"></button>
</ThemedModal>
</WindowChrome>,
);
await screen.findByRole('dialog', { name: '测试弹窗' });
await user.click(screen.getByRole('button', { name: '最小化' }));
expect(nativeWindow.minimize).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '最大化' }));
expect(nativeWindow.toggleMaximize).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '关闭' }));
expect(nativeWindow.close).toHaveBeenCalledTimes(1);
});
});
@@ -14,6 +14,12 @@ vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(),
}));
// 原生侧上传需要登录凭据;这里只钉住「取到了 token」这一件事。
vi.mock('../src/services/clientApi', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/services/clientApi')>()),
getStoredAuthAccessToken: () => 'test-access-token',
}));
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
generateGameDistributionCover,
@@ -29,6 +35,14 @@ const MANIFEST = {
goal: '守住轨道城',
} as unknown as GameCreationAppManifest;
/** 归一化发行包的暂存摘要;发布链路只应传递它,不再传整包字节。 */
const STAGED_PACKAGE = {
stagingPath: 'C:/app-data/game-package-staging/aaaa.zip',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 1024,
packageFileCount: 1,
};
function jsonResponse(payload: unknown) {
return new Response(
JSON.stringify({
@@ -66,23 +80,25 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台
status: 'awaiting_upload',
}),
)
.mockResolvedValueOnce(
jsonResponse({ versionId: 'gamever_1', status: 'uploaded' }),
)
.mockResolvedValueOnce(
jsonResponse({ version: { status: 'pending_review' } }),
);
const invokeCalls: Array<{ command: string; args: unknown }> = [];
const result = await publishLocalProjectGame({
invoke: (async (command: string) => {
expect(command).toBe('read_local_project_export_package');
return {
packageRelativePath: 'exports/playtest-package-1.zip',
packageBytes: [1, 2, 3],
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
files: [{ path: 'index.html', sizeBytes: 3, sha256: 'a'.repeat(64) }],
};
invoke: (async (command: string, args?: Record<string, unknown>) => {
invokeCalls.push({ command, args });
if (command === 'prepare_local_project_game_package') {
return STAGED_PACKAGE;
}
if (command === 'upload_local_project_game_package') {
return {
versionId: 'gamever_1',
status: 'uploaded',
uploadedBytes: STAGED_PACKAGE.packageSizeBytes,
};
}
throw new Error(`未预期的命令:${command}`);
}) as never,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
@@ -113,18 +129,26 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台
expect(result.gameId).toBe('game_1');
expect(result.versionId).toBe('gamever_1');
// 关键回归:整包字节不再经过 IPC,上传交给原生侧按版本 ID + 暂存路径完成。
const uploadCall = invokeCalls.find(
(call) => call.command === 'upload_local_project_game_package',
);
expect(uploadCall?.args).toMatchObject({
stagingPath: STAGED_PACKAGE.stagingPath,
versionId: 'gamever_1',
apiBaseUrl: 'https://dev.genarrative.world',
accessToken: 'test-access-token',
});
expect(Object.keys(uploadCall?.args ?? {})).not.toContain('packageBytes');
// 三次 HTTP:创建游戏、创建版本、送审;上传不再占用一条 HTTP 调用。
expect(fetchClientHttp).toHaveBeenCalledTimes(3);
});
test('缺少本地项目标识时在发起请求前失败关闭', async () => {
await expect(
publishLocalProjectGame({
invoke: (async () => ({
packageRelativePath: 'exports/playtest-package-1.zip',
packageBytes: [1],
packageSha256: 'a'.repeat(64),
packageSizeBytes: 1,
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
})) as never,
invoke: (async () => STAGED_PACKAGE) as never,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest,
@@ -137,13 +161,7 @@ test('缺少本地项目标识时在发起请求前失败关闭', async () => {
test('缺少封面时在创建游戏前失败关闭', async () => {
await expect(
publishLocalProjectGame({
invoke: (async () => ({
packageRelativePath: 'exports/playtest-package-1.zip',
packageBytes: [1],
packageSha256: 'a'.repeat(64),
packageSizeBytes: 1,
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
})) as never,
invoke: (async () => STAGED_PACKAGE) as never,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
@@ -156,13 +174,7 @@ test('缺少封面时在创建游戏前失败关闭', async () => {
test('截图超过 6 张时在创建游戏前失败关闭', async () => {
await expect(
publishLocalProjectGame({
invoke: (async () => ({
packageRelativePath: 'exports/playtest-package-1.zip',
packageBytes: [1],
packageSha256: 'a'.repeat(64),
packageSizeBytes: 1,
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
})) as never,
invoke: (async () => STAGED_PACKAGE) as never,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
@@ -7,18 +7,23 @@
* npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts
*
* 开启后测试会注册一个临时作者,并通过真实的 `clientApi` / `clientHttp`(而不是
* mock 请求层)调用 AGC 的发布函数,覆盖:本地导出包读取、创建游戏、同
* `localProjectId` 复用游戏身份、真实 ZIP 上传、送审与版本回读。
* mock 请求层)调用 AGC 的发布函数,覆盖:本地发行包暂存摘要、创建游戏、同
* `localProjectId` 复用游戏身份、真实分片上传、送审与版本回读。
*
* jsdom 里没有 Tauri 运行时,`upload_local_project_game_package` 由本测试按服务端
* 分片协议(upload-state → chunk → complete)代跑,等同于原生上传器的行为;
* 原生实现自身的分片规划、权威偏移续传与错误分类在 Rust 单测里覆盖。
*/
import { createHash } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import JSZip from 'jszip';
import { expect, test, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { LocalProjectExportPackagePayload } from '../src/app/types';
import type { StagedGamePackage } from '../src/app/types';
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
import { setStoredAuthAccessToken } from '../src/services/clientAuth';
import { setClientServerSelection } from '../src/services/clientHttp';
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
/** 1x1 透明 PNG:真实上传一张合法图片作为封面,避免依赖本地素材文件。 */
@@ -37,6 +42,12 @@ const liveBaseUrl = (process.env.GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL ?? '')
.replace(/\/+$/u, '');
const liveTest = liveBaseUrl ? test : test.skip;
// AGC 服务默认按渠道选 dev / release 域名;跑真实链路时把「平台服务器」切到传入的本地栈,
// 否则请求会打到线上域名而不是这台机器上的 api-server。
if (liveBaseUrl) {
setClientServerSelection({ preset: 'custom', customBaseUrl: liveBaseUrl });
}
const realFetch = globalThis.fetch.bind(globalThis);
const ENVELOPE_HEADERS = { 'x-genarrative-response-envelope': 'v1' };
@@ -55,21 +66,73 @@ function installFetchBridge() {
: input instanceof URL
? input.toString()
: input;
// jsdom realm 的 Headers / AbortSignal / Blob 都不是 undici 认得的类型(同 2026-09-20
// 那条「跨 realm BodyInit 被 undici 拒绝」的坑):统一降级成 Node 侧能接受的原生值。
const headers = init?.headers
? Object.fromEntries(Array.from(new Headers(init.headers).entries()))
: undefined;
const signal = undefined;
const body = init?.body;
if (typeof FormData !== 'undefined' && body instanceof FormData) {
// jsdom 的 FormData 同样不被 undici 接受:这里手工序列化成 multipart 字节。
const multipart = await serializeFormData(body);
return realFetch(url as string, {
...init,
headers: { ...headers, 'Content-Type': multipart.contentType },
signal,
body: multipart.body,
});
}
if (typeof Blob !== 'undefined' && body instanceof Blob) {
// jsdom 的 Blob/ArrayBuffer 属于另一个 realm,且旧版 jsdom 没有
// Blob.arrayBuffer;统一读成字节后复制为 Node 侧 Buffer 再转发。
const bytes = await readBlobBytes(body);
return realFetch(url as string, {
...init,
headers,
signal,
body: Buffer.from(bytes),
});
}
return realFetch(url as string, init);
return realFetch(url as string, { ...init, headers, signal });
},
);
}
async function serializeFormData(form: FormData): Promise<{
body: Buffer;
contentType: string;
}> {
const boundary = `----agcLive${Date.now().toString(16)}`;
const chunks: Buffer[] = [];
for (const [name, value] of form.entries()) {
if (typeof value === 'string') {
chunks.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
),
);
continue;
}
const bytes = await readBlobBytes(value);
const fileName =
(value as File).name || `agc-live-${Date.now().toString(16)}.bin`;
const contentType = value.type || 'application/octet-stream';
chunks.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`,
),
);
chunks.push(Buffer.from(bytes));
chunks.push(Buffer.from('\r\n'));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`));
return {
body: Buffer.concat(chunks),
contentType: `multipart/form-data; boundary=${boundary}`,
};
}
async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
const maybeArrayBuffer = (
blob as Blob & { arrayBuffer?: () => Promise<ArrayBuffer> }
@@ -120,7 +183,10 @@ async function registerAuthor(): Promise<string> {
return data.token;
}
async function buildExportPayload(): Promise<LocalProjectExportPackagePayload> {
async function buildStagedPackage(): Promise<{
staged: StagedGamePackage;
bytes: Uint8Array;
}> {
const zip = new JSZip();
const indexHtml =
'<!doctype html><html><head><meta charset="utf-8"><title>AGC Live</title>' +
@@ -129,28 +195,169 @@ async function buildExportPayload(): Promise<LocalProjectExportPackagePayload> {
'window.__agcLive=1;document.documentElement.dataset.booted="agc";';
zip.file('index.html', indexHtml);
zip.file('assets/app.js', appJs);
// 让发行包超过单个分片(8 MiB):分片续传只有跨片才有意义,随机字节保证不可压缩。
zip.file('assets/bulk.bin', randomBytes(9 * 1024 * 1024));
const bytes = await zip.generateAsync({ type: 'uint8array' });
const sha256 = createHash('sha256').update(bytes).digest('hex');
return {
packageRelativePath: 'dist/game.zip',
packageBytes: Array.from(bytes),
packageSha256: sha256,
packageSizeBytes: bytes.length,
files: [
{
path: 'index.html',
sizeBytes: Buffer.byteLength(indexHtml),
sha256: createHash('sha256').update(indexHtml).digest('hex'),
},
{
path: 'assets/app.js',
sizeBytes: Buffer.byteLength(appJs),
sha256: createHash('sha256').update(appJs).digest('hex'),
},
],
staged: {
stagingPath: '/tmp/agc-live-staging/game.zip',
packageSha256: sha256,
packageSizeBytes: bytes.length,
packageFileCount: 3,
},
bytes,
};
}
type PackageUploadState = {
receivedBytes: number;
chunkBytes: number;
declaredPackageBytes: number;
};
function packageAuthHeaders(token: string) {
return {
Authorization: `Bearer ${token}`,
...ENVELOPE_HEADERS,
};
}
/** 读取服务端权威已收字节(原生上传器同样以它为准)。 */
async function readPackageUploadState(
versionId: string,
token: string,
): Promise<PackageUploadState> {
return await unwrap<PackageUploadState>(
await realFetch(
apiUrl(
`/api/game-distribution/versions/${versionId}/package/upload-state`,
),
{ headers: packageAuthHeaders(token) },
),
);
}
/** 上传一个分片;偏移由调用方按权威偏移给出。 */
async function uploadPackageChunk(input: {
versionId: string;
token: string;
idempotencyKey: string;
offset: number;
body: Uint8Array;
}): Promise<number> {
const response = await realFetch(
apiUrl(`/api/game-distribution/versions/${input.versionId}/package/chunk`),
{
method: 'PUT',
headers: {
...packageAuthHeaders(input.token),
'Content-Type': 'application/octet-stream',
'x-genarrative-upload-offset': String(input.offset),
'Idempotency-Key': `${input.idempotencyKey}:chunk`,
},
body: Buffer.from(input.body),
},
);
if (!response.ok) {
throw new Error(
`分片上传失败:${response.status} ${await response.text()}`,
);
}
const payload = (await response.json()) as {
data?: { receivedBytes?: number };
receivedBytes?: number;
};
return payload.data?.receivedBytes ?? payload.receivedBytes ?? input.offset;
}
async function completePackageUpload(input: {
versionId: string;
token: string;
idempotencyKey: string;
}) {
return await unwrap<{ versionId: string; status: string }>(
await realFetch(
apiUrl(
`/api/game-distribution/versions/${input.versionId}/package/complete`,
),
{
method: 'POST',
headers: {
...packageAuthHeaders(input.token),
'Idempotency-Key': `${input.idempotencyKey}:complete`,
},
},
),
);
}
/** 从权威偏移继续发送剩余分片,返回本次实际发送过的偏移序列。 */
async function uploadRemainingChunks(input: {
versionId: string;
bytes: Uint8Array;
token: string;
idempotencyKey: string;
}): Promise<number[]> {
const state = await readPackageUploadState(input.versionId, input.token);
const sentOffsets: number[] = [];
let received = state.receivedBytes;
while (received < input.bytes.length) {
const length = Math.min(state.chunkBytes, input.bytes.length - received);
await uploadPackageChunk({
versionId: input.versionId,
token: input.token,
idempotencyKey: input.idempotencyKey,
offset: received,
body: input.bytes.subarray(received, received + length),
});
sentOffsets.push(received);
received = (await readPackageUploadState(input.versionId, input.token))
.receivedBytes;
}
return sentOffsets;
}
/**
* 按服务端分片协议上传整包:与原生上传器同一套请求形状,用于验证服务端合同。
* 第一次调用会**只传第一片就停下**,模拟传输中断;后续调用按权威偏移续传,
* 因此这里能直接证明「中断后不重传已收字节」。
*/
async function uploadStagedPackageViaProtocol(input: {
versionId: string;
bytes: Uint8Array;
token: string;
idempotencyKey: string;
sentOffsets: number[];
}) {
const state = await readPackageUploadState(input.versionId, input.token);
if (state.receivedBytes === 0) {
const firstLength = Math.min(state.chunkBytes, input.bytes.length);
await uploadPackageChunk({
versionId: input.versionId,
token: input.token,
idempotencyKey: input.idempotencyKey,
offset: 0,
body: input.bytes.subarray(0, firstLength),
});
input.sentOffsets.push(0);
}
input.sentOffsets.push(
...(await uploadRemainingChunks({
versionId: input.versionId,
bytes: input.bytes,
token: input.token,
idempotencyKey: input.idempotencyKey,
})),
);
const completed = await completePackageUpload({
versionId: input.versionId,
token: input.token,
idempotencyKey: input.idempotencyKey,
});
return { versionId: completed.versionId, status: completed.status };
}
liveTest(
'AGC 发布函数在真实后端完成创建、上传、送审并在重复发布时复用游戏身份',
async () => {
@@ -158,22 +365,46 @@ liveTest(
const token = await registerAuthor();
setStoredAccessToken(token);
const payload = await buildExportPayload();
const { staged, bytes } = await buildStagedPackage();
const stamp = String(Date.now());
const manifest = {
projectId: `agc-live-${stamp}`,
name: `AGC 真实发布${stamp.slice(-4)}`,
goal: '验证 AGC 一键发布链路',
} as unknown as GameCreationAppManifest;
const invoke = vi.fn(async () => payload);
// 记录本次发布实际发送过的分片偏移,用来证明「中断后不重传已收字节」。
const sentOffsets: number[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'prepare_local_project_game_package') {
return staged;
}
if (command === 'upload_local_project_game_package') {
const uploaded = await uploadStagedPackageViaProtocol({
versionId: String(args?.versionId ?? ''),
bytes,
token,
idempotencyKey: String(args?.idempotencyKey ?? ''),
sentOffsets,
});
return {
versionId: uploaded.versionId,
status: uploaded.status,
uploadedBytes: bytes.length,
};
}
throw new Error(`未预期的命令:${command}`);
},
);
// 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → confirm。
const uploadedCover = await uploadPlatformMediaAsset({
file: buildLiveCoverFile(),
assetKind: 'game_distribution_cover',
pathSegments: ['game-distribution', 'cover', stamp],
entityId: 'game-distribution-cover',
// jsdom 里没有 Tauri HTTP 插件,复用测试注入的 fetch bridge 直连 dev OSS。
fetchImpl: (input, init) => realFetch(apiUrl(input), init),
// jsdom 里没有 Tauri HTTP 插件:直传也走同一个桥,跨 realm 的 FormData 会被
// 先序列化成 Node 侧 multipart 字节,否则 OSS 会以 405 拒绝。
fetchImpl: (input, init) => globalThis.fetch(input, init),
});
expect(uploadedCover.assetObjectId).toMatch(/\S/u);
const metadata = {
@@ -198,7 +429,18 @@ liveTest(
});
expect(first.status).toBe('pending_review');
expect(first.versionNumber).toBe(1);
expect(first.packageSha256).toBe(payload.packageSha256);
expect(first.packageSha256).toBe(staged.packageSha256);
// 分片续传证据:第一片(偏移 0)只发送一次;中断后的续传从权威偏移开始,
// 已收字节不重放、也不跳段。
expect(sentOffsets[0]).toBe(0);
expect(sentOffsets.filter((offset) => offset === 0)).toHaveLength(1);
expect(sentOffsets[1]).toBeGreaterThan(0);
expect(sentOffsets).toEqual(
Array.from(
{ length: Math.ceil(staged.packageSizeBytes / 8 / 1024 / 1024) },
(_, index) => index * 8 * 1024 * 1024,
),
);
const readResult = await unwrap<{
version: { versionId: string; status: string; recoveryAction: string };
@@ -266,7 +266,11 @@ describe('客户端发布入口的可见反馈', () => {
1440,
);
expect(declaration(overlay, 'position')).toBe('fixed');
expect(declaration(overlay, 'inset')).toBe('0');
// 遮罩从自绘标题栏下方开始:发布进行中仍然要能最小化 / 关闭窗口。
expect(declaration(overlay, 'top')).toBe('var(--window-chrome-height)');
expect(declaration(overlay, 'right')).toBe('0');
expect(declaration(overlay, 'bottom')).toBe('0');
expect(declaration(overlay, 'left')).toBe('0');
expect(declaration(overlay, 'z-index')).toBe('500');
expect(declaration(overlay, 'pointer-events')).toBe('auto');
expect(declaration(overlay, 'background')).toBe('rgb(35 24 19 / 62%)');
@@ -35,6 +35,34 @@ function ModalHarness({ noFocusableContent = false }) {
);
}
/**
* 标题栏在模态之外,但它是窗口边框:弹窗打开时最小化 / 最大化 / 关闭必须照常可点。
* 工作区内容反过来仍要被模态挡住,不能因为放行标题栏就一起漏过去。
*/
function WindowChromeHarness({
onMinimize,
onWorkspaceClick,
}: {
onMinimize: () => void;
onWorkspaceClick: () => void;
}) {
return (
<>
<div className="window-chrome__bar" data-window-chrome-bar>
<button type="button" onClick={onMinimize}>
</button>
</div>
<button type="button" onClick={onWorkspaceClick}>
</button>
<ThemedModal open onClose={() => undefined} ariaLabel="测试弹窗">
<button type="button"></button>
</ThemedModal>
</>
);
}
describe('ThemedModal', () => {
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getClientRects').mockImplementation(
@@ -105,4 +133,23 @@ describe('ThemedModal', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(document.activeElement).toBe(opener);
});
it('lets window title bar clicks through while workspace clicks stay trapped', async () => {
const user = userEvent.setup();
const onMinimize = vi.fn();
const onWorkspaceClick = vi.fn();
render(
<WindowChromeHarness
onMinimize={onMinimize}
onWorkspaceClick={onWorkspaceClick}
/>,
);
await screen.findByRole('dialog', { name: '测试弹窗' });
await user.click(screen.getByRole('button', { name: '最小化' }));
expect(onMinimize).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '工作区按钮' }));
expect(onWorkspaceClick).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { repoPath } from './repoPath';
import { parseStyleSheet } from './styleCascade';
const STYLES_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
/**
* 全屏弹层清单:每一层都必须从自绘标题栏下方开始。
*
* 标题栏是窗口边框,不是弹层内容 —— 只要有一个全屏遮罩盖住它,弹窗打开时
* 「最小化 / 最大化 / 关闭」就会被挡住。焦点陷阱那一半的问题见
* `themedModal.test.tsx` 与 `WindowChrome.test.tsx`;新增全屏弹层时把类名加进这份清单。
*/
const WINDOW_CHROME_SAFE_OVERLAYS = [
// ThemedModal 与共享弹层的通用遮罩:top 由这条规则统一抬到标题栏下方。
'.fixed.inset-0',
'.app-update-overlay',
'.game-publish-progress-overlay',
'.launcher-dialog-backdrop',
'.settings-overlay',
'.game-approval-backdrop',
'.project-chat-settings-backdrop',
] as const;
function declarationsForSelector(css: string, selector: string) {
const merged = new Map<string, string>();
for (const rule of parseStyleSheet(css)) {
if (!rule.selectors.includes(selector)) {
continue;
}
for (const [property, value] of rule.declarations) {
merged.set(property, value);
}
}
return merged;
}
describe('窗口标题栏与全屏弹层的层叠约定', () => {
const css = readFileSync(STYLES_PATH, 'utf8');
it.each(WINDOW_CHROME_SAFE_OVERLAYS)('%s 从标题栏下方开始', (selector) => {
const declarations = declarationsForSelector(css, selector);
expect(
declarations.get('top'),
`${selector} 必须声明 top: var(--window-chrome-height)`,
).toBe('var(--window-chrome-height)');
});
});
+3 -2
View File
@@ -90,8 +90,9 @@ http {
location ~ ^/api(?:/|$) {
default_type application/json;
# 中文注释:创作接口会携带参考图 Data URLNginx 只放行到 api-server真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。
client_max_body_size 64m;
# 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server
# 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。
client_max_body_size 210m;
limit_conn genarrative_api_conn 64;
limit_req zone=genarrative_api_rps burst=64 nodelay;
+2 -2
View File
@@ -4,8 +4,8 @@
## 请求体大小
- 生产、开发服和容器模板都在通用 `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 64m`
- 该值只用于让携带参考图 Data URL 的创作接口抵达 `api-server`;不要把它当作业务上传上限。Rust 路由仍通过 `DefaultBodyLimit` 和解码后字节校验限制具体接口,例如拼图参考图路由只放宽到 12 MiB 请求体,图片字节继续按业务规则拒绝。
- 生产、开发服和容器模板都在通用 `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 210m`
- 该值只用于让携带参考图 Data URL 的创作接口和游戏发行包 PUT(路由上限 200 MiB + 1 KiB抵达 `api-server`;不要把它当作业务上传上限。Rust 路由仍通过 `DefaultBodyLimit` 和解码后字节校验限制具体接口,例如拼图参考图路由只放宽到 12 MiB 请求体,图片字节继续按业务规则拒绝。Pingora 网关侧的 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` 必须同样不低于该值,否则请求会在网关层被 413。
- 若线上看到 `413 Request Entity Too Large`,并且 access log 里 `request_time=0.000 upstream_status=-`,通常是 Nginx 没有加载该模板或未 reload;先执行 `nginx -T | grep client_max_body_size``nginx -t` 再检查 `api-server`
## gzip
+3 -2
View File
@@ -119,8 +119,9 @@ server {
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
location ~ ^/api(?:/|$) {
default_type application/json;
# 中文注释:创作接口会携带参考图 Data URLNginx 只放行到 api-server真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。
client_max_body_size 64m;
# 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server
# 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。
client_max_body_size 210m;
limit_conn genarrative_api_conn 64;
limit_req zone=genarrative_api_rps burst=64 nodelay;
+3 -2
View File
@@ -139,8 +139,9 @@ server {
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
location ~ ^/api(?:/|$) {
default_type application/json;
# 中文注释:创作接口会携带参考图 Data URLNginx 只放行到 api-server真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。
client_max_body_size 64m;
# 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server
# 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。
client_max_body_size 210m;
limit_conn genarrative_api_conn 64;
limit_req zone=genarrative_api_rps burst=64 nodelay;
@@ -124,14 +124,14 @@
"nginx": {
"production": [
"location ~ ^/api(?:/|$)",
"client_max_body_size 64m;",
"client_max_body_size 210m;",
"limit_conn genarrative_api_conn 64;",
"limit_req zone=genarrative_api_rps burst=64 nodelay;",
"add_header X-Accel-Buffering no always;"
],
"development": [
"location ~ ^/api(?:/|$)",
"client_max_body_size 64m;",
"client_max_body_size 210m;",
"limit_conn genarrative_api_conn 64;",
"limit_req zone=genarrative_api_rps burst=64 nodelay;",
"add_header X-Accel-Buffering no always;"
+1 -1
View File
@@ -28,7 +28,7 @@ GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE=/var/lib/genarrative/maintenance/en
GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE=/var/lib/genarrative/maintenance/page.html
GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=http
GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=67108864
GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960
# gzip 默认开启;等级和最小响应长度对齐 Nginx gzip_comp_level 5 / gzip_min_length 1024。
# Pingora 正式化口径固定为 gzip-onlybr / zstd 不进入当前网关,Brotli 继续由 Nginx / 前置代理承担。
GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS=gzip
@@ -0,0 +1,49 @@
# AGC 发行包分片续传上传实施计划
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | runtime-smoke-passed(存储原语、服务端入口、原生上传器、渲染进程接线与真实栈分片续传 smoke 均已落地) |
| Date | 2026-09-23 |
| Parent Milestone | `docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md` |
## 修改边界与顺序
1. **存储原语(已完成)**`server-rs/crates/platform-oss/src/lib.rs` 新增 `append_internal_object` / `append_internal_object_with_retry``OssAppendInternalObjectRequest` / `OssAppendInternalObjectResponse`;复用现役 V4 签名助手 `signed_request_builder`(查询串已参与签名)与 `run_internal_put_with_retry` 的可重试分类。`position = 0` 追加到末尾,`position > 0` 必须等于对象当前长度;返回 `next_position` 作为权威已收字节。
2. **服务端入口(已完成)**`server-rs/crates/api-server/src/modules/game_distribution.rs`
- 新增 `GET .../package/upload-state``PUT .../package/chunk``POST .../package/complete``POST .../package/reset` 四个路由,沿用作者鉴权、`game-distribution:publish` 灰度开关与 `Idempotency-Key` 约定;
- 分片大小 `PACKAGE_UPLOAD_CHUNK_BYTES = 8 MiB`,分片请求体放行量为分片大小 + 1 KiB;
- 从整包 `PUT` 抽出共享收口 `confirm_validated_package`(声明比对 → 确认 → 结构化事件),两种入口共用;
- 新增 `game_distribution_oss_client` / `game_distribution_package_object_key` / `staged_package_bytes` / `require_octet_stream_content_type` / `package_upload_offset` 辅助函数;偏移不一致返回 `409 PACKAGE_UPLOAD_OFFSET_MISMATCH` 与权威偏移;未收齐返回 `409 PACKAGE_UPLOAD_INCOMPLETE`;校验失败删除半包并落 `upload_failed`
3. **AGC 原生上传器(已完成)**:新增 `apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs`:内容寻址暂存(`<appData>/game-package-staging/<sha256>.zip`,重启后同包复用同一文件)、`upload-state → chunk → complete` 循环、409 权威偏移续传(响应丢失后按服务端已收字节对齐,不重放不跳段)、仅对传输/超时/408/429/5xx 退避重试(默认 4 次尝试)、`game-package-upload-progress` 进度事件;暂存路径必须落在暂存目录内。命令 `prepare_local_project_game_package` / `upload_local_project_game_package` 已注册,整包回传命令 `read_local_project_export_package` 退役(`read_local_project_export_package_at` 仍供暂存使用)。
4. **渲染进程接线(已完成)**`apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts` 改为 `prepare`(拿摘要与暂存路径)→ 创建游戏 → 创建版本 → 原生分片上传 → 送审;`LocalProjectExportPackagePayload` 整包类型退役,改为 `StagedGamePackage` / `GamePackageUploadOutcome`;不再有任何整包字节进 IPC。
5. **真实栈 smoke(已完成)**:本地 api-server + 真实 OSS bucket 上跑通「中断 → 续传 → 确认」。做法与证据:
- 先用 `npm run dev:spacetime` 把当前模块发布到本地库(`genarrative-game-creator-dev`,自动迁移完成),再用 `npm run dev:api-server``127.0.0.1:8082`
- 本地库的 `feature_gate_config` 原本为空(发布开关默认关闭),用 `spacetime call … upsert_feature_gate_config` 写入 `game-distribution:publish enabled=true rollout=100`
- `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL=http://127.0.0.1:8082 npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts`**1 passed / 3.9s**(发行包 9.0 MiB,跨 8 MiB 分片边界);
- 用例断言实际发送过的分片偏移序列等于 `[0, 8388608]`:第一片只发一次,中断后的续传从权威偏移开始,不重放也不跳段;
- api-server 侧同一轮日志:`package_chunk_stored offset=0 chunk_bytes=8388608 received_bytes=8388608 elapsed_ms=201``package_chunk_stored offset=8388608 chunk_bytes=1049210 received_bytes=9437818 elapsed_ms=82``package_confirmed package_bytes=9437818 file_count=3 oss_put_skipped=true elapsed_ms=884`
- 为了能指向本地栈,用例还补了两处基础设施修正:把客户端平台基址切到传入的 base URL(`setClientServerSelection({preset:'custom'})`),以及桥接层把 jsdom realm 的 `Headers` / `Blob` / `FormData` 降级成 Node 侧原生值(`FormData` 手工序列化为 multipart 字节,否则 OSS 直传回 405)。
## 不改的部分
- 网页端发布路径与整包 `PUT` 语义不变;`MAX_PACKAGE_BYTES`、展开量、单文件与文件数上限不变。
- 未新增 SpacetimeDB 表或字段:已收字节的事实来源是 OSS 对象长度,版本状态机沿用既有 `awaiting_upload → uploaded → …`
- 未引入半包定时清理任务。
## 验证命令
- `cargo test -p platform-oss`74 passed
- `cargo test -p api-server game_distribution`23 passed,含新增 `package_chunk_size_stays_inside_declared_limits``package_upload_offset_requires_non_negative_integer``package_chunk_content_type_must_be_octet_stream`
- `cargo fmt --all -- --check``npm run check:encoding``npm run check:doc-index``git diff --check`
- `cargo test game_package_upload`AGC 原生侧 4 passed:分片规划无缝无重叠、409 权威偏移解析、URL 拼接、内容寻址暂存与路径校验)
- `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit``npm run --workspace apps/ai-game-creator-shell typecheck`(含 `check-config.mjs` 的命令登记门禁)
- `npx vitest run`(发布函数 6 passed、发布面板 9 passed、发布反馈 5 passed;真实链路用例在无 `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL` 时按设计跳过)
- 待做:真实栈 smoke(本地 api-server + 真实 OSS bucket 上跑「中断 → 续传 → 完成」,含 `x-oss-next-append-position` 语义确认)
## 风险与回滚点
- **对象可追加性**`platform-oss` 之前没有追加写,首次真实调用需要在真实 bucket 上确认 `x-oss-next-append-position` 语义;失败时回滚点是 `platform-oss` 新增函数与四条路由(整包 `PUT` 不受影响,可独立回退)。
- **半包对象**:分片写入直接落在版本键上,未完成时是半包。它不进公开目录、不服务发行网关;失败或作者重置时删除。若删除失败会记录 `package_staging_delete_failed` 告警,需要人工确认对象键状态。
- **重置语义**:只有 `awaiting_upload` / `upload_failed` 允许重置,避免破坏已确认事实。
- **内存**:完成动作按 200 MiB 上限回读整包再校验,峰值与整包 `PUT` 同量级;分片路径不再让整包驻留客户端。
@@ -0,0 +1,58 @@
# AGC 发行包分片续传上传
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | runtime-smoke-passed(真实栈「中断 → 续传 → 确认」已通过;AGC 真机一键发布与 200 MiB 档容量数据未验证) |
| Date | 2026-09-23 |
| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(真实发行包与资料合同第 10 条、幂等并发与恢复) |
## 背景与触发
AGC 一键发布今天把整包字节从 WebView 侧送出:`read_local_project_export_package` 先把 `packageBytes` 整包过一遍 IPC 回到渲染进程,渲染进程再用 `@tauri-apps/plugin-http` 发整包 `PUT`,而该插件会把 body 序列化成 `Array.from(new Uint8Array(buffer))` 再走一次 IPC。两次整包 IPC 决定了 AGC 实际可发布的包远小于服务端 200 MiB 上限,失败时表现为客户端侧传输错误(例如「无法连接登录服务」),服务端访问日志里没有这次请求;断流后也只能整包白传。本里程碑把上传下沉到原生侧并支持分片续传。
## 目标
1. AGC 一键发布由原生进程直接读取本地试玩包、按服务端下发的固定分片大小上传,整包字节不再经过 WebView IPC。
2. 传输中断、网络失败、客户端进程退出或应用重启后,同一 `versionId` 只补传缺失字节,不白传整包。
3. 分片入口与现役整包 `PUT` 共用同一版本状态机、摘要口径、幂等键与包校验;网页端发布路径不变。
## 不在本里程碑内
- 不改网页端发布路径(继续整包 `PUT`),不为浏览器实现续传。
- 不做并行分片上传、不做客户端直传 OSS(分片仍经 `api-server` 转发,与今天整包路径同一出口)。
- 不做「后台自动续传」:续传只在下一次发布动作或应用重启后的重试里发生,不引入常驻重传任务。
- 不做未完成分片会话的定时清理任务;半包对象的回收单独开里程碑。
- 不改发行包上限、展开量、单文件与文件数上限。
## 合同要点
- **入口与状态**:分片续传对既有 `versionId` 生效,版本状态沿用 `awaiting_upload → uploaded → …`;分片入口与整包入口互斥,同一版本同时只能有一个写入者,第二个写入返回 `409 UPLOAD_IN_PROGRESS`
- **权威偏移**:服务端记录的已收字节是唯一权威。客户端分片偏移与之不符时返回 `409` 与权威偏移,客户端按权威偏移续传;重复分片不得造成重复写入。
- **完成动作**:全部字节到齐后才执行校验与确认;校验失败删除半包对象并把版本落到 `upload_failed``recoveryAction=reupload`)。重新上传同一版本前必须显式重置分片会话,重置后偏移归零,不允许在半包之上续写不同字节。
- **可见性**:半包对象不进入公开目录、不服务发行网关、不改变当前公开版本;与既有「未通过审核不改变 `activeVersionId`」口径一致。
- **原生侧边界**:原生上传只读本地试玩包并逐片发送,进度以事件回传渲染进程;渲染进程不再持有整包字节。
## 依赖
- `platform-oss`:需要一组可续写的对象写入原语(追加语义或等价的分片会话),以及读取已收字节的探测能力;现役只有整对象 `PUT`
- `api-server``modules/game_distribution.rs` 新增分片入口与完成动作,复用既有 `validate_release_zip`、OSS 上传重试分类、`package_confirmed` / `package_rejected` 可观测事件。
- AGC`src-tauri` 新增原生上传命令与进度事件,`src/services/gameDistributionPublish.ts` 改为调用原生命令;`read_local_project_export_package` 不再为发布回传整包字节。
- 反代/网关:分片请求体远小于现役 210 MiB 放行量,沿用现有配置,不改限额。
## 验收标准
1. **不再整包过 IPC**:发布 200 MiB 档包时,渲染进程侧不出现整包字节(对照 `read_local_project_export_package` 的返回体与 IPC 报文大小),上传由原生进程完成。
2. **续传生效**:上传中途断开传输后重发同一版本,只补传缺失分片;分片请求数、已传字节与最终包摘要三项均可复核。
3. **跨重启续传**:上传中断时退出应用并重启,重新发布时服务端返回权威已收字节,客户端从该偏移继续,最终确认成功。
4. **偏移与重复**:分片偏移不符返回 `409` 与权威偏移;重复提交同一分片不产生重复写入;同版本第二个写入者返回 `409 UPLOAD_IN_PROGRESS`
5. **失败关闭**:完成动作里校验失败(非法 ZIP、超限、压缩比越界等)删除半包对象、版本落 `upload_failed`,半包不出现在公开目录,也不影响当前公开版本。
6. **兼容与回归**:整包 `PUT` 路径与既有测试保持绿;`npm run check:doc-index``npm run check:encoding``git diff --check` 通过;`check:spacetime-schema` 按是否新增持久字段决定是否纳入。
7. **运行时证据(已获得)**:本地 api-server`127.0.0.1:8082`,库 `genarrative-game-creator-dev`+ 真实 OSS bucket 上跑通 `gameDistributionPublishLive.test.ts`9.0 MiB 发行包跨 8 MiB 分片边界,第一片只发送一次,中断后续传从权威偏移 `8388608` 继续、第二片 `received_bytes=9437818`,最后 `package_confirmed``oss_put_skipped=true`);整轮 3.9s。**未获得**:AGC 真机(Tauri 运行时)一键发布的端到端运行,以及 200 MiB 档的耗时 / 内存容量数据。
## 待评审的决策点
1. **续写原语**:OSS 追加写(顺序、单对象、续传只需回读当前长度)对比 OSS Multipart(可并行、更通用但需要多组新操作)。建议追加写,顺序续传已满足本里程碑目标。
2. **分片大小**:建议 8 MiB200 MiB 上限 → 最多 25 片,单片请求体远低于现役放行量)。
3. **重置语义**:建议只有显式重置(作者点「重新上传」或 `reupload` 恢复动作)才删除半包并归零;其余情况一律按权威偏移续传。
4. **半包回收**:本里程碑只标记未完成会话,不做定时清理;回收另立里程碑(涉及「不得删除仍被公开版本引用的对象」口径)。
@@ -120,7 +120,7 @@
### 行为与验收
- [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销”。
- [ ] 100 MiB 包与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。
- [ ] 200 MiB 包(现行上限,见 2026-09-23 决策记录)与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。已有证据覆盖 100 MiB 档,上限提升后的档位待复跑。
- [ ] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件。
- [ ] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界。
- [ ] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查。
@@ -1,5 +1,20 @@
# 决策记录
## 2026-09-23 自绘标题栏是窗口边框:弹层从它下方开始,焦点陷阱放行它
- 背景:AGC 打开任意一个 `ThemedModal` 弹窗(发布面板、发布进度、资源预览、账本、错误报告等)后,右上角「最小化 / 最大化 / 关闭」点击没有任何反应,标题栏拖拽也不能移动窗口;关掉弹窗立刻恢复。原因是标题栏在模态之外,而 `focus-trap-react` 在 document 捕获阶段监听 `mousedown`/`touchstart`/`click`,模态外的点击被 `preventDefault()``click` 直接 `stopImmediatePropagation()` —— React 的监听在更内层,事件到不了它,所以表现是「点了没反应」而不是报错。另有 `.app-update-overlay``inset: 0` 真的把标题栏盖住了。
- 决策:把自绘标题栏定为**窗口边框**,不属于弹层内容:① portal 到 body 的全屏弹层一律 `top: var(--window-chrome-height)`,禁止用 `inset: 0` 盖住标题栏;② `ThemedModal` 的焦点陷阱用 `allowOutsideClick` 只放行落在 `[data-window-chrome-bar]` 内的目标,工作区内容的点击继续被拦住;③ `WindowChrome` 的标题栏加 `data-window-chrome-bar` 标记,作为这条约定的唯一契约点。
- 影响范围:`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx``apps/ai-game-creator-shell/src/components/WindowChrome.tsx``apps/ai-game-creator-shell/src/styles.css``:root` 注释、`.app-update-overlay``.game-publish-progress-overlay`)。
- 验证方式:`tests/themedModal.test.tsx`(标题栏点击放行、工作区点击仍被拦)、`tests/WindowChrome.test.tsx`(弹窗打开时三个窗口按钮仍调用原生窗口 API)、`tests/windowChromeOverlayContract.test.ts`7 个全屏弹层都从标题栏下方开始)、`tests/gamePublishFeedback.test.tsx` 与 appSurface208 passed);两处新增用例都做过「去掉修复即失败」的反向确认。`npm run --workspace apps/ai-game-creator-shell typecheck`、eslint、`npm run check:encoding``git diff --check` 通过。
## 2026-09-23 游戏发行包上限提升到 200 MiB(反代放行量与发行缓存同步)
- 背景:游戏广场发行包上限原为 100 MiB(`module-game-distribution``MAX_PACKAGE_BYTES` 与网页端 `GAME_PACKAGE_MAX_BYTES`),而 Nginx 三份模板与 Pingora 网关的通用 `/api` 放行量是 64 MiB。上限只改一层没有意义:包体超过 100 MiB 时先在反代层被 413`api-server` 的 ZIP 校验根本不会执行。
- 决策:发行包上限 100 MiB → 200 MiB;展开总量 250 MiB → 500 MiB(保持 2.5 倍余量);单文件 64 MiB、最多 10,000 个文件、展开/压缩比 100 三条内容规则不变;发行包路由请求体上限继续从包上限派生(200 MiB + 1 KiB)。反代放行量统一放宽到 210 MiB:`deploy/nginx/genarrative.conf``deploy/nginx/genarrative-dev-http.conf``deploy/container/nginx.conf` 使用 `client_max_body_size 210m`Pingora `DEFAULT_MAX_API_BODY_BYTES` 改为 `220200960` 并同步 `deploy/pingora/pingora-gateway.env.example`。发行静态资源进程内缓存字节预算 200 MiB → 256 MiB,让 200 MiB 档发行包仍能进缓存、且不独占整份预算。
- 边界:包内单个文件仍不得超过 64 MiB;线上 Pingora 环境文件若仍写 `67108864`,必须在重启网关前同步改值,否则发行包 PUT 会在网关层被 413。AGC 一键发布经 `@tauri-apps/plugin-http` 传整包字节,实际可发布体积还受该传输方式限制,200 MiB 档的客户端容量需要单独验证。
- 影响范围:`server-rs/crates/module-game-distribution/src/package.rs``server-rs/crates/api-server/src/modules/game_distribution.rs``server-rs/crates/pingora-gateway/src/main.rs``src/components/game-distribution/gameZipPackage.ts``deploy/{nginx,container,pingora}``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`
- 验证方式:`cargo test -p module-game-distribution`13 passed,其中 `accepts_package_above_the_previous_hundred_mib_limit` 用两个 50 MiB 存储型条目构造 100 MiB 出头的包;把上限临时改回 100 MiB 时该用例确实失败,证明它能守住新上限)、`cargo test -p api-server game_distribution`(20 passed,含新增的请求体上限覆盖包上限断言)、`cargo test -p pingora-gateway`38 passed,含 `matches_nginx_route_parity_matrix`)、`npx vitest run src/components/game-distribution`46 passed)、`cargo fmt --all -- --check``npm run check:encoding``npm run check:doc-index``git diff --check` 通过。`npm run check:pingora-route-parity` 仍在 dev-http / 容器模板缺少 `/games` 等 SPA 路由处失败,改动前同样失败,与本次口径无关。200 MiB 档真实栈容量证据(上传耗时、api-server 峰值内存、超限 413 口径)尚未复跑,发布前需按阶段 D 脚本重跑一轮。
## 2026-09-23 引用名不允许空白:素材 / Skill / 附件共用 `normalizeMentionName`
- 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero``hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠``@hero v2` 内部也算一次合法命中。
@@ -4,6 +4,14 @@
策划 Runtime 会通过状态事件与命令返回交付同一份最终视图。若前端清空临时正文后再拿“最后一条非用户历史消息”回填动画,就会出现正式回复旁又播放一遍、播放后消失的假重试。正文应按 `messageId` 保存显示进度,与正式消息共用一个气泡;请求完成不清动画,不延迟正式业务状态。Provider 自动重试复用消息 ID 并发送空文本,只允许重置未持久化的该条回复。正文、工具状态和 reasoning 分开;事件与异步命令收尾均检查项目及活动回合,旧请求不能覆盖新回合。详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
## 2026-09-23 弹窗打开时自绘标题栏的最小化 / 最大化 / 关闭静默失效
- **现象**:AGC 打开「发布到游戏广场」面板(以及其它任何弹窗)后,右上角三个窗口按钮点了没有任何反应,拖拽标题栏也不能移动窗口;关掉弹窗立刻恢复。标题栏看着完全正常,遮罩也明显只压住了下面的工作区,所以很容易误判成「按钮自己坏了」或 Tauri 窗口 API 挂了。
- **原因**:标题栏在模态之外,但它是窗口边框。`ThemedModal` 用的 `focus-trap-react` 在 **document 捕获阶段**监听 `mousedown`/`touchstart`/`click`:模态外的点击一律 `preventDefault()``click` 还会 `stopImmediatePropagation()`。React 的监听挂在 document 内的根容器上,捕获阶段就被掐掉的 `click` 永远到不了 React,于是既不报错也不执行 —— 与「焦点陷阱吞掉模态外点击」是同一类问题(见 2026-09-20 发布面板焦点陷阱那条)。另有一条独立的同类缺陷:`.app-update-overlay``inset: 0`,把标题栏真的盖住了,更新弹窗期间按钮被遮罩挡住。
- **处理(现行口径)**:① 全屏弹层一律从标题栏下方开始(`top: var(--window-chrome-height)`),不得用 `inset: 0` 盖住标题栏;② `ThemedModal` 的焦点陷阱用 `allowOutsideClick` 只放行落在 `[data-window-chrome-bar]` 内的目标,工作区内容点击继续被拦;③ 新增全屏弹层时把类名补进 `apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts` 的清单。
- **验证**`npx vitest run apps/ai-game-creator-shell/tests/themedModal.test.tsx apps/ai-game-creator-shell/tests/WindowChrome.test.tsx apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts`(标题栏点击放行、工作区点击仍被拦、7 个全屏弹层都在标题栏下方);两个新增用例去掉修复后确实失败,确认能守住这条约定。
- **关联**`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx``apps/ai-game-creator-shell/src/components/WindowChrome.tsx``apps/ai-game-creator-shell/src/styles.css`
## Direct 宿主继续请求不能重发原始用户条目
原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。

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