合并最新主分支并清理新增策划耦合
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled

合入 master 的聊天界面、回合控制及运行时更新
解决七处合并冲突,保持退役策划 V1/V2 链路删除
将新增界面判断连接到 Design Agent 状态并移除旧 GDD 测试依赖
保留现役文档建造指令、回合终止及推理档设置
This commit is contained in:
2026-09-17 02:37:48 +00:00
198 changed files with 19532 additions and 2688 deletions
@@ -12,6 +12,7 @@ import userEvent from '@testing-library/user-event';
import { type ContextType, startTransition, Suspense } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { clearSignedAssetReadUrlCache } from '../../services/assetReadUrlService';
import { AuthUiContext } from '../auth/AuthUiContext';
import { CreationLandingView } from './CreationLandingView';
@@ -153,6 +154,7 @@ function SuspendAfterLandingRender({ active }: { active: boolean }) {
describe('CreationLandingView', () => {
beforeEach(() => {
clearSignedAssetReadUrlCache();
listPublicEditorProjectResourcesMock.mockResolvedValue([]);
});
@@ -161,6 +163,7 @@ describe('CreationLandingView', () => {
listPublicEditorProjectResourcesMock.mockReset();
createEditorProjectMock.mockReset();
toggleEditorShowcaseAssetLikeMock.mockReset();
clearSignedAssetReadUrlCache();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -1351,7 +1354,7 @@ describe('CreationLandingView', () => {
`/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`,
expect.objectContaining({ method: 'GET' }),
);
});
}, 10_000);
it('loads more featured resources when the list reaches the end', async () => {
const observers = installIntersectionObserverMock();
@@ -10,7 +10,10 @@ import {
import { describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView';
import {
ImageCanvasSelectedLayerToolbarView,
type ImageCanvasSelectedToolbarAction,
} from './ImageCanvasSelectedLayerToolbarView';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
@@ -67,6 +70,121 @@ function renderSelectedToolbar(
}
describe('ImageCanvasSelectedLayerToolbarView', () => {
it('常规动作与末组分别分隔,导出紧邻删除且位于删除之前', () => {
const props = renderSelectedToolbar({
supportedActions: new Set(['quick-edit', 'download']),
downloadLabel: '导出',
extraActions: <button>引用</button>,
endActions: <button>删除素材</button>,
});
const toolbar = screen.getByRole('toolbar');
expect(
toolbar.querySelectorAll(
'.image-canvas-editor__floating-toolbar-divider',
),
).toHaveLength(2);
expect(
within(toolbar)
.getAllByRole('button')
.map((button) => button.textContent),
).toEqual(['快速编辑', '引用', '导出', '删除素材']);
expect(
screen.getByRole('button', { name: '导出' }).nextElementSibling,
).toBe(screen.getByRole('button', { name: '删除素材' }));
fireEvent.click(screen.getByRole('button', { name: '导出' }));
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
});
it('任意动作组合都不生成相邻、开头或结尾分隔线', () => {
const actions: ImageCanvasSelectedToolbarAction[] = [
'quick-edit',
'crop-expand',
'redraw',
'download',
];
for (const mediaType of ['image', 'audio', 'video'] as const) {
for (let mask = 0; mask < 1 << actions.length; mask += 1) {
for (const withExtraActions of [false, true]) {
renderSelectedToolbar({
selectedLayer: createLayer({ mediaType }),
supportedActions: new Set(
actions.filter((_, index) => (mask & (1 << index)) !== 0),
),
extraActions: withExtraActions ? <button>信息</button> : null,
});
const toolbar = screen.getByRole('toolbar');
const divider = '.image-canvas-editor__floating-toolbar-divider';
expect(toolbar.querySelector(`${divider} + ${divider}`)).toBeNull();
expect(toolbar.firstElementChild?.matches(divider) ?? false).toBe(
false,
);
expect(toolbar.lastElementChild?.matches(divider) ?? false).toBe(
false,
);
cleanup();
}
}
}
});
it('空的条件动作和嵌套 Fragment 不产生空分组', () => {
renderSelectedToolbar({
supportedActions: new Set(['quick-edit', 'download']),
extraActions: (
<>
{null}
<>{false}</>
</>
),
endActions: <>{null}</>,
});
const toolbar = screen.getByRole('toolbar');
expect(
toolbar.querySelectorAll(
'.image-canvas-editor__floating-toolbar-divider',
),
).toHaveLength(1);
expect(
within(toolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['快速编辑', '下载按钮']);
expect(toolbar.lastElementChild?.tagName).toBe('BUTTON');
});
it.each(['image', 'audio', 'video', 'image-sequence', undefined] as const)(
'%s 素材使用同一个带文字导出入口与回调',
(mediaType) => {
const props = renderSelectedToolbar({
selectedLayer: createLayer({ mediaType }),
supportedActions: new Set(['download']),
downloadLabel: '导出',
extraActions: <button>信息</button>,
endActions: <button>删除素材</button>,
});
const toolbar = screen.getByRole('toolbar');
expect(
within(toolbar)
.getAllByRole('button')
.map((button) => button.textContent),
).toEqual(['信息', '导出', '删除素材']);
expect(
screen.getByRole('button', { name: '导出' }).nextElementSibling,
).toBe(screen.getByRole('button', { name: '删除素材' }));
fireEvent.click(screen.getByRole('button', { name: '导出' }));
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
},
);
it('宿主未放行下载时不展示导出入口', () => {
renderSelectedToolbar({
supportedActions: new Set(),
downloadLabel: '导出',
extraActions: <button>信息</button>,
});
expect(screen.queryByRole('button', { name: '导出' })).toBeNull();
});
it('renders common layer actions in the Lovart toolbar order and forwards callbacks', () => {
const props = renderSelectedToolbar();
const toolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
@@ -10,7 +10,13 @@ import {
Sparkles,
WandSparkles,
} from 'lucide-react';
import type { CSSProperties, ReactNode } from 'react';
import {
Children,
type CSSProperties,
Fragment,
isValidElement,
type ReactNode,
} from 'react';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
@@ -39,8 +45,12 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
* 让没有对应后端链路的画布宿主不必把「点了没反应」的按钮渲染出来。
*/
supportedActions?: ReadonlySet<ImageCanvasSelectedToolbarAction> | null;
/** 宿主在工具条上追加的动作节点,渲染在下载按钮之前。 */
/** 宿主在工具条上追加的常规动作,渲染在下载按钮之前。 */
extraActions?: ReactNode;
/** 工具栏末组动作,紧随下载按钮,与下载共用一个分组。 */
endActions?: ReactNode;
/** 提供文字时显示带文字的导出入口;缺省保持网页画布的图标按钮。 */
downloadLabel?: string;
selectedLayer: CanvasLayer | null;
selectedToolbarStyle: CSSProperties | null;
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
@@ -58,9 +68,20 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
onDownloadLayer: (layer: CanvasLayer) => void;
};
/** 条件动作通常放在 Fragment 中;空 Fragment 不能产生一个空分组。 */
function hasToolbarActions(actions: ReactNode): boolean {
return Children.toArray(actions).some((action) =>
isValidElement<{ children?: ReactNode }>(action) && action.type === Fragment
? hasToolbarActions(action.props.children)
: action !== '',
);
}
export function ImageCanvasSelectedLayerToolbarView({
supportedActions = null,
extraActions,
endActions,
downloadLabel,
selectedLayer,
selectedToolbarStyle,
onOpenQuickEditPanel,
@@ -88,12 +109,35 @@ export function ImageCanvasSelectedLayerToolbarView({
'redraw',
canOpenRedrawPanel(selectedLayer),
);
const extraActionDivider = extraActions ? (
const showDownload = isActionSupported('download', true);
const downloadAction = showDownload ? (
downloadLabel ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label={downloadLabel}
title={downloadLabel}
icon={<Download className="h-4 w-4" />}
onClick={() => onDownloadLayer(selectedLayer)}
>
<span>{downloadLabel}</span>
</CanvasChromeButton>
) : (
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
)
) : null;
const hasExtraActions = hasToolbarActions(extraActions);
const hasEndActions = showDownload || hasToolbarActions(endActions);
const divider = (
<span
aria-hidden="true"
className="image-canvas-editor__floating-toolbar-divider"
/>
) : null;
);
if (selectedLayer.mediaType === 'audio') {
return (
@@ -115,16 +159,11 @@ export function ImageCanvasSelectedLayerToolbarView({
<span>改造</span>
</CanvasChromeButton>
) : null}
{extraActionDivider}
{canRedraw && hasExtraActions ? divider : null}
{extraActions}
{isActionSupported('download', true) ? (
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
) : null}
{(canRedraw || hasExtraActions) && hasEndActions ? divider : null}
{downloadAction}
{endActions}
</div>
);
}
@@ -141,6 +180,25 @@ export function ImageCanvasSelectedLayerToolbarView({
'character-animation',
selectedLayer.assetKind === 'character',
);
const showCropExpand =
canRasterEdit && isActionSupported('crop-expand', true);
const showRemoveBackground =
canRasterEdit && isActionSupported('remove-background', true);
const showPerfectPixel =
canRasterEdit && isActionSupported('perfect-pixel', true);
const showSplitIconSpritesheet =
selectedLayer.assetKind === 'icon-spritesheet' &&
isActionSupported('split-icon-spritesheet', true);
const showExtractUiDesign =
selectedLayer.assetKind === 'ui-design' &&
isActionSupported('extract-ui-design', true);
const hasEditingActions =
showCropExpand ||
showRemoveBackground ||
showPerfectPixel ||
showSplitIconSpritesheet ||
showExtractUiDesign ||
showCharacterAnimation;
return (
<div
@@ -151,23 +209,18 @@ export function ImageCanvasSelectedLayerToolbarView({
onPointerDown={(event) => event.stopPropagation()}
>
{showQuickEdit ? (
<>
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="快速编辑"
title="快速编辑"
icon={<Sparkles className="h-4 w-4" />}
onClick={() => onOpenQuickEditPanel(selectedLayer)}
>
<span>快速编辑</span>
</CanvasChromeButton>
<span
aria-hidden="true"
className="image-canvas-editor__floating-toolbar-divider"
/>
</>
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="快速编辑"
title="快速编辑"
icon={<Sparkles className="h-4 w-4" />}
onClick={() => onOpenQuickEditPanel(selectedLayer)}
>
<span>快速编辑</span>
</CanvasChromeButton>
) : null}
{canRasterEdit && isActionSupported('crop-expand', true) ? (
{showQuickEdit && hasEditingActions ? divider : null}
{showCropExpand ? (
<EditorIconButton
label="裁扩按钮"
title="裁扩按钮"
@@ -175,7 +228,7 @@ export function ImageCanvasSelectedLayerToolbarView({
onClick={() => onOpenCropExpandPanel(selectedLayer)}
/>
) : null}
{canRasterEdit && isActionSupported('remove-background', true) ? (
{showRemoveBackground ? (
<EditorIconButton
label="去除背景按钮"
title="去除背景按钮"
@@ -183,7 +236,7 @@ export function ImageCanvasSelectedLayerToolbarView({
onClick={() => onRemoveBackground(selectedLayer)}
/>
) : null}
{canRasterEdit && isActionSupported('perfect-pixel', true) ? (
{showPerfectPixel ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
@@ -236,8 +289,7 @@ export function ImageCanvasSelectedLayerToolbarView({
</span>
</CanvasChromeButton>
) : null}
{selectedLayer.assetKind === 'icon-spritesheet' &&
isActionSupported('split-icon-spritesheet', true) ? (
{showSplitIconSpritesheet ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label={
@@ -274,8 +326,7 @@ export function ImageCanvasSelectedLayerToolbarView({
</span>
</CanvasChromeButton>
) : null}
{selectedLayer.assetKind === 'ui-design' &&
isActionSupported('extract-ui-design', true) ? (
{showExtractUiDesign ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="提取素材"
@@ -299,10 +350,7 @@ export function ImageCanvasSelectedLayerToolbarView({
) : null}
{canRedraw ? (
<>
<span
aria-hidden="true"
className="image-canvas-editor__floating-toolbar-divider"
/>
{showQuickEdit || hasEditingActions ? divider : null}
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="改造"
@@ -314,16 +362,16 @@ export function ImageCanvasSelectedLayerToolbarView({
</CanvasChromeButton>
</>
) : null}
{extraActionDivider}
{(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions
? divider
: null}
{extraActions}
{isActionSupported('download', true) ? (
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
) : null}
{(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) &&
hasEndActions
? divider
: null}
{downloadAction}
{endActions}
</div>
);
}
-12
View File
@@ -5090,18 +5090,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
height: 1.125rem;
}
/* 紧挨着的两条分隔线只保留一条。
共享工具条会在「快速编辑」之后输出一条(`ImageCanvasSelectedLayerToolbarView.tsx`
的 showQuickEdit 分支),并为 `extraActions` 再自动生成一条前置分隔线
(同文件 `extraActionDivider`)。AGC 资源卡恰好是"快速编辑可用 + 中间那些动作未接通
不渲染"的组合,于是这两条直接相邻,用户看到两条竖线。两条相邻的分隔线在视觉上
本来就没有意义,所以在样式层去重:不改任何 JSX 结构,对两端(美术画布与资源画布)
都成立,也不会隐藏任何真正起分隔作用的那一条。 */
.image-canvas-editor__floating-toolbar-divider
+ .image-canvas-editor__floating-toolbar-divider {
display: none;
}
.image-canvas-editor__bottom-toolbar-option-wrap {
display: inline-flex;
}
+39 -3
View File
@@ -322,6 +322,7 @@ describe('apiClient', () => {
it('emits auth change events when refresh fails on protected requests', async () => {
setStoredAccessToken('expired-token', { emit: false });
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
@@ -330,7 +331,12 @@ describe('apiClient', () => {
});
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(2);
// 业务 401 + refresh 401 收敛重试 + refresh 仍 401:只有两次刷新都被明确拒绝,
// 才判定登录态权威失效并广播一次全局鉴权变化。
expect(
fetchMock.mock.calls.filter(([input]) => input === '/api/auth/refresh'),
).toHaveLength(2);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
expect(getStoredAccessToken()).toBe('');
});
@@ -405,7 +411,9 @@ describe('apiClient', () => {
it('keeps local token when explicit refresh opts out of clearing on failure', async () => {
setStoredAccessToken('usable-local-token', { emit: false });
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
await expect(
refreshStoredAccessToken({ clearOnFailure: false }),
@@ -456,7 +464,9 @@ describe('apiClient', () => {
it('clears local token when refresh confirms the session is unauthorized', async () => {
setStoredAccessToken('expired-local-token', { emit: false });
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
status: 401,
@@ -466,6 +476,32 @@ describe('apiClient', () => {
expect(getStoredAccessToken()).toBe('');
});
it('retries refresh once with the current cookie after a rotation race', async () => {
setStoredAccessToken('expired-local-token', { emit: false });
fetchMock
// 并发轮换竞争:这一次 refresh 拿到的是被另一个客户端轮换过的旧 cookie。
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
// 收敛重试使用浏览器当前 cookie,拿到轮换后的新 token。
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: { token: 'converged-token' },
error: null,
meta: { apiVersion: '2026-06-16' },
}),
}),
);
await expect(refreshStoredAccessToken()).resolves.toBe('converged-token');
expect(
fetchMock.mock.calls.filter(([input]) => input === '/api/auth/refresh'),
).toHaveLength(2);
expect(getStoredAccessToken()).toBe('converged-token');
expect(dispatchEventMock).not.toHaveBeenCalled();
});
it('does not clear auth when protected request refresh fails transiently', async () => {
setStoredAccessToken('expired-token-during-restart', { emit: false });
fetchMock
+20 -3
View File
@@ -775,7 +775,7 @@ async function refreshAccessToken() {
return refreshAccessTokenAttempt.promise;
}
const promise = (async () => {
const performRefresh = async () => {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'same-origin',
@@ -802,6 +802,23 @@ async function refreshAccessToken() {
publishRefreshedAccessToken(nextToken, authStateSnapshot);
return nextToken;
};
const promise = (async () => {
try {
return await performRefresh();
} catch (error) {
const authoritative =
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403);
// 登录态已经变化(换号 / 退出 / 另一个 refresh 已发布新 token)时不要重试:
// 这次 refresh 的归属已经过期,重试只会把旧账号的结论带到新代次上。
if (!authoritative || !isCurrentAuthState(authStateSnapshot)) {
throw error;
}
// 并发轮换收敛:另一个标签页 / 客户端可能刚刚轮换过 refresh cookie,用当前
// cookie 再试一次。重试成功则继续使用新凭据;重试仍被明确拒绝才算权威失效。
return await performRefresh();
}
})();
const attempt: RefreshAccessTokenAttempt = {
...authStateSnapshot,
@@ -870,7 +887,7 @@ export async function fetchWithApiAuth(
requestHeaders[REQUEST_ID_HEADER] = requestId;
let hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
requestHeaders.authorization?.trim(),
);
if (
@@ -886,7 +903,7 @@ export async function fetchWithApiAuth(
requestHeaders[REQUEST_ID_HEADER] = requestId;
hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
requestHeaders.authorization?.trim(),
);
} catch (error) {
if (requestSignal?.aborted) {