合并 master:标题栏弹层契约、发行包上限与策划回复修复等上游变更
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m51s
Project CI / Backend tests (pull_request) Successful in 4m57s
Project CI / Native shell tests (pull_request) Successful in 6m8s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m12s
Project CI / Frontend tests (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m14s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m21s
Project CI / Repository checks (pull_request) Successful in 1m45s

- 合并 origin/master(自绘标题栏作为窗口边框的弹层契约、游戏发行包上限提升到 200 MiB、策划回复重复播放修复 #501、标题栏品牌标签用例修正 f0ef06891 等)到本分支,保持 PR #497 可合并。
- 冲突仍只在 `docs/project-memory/shared-memory/decision-log.md` 顶部新增条目:按「新条目在上」保留双方内容,本分支的运行视窗条目在前。
- 上一轮合并记录的「`WindowChrome` 用例断言渠道产品名 stale」已由上游 f0ef06891 自行修复,本轮合并后该用例恢复通过。
- 上游改动未触及本分支的运行页 chrome 与预览适配文件;合并后重跑 `ai-game-creator-shell:check:web`、编码检查与 `git diff --check`。
This commit is contained in:
2026-09-23 19:37:07 +08:00
40 changed files with 2518 additions and 397 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,
+76 -177
View File
@@ -116,6 +116,7 @@ import {
type DirectProjectInitialTurn,
} from './view/project-development/chat/DirectProjectChatView';
import { PlanningChatView } from './view/project-development/planning/PlanningChatView';
import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation';
import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel';
function isPersistableDirectCodexConversationMessage(message: ChatMessage) {
@@ -366,23 +367,31 @@ export function App({
// 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。
const [gamePublishAllowed, setGamePublishAllowed] = useState(false);
const [projectChatError, setProjectChatError] = useState('');
const [designAgentTransientReply, setDesignAgentTransientReplyVisible] =
useState('');
const designAgentTransientReplyTargetRef = useRef('');
const designAgentVisibleReplyRef = useRef('');
const designAgentPendingViewRef = useRef<{
clientTurnId: string;
projectPath: string;
view: DesignView;
} | null>(null);
const designReplyAnimation = useDesignReplyAnimation();
const [designAgentStatus, setDesignAgentStatus] = useState('');
const [designAgentReasoning, setDesignAgentReasoning] = useState('');
function setDesignAgentTransientReplyTarget(next: string) {
designAgentTransientReplyTargetRef.current = next;
if (!next) {
designAgentVisibleReplyRef.current = '';
setDesignAgentTransientReplyVisible('');
}
function isCurrentDesignTurn(projectPath: string, clientTurnId: string) {
const tracked = designAgentTurnRef.current;
return (
localProjectPathRef.current === projectPath &&
tracked?.projectPath === projectPath &&
tracked.clientTurnId === clientTurnId
);
}
function beginDesignTurn(projectPath: string, clientTurnId: string) {
designAgentTurnRef.current = { projectPath, clientTurnId };
designAgentReasoningTurnRef.current = { projectPath, clientTurnId };
designReplyAnimation.reset(
latestMessagesRef.current.flatMap((message) =>
message.messageId ? [message.messageId] : [],
),
);
setDesignAgentStatus('');
setDesignAgentReasoning('');
setProjectChatError('');
setChatAgentBusy(true);
}
function designAgentEventSubscriptionReady() {
@@ -407,35 +416,12 @@ export function App({
designAgentEventSubscriptionResolveRef.current = null;
}
useEffect(() => {
const timer = window.setInterval(() => {
const target = designAgentTransientReplyTargetRef.current;
setDesignAgentTransientReplyVisible((current) => {
if (!target) {
designAgentVisibleReplyRef.current = '';
return '';
}
const prefix = target.startsWith(current) ? current : '';
if (prefix === target) {
designAgentVisibleReplyRef.current = target;
return target;
}
const remaining = target.length - prefix.length;
const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1;
const next = target.slice(0, prefix.length + step);
designAgentVisibleReplyRef.current = next;
return next;
});
}, 50);
return () => window.clearInterval(timer);
}, []);
/**
* 策划 Agent 的实时事件流:本轮流式正文、思考过程和回合中途的视图都靠它推给界面。
*
* 订阅建立是异步的,而回合由一个 invoke 发起;`designAgentEventSubscriptionReady()`
* 让回合等监听器挂好再开始,避免开头几个事件丢掉。事件只认当前项目;有在跑的回合
* 还要认本轮 `clientTurnId`,迟到的上一轮事件不会画到这一轮上
* 让回合等监听器挂好再开始,避免开头几个事件丢掉。正文和视图严格匹配活动回合
* reasoning 另按原回合接收迟到补充,不能让过期视图重播正文
*/
useEffect(() => {
const ready = createDesignAgentEventSubscriptionReady();
@@ -452,19 +438,7 @@ export function App({
let disposed = false;
void subscribeTauriEvent<DesignEvent>('design-agent-update', (event) => {
const payload = event.payload;
const tracked = designAgentTurnRef.current;
if (
payload.projectPath !== localProjectPathRef.current ||
(tracked && payload.clientTurnId !== tracked.clientTurnId)
) {
return;
}
if (
(payload.kind === 'text' || payload.kind === 'tool') &&
payload.text
) {
setDesignAgentTransientReplyTarget(payload.text);
}
if (payload.projectPath !== localProjectPathRef.current) return;
if (payload.reasoningText != null) {
const reasoningTurn = designAgentReasoningTurnRef.current;
if (
@@ -474,8 +448,21 @@ export function App({
setDesignAgentReasoning(payload.reasoningText);
}
}
if (!isCurrentDesignTurn(payload.projectPath, payload.clientTurnId))
return;
if (
payload.kind === 'text' &&
payload.messageId &&
payload.text != null
) {
designReplyAnimation.receiveText(payload.messageId, payload.text);
if (payload.text) setDesignAgentStatus('');
}
if (payload.kind === 'tool' && payload.text != null) {
setDesignAgentStatus(payload.text);
}
if (payload.view) {
applyDesignAgentViewAfterTransient(
applyDesignAgentTurnView(
payload.view,
payload.projectPath,
payload.clientTurnId,
@@ -557,67 +544,18 @@ export function App({
latestMessagesRef.current = conversation;
}
function commitDesignAgentView(view: DesignView, projectPath: string) {
const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId;
designAgentPendingViewRef.current = null;
applyDesignView(view, projectPath);
setDesignAgentReasoning('');
setDesignAgentTransientReplyTarget('');
if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) {
designAgentTurnRef.current = null;
}
}
function applyDesignAgentViewAfterTransient(
function applyDesignAgentTurnView(
view: DesignView,
projectPath: string,
clientTurnId: string,
) {
let target = designAgentTransientReplyTargetRef.current;
const tracked = designAgentTurnRef.current;
if (!target.trim() && !view.running) {
const latestAssistantText = [...view.messages]
.reverse()
.find((message) => message.role !== 'user' && message.text.trim())
?.text.trim();
if (latestAssistantText) {
setDesignAgentTransientReplyTarget(latestAssistantText);
target = latestAssistantText;
}
}
if (
!view.running &&
tracked?.clientTurnId === clientTurnId &&
target.trim() &&
designAgentVisibleReplyRef.current !== target
) {
designAgentPendingViewRef.current = {
clientTurnId,
projectPath,
view,
};
return;
}
commitDesignAgentView(view, projectPath);
if (!isCurrentDesignTurn(projectPath, clientTurnId)) return;
designReplyAnimation.receiveView(view);
applyDesignView(view, projectPath);
setDesignAgentReasoning('');
if (!view.running) setDesignAgentStatus('');
}
useEffect(() => {
const timer = window.setInterval(() => {
const pending = designAgentPendingViewRef.current;
if (!pending) {
return;
}
const target = designAgentTransientReplyTargetRef.current;
if (target && designAgentVisibleReplyRef.current !== target) {
return;
}
commitDesignAgentView(pending.view, pending.projectPath);
}, 50);
return () => window.clearInterval(timer);
// 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function hydrateDesignAgentSession(nextProjectPath: string) {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath.trim()) {
@@ -647,32 +585,18 @@ export function App({
setProjectChatError('需要在 Tauri App 内运行。');
return;
}
designAgentTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentReasoningTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentPendingViewRef.current = null;
beginDesignTurn(nextProjectPath, clientTurnId);
await designAgentEventSubscriptionReady();
setChatAgentBusy(true);
setProjectChatError('');
setDesignAgentTransientReplyTarget('');
setDesignAgentReasoning('');
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return;
try {
const view = await invoke<DesignView>('continue_design_agent_session', {
projectPath: nextProjectPath,
clientTurnId,
input,
});
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId);
applyDesignAgentTurnView(view, nextProjectPath, clientTurnId);
} catch (error) {
if (localProjectPathRef.current !== nextProjectPath) {
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) {
return;
}
const message = error instanceof Error ? error.message : String(error);
@@ -680,12 +604,13 @@ export function App({
requestRuntimeConfigOpen();
}
setProjectChatError(message);
designReplyAnimation.discardUnpersisted();
} finally {
if (!designAgentPendingViewRef.current) {
if (isCurrentDesignTurn(nextProjectPath, clientTurnId)) {
designAgentTurnRef.current = null;
setDesignAgentTransientReplyTarget('');
setDesignAgentStatus('');
setChatAgentBusy(false);
}
setChatAgentBusy(false);
}
}
@@ -876,7 +801,8 @@ export function App({
}, [
messages,
projectChatError,
designAgentTransientReply,
designReplyAnimation.replies,
designAgentStatus,
designAgentReasoning,
designAgentView,
pendingUiConfirmation,
@@ -1366,11 +1292,12 @@ export function App({
* 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。
*/
function resetChatState() {
setChatAgentBusy(false);
setChatFilesImporting(false);
setChatFileImportNotice('');
setProjectChatError('');
setDesignAgentTransientReplyTarget('');
designAgentPendingViewRef.current = null;
designReplyAnimation.reset();
setDesignAgentStatus('');
setDesignAgentReasoning('');
setDesignAgentActive(planningStartMode);
designAgentActiveRef.current = planningStartMode;
@@ -2295,7 +2222,8 @@ export function App({
pendingConfirmation={pendingUiConfirmation}
projectPath={localProject?.projectPath ?? projectPath}
conversationMessages={messages}
transientReply={designAgentTransientReply}
replyAnimations={designReplyAnimation.replies}
designStatus={designAgentStatus}
showDesignReasoning={designAgentActive}
designReasoning={designAgentReasoning}
designReasoningEntries={
@@ -2313,66 +2241,37 @@ export function App({
return;
}
const clientTurnId = createAgentChatRunId('design-agent-turn');
designAgentTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentReasoningTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentPendingViewRef.current = null;
setDesignAgentTransientReplyTarget('');
setDesignAgentReasoning('');
setChatAgentBusy(true);
beginDesignTurn(nextProjectPath, clientTurnId);
void designAgentEventSubscriptionReady()
.then(() =>
invoke<DesignView>('decide_design_phase', {
.then(() => {
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId))
return null;
return invoke<DesignView>('decide_design_phase', {
projectPath: nextProjectPath,
clientTurnId,
requestId,
approved,
}),
)
});
})
.then((view) => {
if (
localProjectPathRef.current !== nextProjectPath ||
designAgentTurnRef.current?.projectPath !==
nextProjectPath ||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
) {
return;
}
applyDesignAgentViewAfterTransient(
if (!view) return;
applyDesignAgentTurnView(
view,
nextProjectPath,
clientTurnId,
);
})
.catch((error) => {
if (
localProjectPathRef.current !== nextProjectPath ||
designAgentTurnRef.current?.projectPath !==
nextProjectPath ||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
) {
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId))
return;
}
setProjectChatError(String(error));
designReplyAnimation.discardUnpersisted();
})
.finally(() => {
if (
localProjectPathRef.current !== nextProjectPath ||
designAgentTurnRef.current?.projectPath !==
nextProjectPath ||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
) {
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId))
return;
}
if (!designAgentPendingViewRef.current) {
designAgentTurnRef.current = null;
setDesignAgentTransientReplyTarget('');
}
designAgentTurnRef.current = null;
setDesignAgentStatus('');
setChatAgentBusy(false);
});
}
+13 -4
View File
@@ -796,12 +796,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 {
@@ -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;
}
@@ -9810,7 +9820,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;
@@ -31,6 +31,7 @@ import {
DesignAgentPendingActions,
DesignAgentPhaseStatus,
} from './DesignAgentSurface';
import type { DesignReplyAnimation } from './useDesignReplyAnimation';
/** 后台任务失败文案要过一遍运行态错误解释器再给用户看。 */
function planningMessageText(message: Pick<ChatMessage, 'text' | 'role'>) {
@@ -80,7 +81,8 @@ type PlanningChatViewProps = {
onSubmit: FormEventHandler<HTMLFormElement>;
pendingConfirmation: PendingUiConfirmation | null;
projectPath: string;
transientReply: string;
replyAnimations?: DesignReplyAnimation[];
designStatus?: string;
showDesignReasoning?: boolean;
designReasoning?: string;
designReasoningEntries?: DesignReasoningEntry[];
@@ -117,7 +119,8 @@ export function PlanningChatView({
onSubmit,
pendingConfirmation,
projectPath,
transientReply,
replyAnimations = [],
designStatus = '',
showDesignReasoning = false,
designReasoning = '',
designReasoningEntries = [],
@@ -169,34 +172,67 @@ export function PlanningChatView({
const handleDesignClarify = onDesignClarify ?? (() => undefined);
const handleDesignRetry = onDesignRetry ?? (() => undefined);
const renderMessage = (message: ChatMessage, index: number) => (
<div
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
>
<AgentMessageContent tone="body">
<ChatMarkdownMessage
role={message.role}
text={planningMessageText(message)}
/>
</AgentMessageContent>
{showDesignReasoning && message.reasoningText ? (
<AgentReasoning
text={message.reasoningText}
label="策划 Agent 思考过程"
/>
) : null}
{message.role === 'user' && message.updatedAt ? (
<time
className="message-sent-at"
dateTime={new Date(message.updatedAt).toISOString()}
title={`发送于 ${new Date(message.updatedAt).toLocaleString('zh-CN', { hour12: false })}`}
>
{formatClockTime(message.updatedAt)}
</time>
) : null}
</div>
const animationsById = new Map(
replyAnimations.map((reply) => [reply.messageId, reply]),
);
const displayedMessages = [...visibleMessages];
const conversationIds = new Set(
conversationMessages.map((message) => message.messageId),
);
for (const reply of replyAnimations) {
if (
!reply.persisted &&
reply.target &&
!conversationIds.has(reply.messageId)
) {
displayedMessages.push({
role: 'assistant',
messageId: reply.messageId,
text: reply.target,
});
}
}
const renderMessage = (message: ChatMessage, index: number) => {
const animation = message.messageId
? animationsById.get(message.messageId)
: undefined;
const streaming = Boolean(
animation &&
(!animation.persisted || animation.visible !== animation.target),
);
return (
<div
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
aria-label={streaming ? '策划 Agent 实时回复' : undefined}
aria-live={streaming ? 'polite' : undefined}
data-message-id={message.messageId}
>
<AgentMessageContent tone="body">
<ChatMarkdownMessage
role={message.role}
text={animation ? animation.visible : planningMessageText(message)}
streaming={streaming}
/>
</AgentMessageContent>
{showDesignReasoning && message.reasoningText ? (
<AgentReasoning
text={message.reasoningText}
label="策划 Agent 思考过程"
/>
) : null}
{message.role === 'user' && message.updatedAt ? (
<time
className="message-sent-at"
dateTime={new Date(message.updatedAt).toISOString()}
title={`发送于 ${new Date(message.updatedAt).toLocaleString('zh-CN', { hour12: false })}`}
>
{formatClockTime(message.updatedAt)}
</time>
) : null}
</div>
);
};
return (
<section className="project-chat-surface" aria-label="立项策划对话">
@@ -251,7 +287,7 @@ export function PlanningChatView({
<ChatMarkdownMessage role="user" text={initialPlanningPrompt} />
</div>
) : null}
{visibleMessages.map(renderMessage)}
{displayedMessages.map(renderMessage)}
{showDesignReasoning
? designReasoningEntries
.filter((entry) => !entry.messageId)
@@ -269,20 +305,9 @@ export function PlanningChatView({
label="策划 Agent 思考过程"
/>
) : null}
{transientReply ? (
<div
className="message message--assistant"
aria-label="策划 Agent 实时回复"
aria-live="polite"
data-runtime-owned="true"
>
<AgentMessageContent>
<ChatMarkdownMessage
role="assistant"
text={transientReply}
streaming
/>
</AgentMessageContent>
{designStatus ? (
<div role="status" aria-label="策划 Agent 工具状态">
{designStatus}
</div>
) : null}
</div>
@@ -0,0 +1,113 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DesignView } from '../../../app/types';
export type DesignReplyAnimation = {
messageId: string;
target: string;
visible: string;
persisted: boolean;
};
/** 正文动画只管理显示进度,不延迟正式会话状态或参与请求收尾。 */
export function useDesignReplyAnimation() {
const entriesRef = useRef<DesignReplyAnimation[]>([]);
const historyIdsRef = useRef(new Set<string>());
const [replies, setReplies] = useState<DesignReplyAnimation[]>([]);
const publish = useCallback((next: DesignReplyAnimation[]) => {
entriesRef.current = next;
setReplies(next);
}, []);
const reset = useCallback(
(historyIds: string[] = []) => {
historyIdsRef.current = new Set(historyIds);
publish([]);
},
[publish],
);
const receiveText = useCallback(
(messageId: string, target: string) => {
if (historyIdsRef.current.has(messageId)) return;
const previous = entriesRef.current.find(
(entry) => entry.messageId === messageId,
);
// 空文本是同一 Provider 请求的 attempt 重置,不能清掉正式回复。
if (previous?.persisted) return;
const next = {
messageId,
target,
visible: target.startsWith(previous?.visible ?? '')
? (previous?.visible ?? '')
: '',
persisted: false,
};
publish(
previous
? entriesRef.current.map((entry) =>
entry.messageId === messageId ? next : entry,
)
: [...entriesRef.current, next],
);
},
[publish],
);
const receiveView = useCallback(
(view: DesignView) => {
let next = [...entriesRef.current];
for (const message of view.messages) {
if (
message.role !== 'assistant' ||
historyIdsRef.current.has(message.id)
)
continue;
const index = next.findIndex((entry) => entry.messageId === message.id);
const previous = next[index];
const entry = {
messageId: message.id,
target: message.text,
visible: message.text.startsWith(previous?.visible ?? '')
? (previous?.visible ?? '')
: '',
persisted: true,
};
if (index < 0) next.push(entry);
else next[index] = entry;
}
if (!view.running) {
const persistedIds = new Set(
view.messages.map((message) => message.id),
);
next = next.filter((entry) => persistedIds.has(entry.messageId));
}
publish(next);
},
[publish],
);
const discardUnpersisted = useCallback(() => {
publish(entriesRef.current.filter((entry) => entry.persisted));
}, [publish]);
useEffect(() => {
const timer = window.setInterval(() => {
let changed = false;
const next = entriesRef.current.map((entry) => {
const remaining = entry.target.length - entry.visible.length;
if (remaining <= 0) return entry;
changed = true;
const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1;
return {
...entry,
visible: entry.target.slice(0, entry.visible.length + step),
};
});
if (changed) publish(next);
}, 50);
return () => window.clearInterval(timer);
}, [publish]);
return { replies, reset, receiveText, receiveView, discardUnpersisted };
}
@@ -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);
});
});
@@ -405,11 +405,159 @@ export function registerDesignAgentSurfaceTests() {
);
});
it('keeps one Design Agent reply when repeated completion arrives after typing catches up', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designConversationView(),
});
const originalInvoke = harness.invoke.getMockImplementation()!;
let finish!: (view: unknown) => void;
harness.invoke.mockImplementation((command, args) =>
command === 'continue_design_agent_session'
? new Promise((resolve) => {
finish = resolve;
})
: originalInvoke(command, args),
);
renderDesignAgent(harness);
const input = await screen.findByLabelText('项目需求');
await expectDesignModelReady();
await setComposerText(input, '只回复 pong');
fireEvent.submit(input.closest('form') as HTMLFormElement);
await waitFor(() => expect(finish).toBeDefined());
const call = harness.invoke.mock.calls.find(
([command]) => command === 'continue_design_agent_session',
)!;
const clientTurnId = String(
(call[1] as { clientTurnId: string }).clientTurnId,
);
const messageId = `${clientTurnId}:response:0`;
const view = {
...designConversationView(),
messages: [{ id: messageId, role: 'assistant', text: 'pong' }],
};
const emit = (payload: Record<string, unknown>) =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
...payload,
});
act(() => emit({ kind: 'text', messageId, text: 'pong' }));
await screen.findByText('pong');
act(() => emit({ kind: 'state', view }));
act(() => emit({ kind: 'state', view }));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(screen.getAllByText('pong')).toHaveLength(1);
expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull();
await act(async () => finish(view));
expect(screen.getAllByText('pong')).toHaveLength(1);
expect(
harness.invoke.mock.calls.filter(
([command]) => command === 'continue_design_agent_session',
),
).toHaveLength(1);
});
for (const firstDelivery of ['event', 'command'] as const) {
it(`animates a whole Design Agent reply once when ${firstDelivery} completes first`, async () => {
const history = { id: 'history', role: 'assistant', text: '历史回复' };
const harness = createProjectChatRuntimeHarness({
designAgentView: { ...designConversationView(), messages: [history] },
});
const originalInvoke = harness.invoke.getMockImplementation()!;
const requests: {
clientTurnId: string;
finish: (view: unknown) => void;
}[] = [];
harness.invoke.mockImplementation((command, args) =>
command === 'continue_design_agent_session'
? new Promise((resolve) => {
requests.push({
clientTurnId: String(args?.clientTurnId),
finish: resolve,
});
})
: originalInvoke(command, args),
);
renderDesignAgent(harness);
await screen.findByText(history.text);
await expectDesignModelReady();
const input = screen.getByLabelText('项目需求');
await setComposerText(input, '继续');
fireEvent.submit(input.closest('form')!);
await waitFor(() => expect(requests).toHaveLength(1));
const request = requests[0];
const messageId = `${request.clientTurnId}:response:0`;
const reply = '整块返回也逐步显示';
const terminal = {
...designConversationView(),
messages: [history, { id: messageId, role: 'assistant', text: reply }],
};
const emit = (payload: Record<string, unknown>) =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId: request.clientTurnId,
...payload,
});
if (firstDelivery === 'event')
act(() => emit({ kind: 'state', view: terminal }));
else await act(async () => request.finish(terminal));
const bubble = screen.getByLabelText('策划 Agent 实时回复');
expect(bubble.textContent).not.toBe(reply);
expect(screen.getAllByText(history.text)).toHaveLength(1);
expect(
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
).toBe(false);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
const prefix = bubble.textContent;
expect(prefix?.length).toBeGreaterThan(0);
act(() => {
emit({ kind: 'state', view: terminal });
emit({ kind: 'state', view: terminal });
emit({ kind: 'text', messageId, text: '' });
});
expect(bubble.textContent).toBe(prefix);
await screen.findByText(reply);
expect(screen.getByText(reply).closest('.message')).toBe(bubble);
expect(
document.querySelectorAll(`[data-message-id="${messageId}"]`),
).toHaveLength(1);
expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull();
// 用户可在旧 invoke 尚未返回时开始下一轮;旧 finally 不能解锁新回合。
await setComposerText(input, '下一轮');
fireEvent.submit(input.closest('form')!);
await waitFor(() => expect(requests).toHaveLength(2));
act(() => {
emit({ kind: 'text', messageId: 'stale', text: '迟到旧回复' });
emit({ kind: 'state', view: terminal });
});
await act(async () => request.finish(terminal));
expect(
screen.getByRole('button', { name: '思考中' }).hasAttribute('disabled'),
).toBe(true);
expect(screen.queryByText('迟到旧回复')).toBeNull();
await act(async () => requests[1].finish(terminal));
});
}
it('follows streamed Design Agent content until the user scrolls up', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designConversationView(),
designAgentContinueView: designConversationView(),
});
const originalInvoke = harness.invoke.getMockImplementation()!;
let finish!: () => void;
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
harness.invoke.mockImplementation(async (command, args) => {
if (command === 'continue_design_agent_session') await gate;
return originalInvoke(command, args);
});
renderDesignAgent(harness);
const input = await screen.findByLabelText('项目需求');
@@ -459,11 +607,26 @@ export function registerDesignAgentSurfaceTests() {
projectPath: harness.projectPath,
clientTurnId,
kind: 'text',
messageId: `${clientTurnId}:response:0`,
text: '正在补充关卡节奏',
}),
);
await screen.findByLabelText('策划 Agent 实时回复');
await waitFor(() => expect(messageList.scrollTop).toBe(1000));
const replyBeforeTool = screen.getByLabelText('策划 Agent 实时回复');
const prefixBeforeTool = replyBeforeTool.textContent;
act(() =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'tool',
text: '正在读取方案文件',
}),
);
expect(screen.getByLabelText('策划 Agent 工具状态').textContent).toBe(
'正在读取方案文件',
);
expect(replyBeforeTool.textContent).toBe(prefixBeforeTool);
messageList.scrollTop = 120;
fireEvent.scroll(messageList);
@@ -478,6 +641,7 @@ export function registerDesignAgentSurfaceTests() {
projectPath: harness.projectPath,
clientTurnId,
kind: 'text',
messageId: `${clientTurnId}:response:0`,
text: '正在补充关卡节奏与多人规则',
});
});
@@ -488,6 +652,7 @@ export function registerDesignAgentSurfaceTests() {
).toContain('多人规则'),
);
expect(messageList.scrollTop).toBe(120);
await act(async () => finish());
});
it('shows only known optimistic send times and does not invent persisted times', async () => {
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { DesignView } from '../src/app/types';
import { useDesignReplyAnimation } from '../src/view/project-development/planning/useDesignReplyAnimation';
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
cleanup();
vi.useRealTimers();
});
function view(messages: DesignView['messages'], running = false): DesignView {
return {
session: {
sessionId: 's',
projectId: 'p',
currentPhase: 'concept',
approvedPhases: [],
pendingApproval: null,
pendingClarification: null,
turnIndex: 1,
lastError: null,
},
messages,
running,
canRetry: false,
};
}
it('keeps each reply progress across tool snapshots and repeated completion', () => {
const { result } = renderHook(useDesignReplyAnimation);
const history = { id: 'history', role: 'assistant', text: '历史' };
const first = { id: 't:response:0', role: 'assistant', text: '第一条回复' };
const second = { id: 't:response:1', role: 'assistant', text: '第二条回复' };
act(() => {
result.current.reset([history.id]);
result.current.receiveText(first.id, first.text);
vi.advanceTimersByTime(100);
});
expect(result.current.replies[0].visible).toBe('第一');
act(() =>
result.current.receiveView(
view(
[history, first, { id: 'tool', role: 'tool', text: '工具已完成' }],
true,
),
),
);
expect(result.current.replies).toHaveLength(1);
expect(result.current.replies[0].visible).toBe('第一');
act(() => result.current.receiveText(second.id, second.text));
const terminal = view([history, first, second]);
act(() => {
result.current.receiveView(terminal);
result.current.receiveView(terminal);
});
expect(result.current.replies.map((reply) => reply.visible)).toEqual([
'第一',
'',
]);
act(() => vi.advanceTimersByTime(500));
act(() => result.current.receiveView(terminal));
expect(result.current.replies.map((reply) => reply.visible)).toEqual([
first.text,
second.text,
]);
});
it('resets only an unpersisted retry attempt and discards failed partial output', () => {
const { result } = renderHook(useDesignReplyAnimation);
act(() => {
result.current.receiveText('t:response:0', '尝试失败');
vi.advanceTimersByTime(100);
});
expect(result.current.replies[0].visible).toBe('尝试');
act(() => result.current.receiveText('t:response:0', ''));
expect(result.current.replies[0].visible).toBe('');
const saved = { id: 't:response:0', role: 'assistant', text: '成功' };
act(() => {
result.current.receiveText(saved.id, saved.text);
result.current.receiveView(view([saved], true));
vi.advanceTimersByTime(100);
result.current.receiveText(saved.id, '');
result.current.receiveText('t:response:1', '未被接受的文本');
result.current.receiveView(view([saved]));
});
expect(result.current.replies).toEqual([
{ messageId: saved.id, target: '成功', visible: '成功', persisted: true },
]);
act(() => {
result.current.receiveText('t:response:2', '连接中断');
result.current.discardUnpersisted();
});
expect(result.current.replies).toHaveLength(1);
act(() => result.current.reset([saved.id]));
expect(result.current.replies).toEqual([]);
});
@@ -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

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