合并 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
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+69
-44
@@ -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>
|
||||
|
||||
+113
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -90,8 +90,9 @@ http {
|
||||
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -119,8 +119,9 @@ server {
|
||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 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;
|
||||
|
||||
|
||||
@@ -139,8 +139,9 @@ server {
|
||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 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;"
|
||||
|
||||
@@ -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-only;br / 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
Reference in New Issue
Block a user