Merge remote-tracking branch 'origin/master' into codex/wooden-fish-template

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	.hermes/shared-memory/pitfalls.md
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
2026-05-26 02:00:11 +08:00
140 changed files with 12176 additions and 3526 deletions

View File

@@ -18,6 +18,14 @@ const entryConfig = {
title: '选择创作类型',
description: '',
},
eventBanner: {
title: '泥点挑战',
description: '创作活动测试横幅。',
coverImageSrc: '/creation-type-references/puzzle.webp',
prizePoolMudPoints: 1000,
startsAtText: '2026-05-01',
endsAtText: '2026-05-31',
},
creationTypes: [
{
id: 'wooden-fish',
@@ -28,6 +36,9 @@ const entryConfig = {
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
],

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
derivePlatformCreationTypes,
groupVisiblePlatformCreationTypes,
getVisiblePlatformCreationTypes,
isPlatformCreationTypeOpen,
isPlatformCreationTypeVisible,
@@ -22,6 +23,9 @@ test('database entry config controls visibility open state and display order', (
visible: true,
open: false,
sortOrder: 30,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -33,6 +37,9 @@ test('database entry config controls visibility open state and display order', (
visible: true,
open: true,
sortOrder: 20,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -44,6 +51,9 @@ test('database entry config controls visibility open state and display order', (
visible: false,
open: true,
sortOrder: 10,
categoryId: 'festival',
categoryLabel: '节日主题',
categorySortOrder: 30,
updatedAtMicros: 1,
},
]);
@@ -79,6 +89,9 @@ test('visible platform creation types hide invisible cards and put locked cards
visible: false,
open: true,
sortOrder: 1,
categoryId: 'hidden',
categoryLabel: '隐藏',
categorySortOrder: 99,
updatedAtMicros: 1,
},
{
@@ -90,6 +103,9 @@ test('visible platform creation types hide invisible cards and put locked cards
visible: true,
open: false,
sortOrder: 2,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -101,6 +117,9 @@ test('visible platform creation types hide invisible cards and put locked cards
visible: true,
open: true,
sortOrder: 3,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
]);
@@ -131,6 +150,9 @@ test('edutainment switch hides baby object match creation entry from database co
visible: true,
open: true,
sortOrder: 1,
categoryId: 'character',
categoryLabel: '角色创作',
categorySortOrder: 40,
updatedAtMicros: 1,
},
{
@@ -142,6 +164,9 @@ test('edutainment switch hides baby object match creation entry from database co
visible: true,
open: true,
sortOrder: 2,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
]);
@@ -160,6 +185,9 @@ test('edutainment switch hides baby object match creation entry from database co
visible: true,
open: true,
sortOrder: 1,
categoryId: 'character',
categoryLabel: '角色创作',
categorySortOrder: 40,
updatedAtMicros: 1,
},
{
@@ -171,6 +199,9 @@ test('edutainment switch hides baby object match creation entry from database co
visible: true,
open: true,
sortOrder: 2,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
]);
@@ -194,6 +225,9 @@ test('baby object match entry is visible and open when database marks it creatab
visible: true,
open: true,
sortOrder: 90,
categoryId: 'character',
categoryLabel: '角色创作',
categorySortOrder: 40,
updatedAtMicros: 1,
},
]);
@@ -208,3 +242,109 @@ test('baby object match entry is visible and open when database marks it creatab
expect(isPlatformCreationTypeVisible(cards, 'baby-object-match')).toBe(true);
expect(isPlatformCreationTypeOpen(cards, 'baby-object-match')).toBe(true);
});
test('groups visible platform creation types by backend category metadata', () => {
const cards = derivePlatformCreationTypes([
{
id: 'puzzle',
title: '秋日暖阳',
subtitle: '记录秋日的温暖时光',
badge: '热门',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'match3d',
title: '秋日小屋',
subtitle: '打造专属的秋日小屋',
badge: '精选',
imageSrc: '/creation-type-references/match3d.webp',
visible: true,
open: true,
sortOrder: 40,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '分支叙事体验',
badge: '敬请期待',
imageSrc: '/creation-type-references/visual-novel.webp',
visible: true,
open: false,
sortOrder: 60,
categoryId: 'festival',
categoryLabel: '节日主题',
categorySortOrder: 30,
updatedAtMicros: 1,
},
{
id: 'hidden',
title: '隐藏入口',
subtitle: '隐藏',
badge: '隐藏',
imageSrc: '/creation-type-references/hidden.webp',
visible: false,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
]);
const groups = groupVisiblePlatformCreationTypes(cards);
expect(groups.map((group) => group.label)).toEqual([
'最近创作',
'节日主题',
]);
expect(groups[0]?.items.map((item) => item.id)).toEqual([
'puzzle',
'match3d',
]);
expect(groups[1]?.items.map((item) => item.id)).toEqual(['visual-novel']);
});
test('falls back when backend creation type category metadata is missing', () => {
const cards = derivePlatformCreationTypes([
{
id: 'legacy-entry',
title: '历史入口',
subtitle: '旧数据缺少分类字段',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 10,
categoryId: undefined as unknown as string,
categoryLabel: undefined as unknown as string,
categorySortOrder: 0,
updatedAtMicros: 1,
},
]);
expect(cards[0]).toEqual(
expect.objectContaining({
id: 'legacy-entry',
categoryId: 'recent',
categoryLabel: '最近创作',
}),
);
expect(groupVisiblePlatformCreationTypes(cards)).toEqual([
expect.objectContaining({
id: 'recent',
label: '最近创作',
}),
]);
});

View File

@@ -10,9 +10,23 @@ export type PlatformCreationTypeCard = {
badge: string;
imageSrc: string;
locked: boolean;
categoryId: string;
categoryLabel: string;
categorySortOrder: number;
sortOrder: number;
hidden?: boolean;
};
export type PlatformCreationTypeGroup = {
id: string;
label: string;
sortOrder: number;
items: PlatformCreationTypeCard[];
};
const FALLBACK_CREATION_CATEGORY_ID = 'recent';
const FALLBACK_CREATION_CATEGORY_LABEL = '最近创作';
export function getVisiblePlatformCreationTypes(
creationTypes: readonly PlatformCreationTypeCard[],
) {
@@ -41,6 +55,50 @@ export function isPlatformCreationTypeOpen(
);
}
function normalizeCategoryId(value: string | null | undefined) {
const normalized = typeof value === 'string' ? value.trim() : '';
return normalized || FALLBACK_CREATION_CATEGORY_ID;
}
function normalizeCategoryLabel(value: string | null | undefined) {
const normalized = typeof value === 'string' ? value.trim() : '';
return normalized || FALLBACK_CREATION_CATEGORY_LABEL;
}
export function groupVisiblePlatformCreationTypes(
creationTypes: readonly PlatformCreationTypeCard[],
): PlatformCreationTypeGroup[] {
const groups = new Map<string, PlatformCreationTypeGroup>();
for (const item of getVisiblePlatformCreationTypes(creationTypes)) {
const categoryId = normalizeCategoryId(item.categoryId);
const categoryLabel = normalizeCategoryLabel(item.categoryLabel);
const existing = groups.get(categoryId);
if (existing) {
existing.items.push(item);
if (item.categorySortOrder < existing.sortOrder) {
existing.sortOrder = item.categorySortOrder;
}
continue;
}
groups.set(categoryId, {
id: categoryId,
label: categoryLabel,
sortOrder: item.categorySortOrder,
items: [item],
});
}
return [...groups.values()].sort((left, right) => {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.label.localeCompare(right.label, 'zh-Hans-CN');
});
}
/**
* 创作入口卡片只做展示派生;配置事实源来自后端 API / SpacetimeDB前端不再保留入口默认配置。
*/
@@ -56,6 +114,10 @@ export function derivePlatformCreationTypes(
badge: item.badge,
imageSrc: item.imageSrc,
locked: !item.open,
categoryId: normalizeCategoryId(item.categoryId),
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
categorySortOrder: item.categorySortOrder,
sortOrder: item.sortOrder,
hidden:
!item.visible ||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
export const PLATFORM_DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
export function getInitialPlatformDesktopLayout() {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(PLATFORM_DESKTOP_LAYOUT_QUERY).matches;
}
export function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(
getInitialPlatformDesktopLayout,
);
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return;
}
const mediaQuery = window.matchMedia(PLATFORM_DESKTOP_LAYOUT_QUERY);
const updateLayout = (event?: MediaQueryListEvent) => {
setIsDesktopLayout(event?.matches ?? mediaQuery.matches);
};
updateLayout();
// 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', updateLayout);
return () => mediaQuery.removeEventListener('change', updateLayout);
}
mediaQuery.addListener(updateLayout);
return () => mediaQuery.removeListener(updateLayout);
}, []);
return isDesktopLayout;
}

View File

@@ -36,6 +36,7 @@ export type SelectionStage =
| 'jump-hop-result'
| 'jump-hop-runtime'
| 'jump-hop-gallery-detail'
| 'bark-battle-workspace'
| 'bark-battle-generating'
| 'bark-battle-result'
| 'bark-battle-runtime'

View File

@@ -300,6 +300,161 @@ function ActionCompleteHarness({
);
}
function SessionChangeHarness({
onSessionChanged,
}: {
onSessionChanged: (session: TestSession | null) => void;
}) {
const flow = usePlatformCreationAgentFlowController<
TestSession,
Record<string, never>,
{ session: TestSession },
TestMessagePayload,
{ action: string },
{ session: TestSession }
>({
client: {
createSession: async () => ({
session: {
sessionId: 'session-open',
messages: [],
},
}),
getSession: async () => ({
session: {
sessionId: 'session-restore',
messages: [],
},
}),
streamMessage: async () => ({
sessionId: 'session-open',
messages: [],
}),
executeAction: async () => ({
session: {
sessionId: 'session-compile',
messages: [],
},
}),
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'match3d-agent-workspace',
resultStage: 'match3d-result',
platformStage: 'platform',
isCompileAction: (payload) => payload.action === 'match3d_compile_draft',
resolveErrorMessage: (error, fallback) =>
error instanceof Error ? error.message : fallback,
errorMessages: {
open: '打开失败',
restoreMissingSession: '缺少会话',
restore: '恢复失败',
submit: '发送失败',
execute: '执行失败',
},
enterCreateTab: () => {},
setSelectionStage: () => {},
onSessionChanged,
onActionComplete: ({ response, setSession }) => {
setSession(response.session);
},
});
return (
<div>
<button type="button" onClick={() => void flow.openWorkspace({})}>
</button>
<button
type="button"
onClick={() => void flow.restoreDraft('session-restore')}
>
</button>
<button
type="button"
onClick={() =>
void flow.executeAction({ action: 'match3d_compile_draft' })
}
>
</button>
</div>
);
}
function SessionSetterIdentityHarness({
onSetterIdentity,
}: {
onSetterIdentity: (setter: unknown) => void;
}) {
const [renderCount, setRenderCount] = useState(0);
const flow = usePlatformCreationAgentFlowController<
TestSession,
Record<string, never>,
{ session: TestSession },
TestMessagePayload,
{ action: string },
{ session: TestSession }
>({
client: {
createSession: async () => ({
session: {
sessionId: 'session-open',
messages: [],
},
}),
getSession: async () => ({
session: {
sessionId: 'session-restore',
messages: [],
},
}),
streamMessage: async () => ({
sessionId: 'session-open',
messages: [],
}),
executeAction: async () => ({
session: {
sessionId: 'session-compile',
messages: [],
},
}),
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'match3d-agent-workspace',
resultStage: 'match3d-result',
platformStage: 'platform',
isCompileAction: () => false,
resolveErrorMessage: (error, fallback) =>
error instanceof Error ? error.message : fallback,
errorMessages: {
open: '打开失败',
restoreMissingSession: '缺少会话',
restore: '恢复失败',
submit: '发送失败',
execute: '执行失败',
},
enterCreateTab: () => {},
setSelectionStage: () => {},
onSessionChanged: () => {},
});
useEffect(() => {
onSetterIdentity(flow.setSession);
});
return (
<button
type="button"
onClick={() => setRenderCount((current) => current + 1)}
>
{renderCount}
</button>
);
}
test('creation agent flow preserves streamed assistant text when stream fails', async () => {
const streamMessage = vi.fn(async (_sessionId, _payload, options) => {
options?.onUpdate?.('先把方洞万能的反差定住。');
@@ -391,3 +546,48 @@ test('creation agent flow suppresses compile result stage for background complet
'match3d-agent-workspace',
);
});
test('creation agent flow notifies session changes after open restore and compile', async () => {
const onSessionChanged = vi.fn();
render(<SessionChangeHarness onSessionChanged={onSessionChanged} />);
await act(async () => {
screen.getByRole('button', { name: '打开' }).click();
});
await act(async () => {
screen.getByRole('button', { name: '恢复' }).click();
});
await act(async () => {
screen.getByRole('button', { name: '编译' }).click();
});
await waitFor(() => {
expect(onSessionChanged).toHaveBeenCalledTimes(3);
});
expect(
onSessionChanged.mock.calls.map(([session]) => session?.sessionId),
).toEqual(['session-open', 'session-restore', 'session-compile']);
});
test('creation agent flow keeps session setter stable across parent rerenders', async () => {
const onSetterIdentity = vi.fn();
render(<SessionSetterIdentityHarness onSetterIdentity={onSetterIdentity} />);
await waitFor(() => {
expect(onSetterIdentity).toHaveBeenCalledTimes(1);
});
const initialSetter = onSetterIdentity.mock.calls[0]?.[0];
await act(async () => {
screen.getByRole('button', { name: //u }).click();
});
await waitFor(() => {
expect(onSetterIdentity).toHaveBeenCalledTimes(2);
});
expect(onSetterIdentity.mock.calls[1]?.[0]).toBe(initialSetter);
});

View File

@@ -1,4 +1,5 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { TextStreamOptions } from '../../services/aiTypes';
import type { SelectionStage } from './platformEntryTypes';
@@ -75,12 +76,13 @@ type PlatformCreationAgentFlowControllerOptions<
enterCreateTab: () => void;
setSelectionStage: (stage: SelectionStage) => void;
onSessionOpened?: () => void;
onSessionChanged?: (session: TSession | null) => void;
onOpenError?: (params: { error: unknown; errorMessage: string }) => void;
onActionComplete?: (params: {
payload: TActionPayload;
response: TActionResponse;
session: TSession;
setSession: (session: TSession) => void;
setSession: Dispatch<SetStateAction<TSession | null>>;
}) =>
| Promise<{ openResult?: boolean } | void>
| { openResult?: boolean }
@@ -94,7 +96,7 @@ type PlatformCreationAgentFlowControllerOptions<
error: unknown;
errorMessage: string;
session: TSession;
setSession: (session: TSession) => void;
setSession: Dispatch<SetStateAction<TSession | null>>;
}) => void | Promise<void>;
};
@@ -141,12 +143,27 @@ export function usePlatformCreationAgentFlowController<
TActionResponse
>,
) {
const [session, setSession] = useState<TSession | null>(null);
const [session, rawSetSession] = useState<TSession | null>(null);
const [error, setError] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [streamingReplyText, setStreamingReplyText] = useState('');
const [isStreamingReply, setIsStreamingReply] = useState(false);
const latestStreamingReplyTextRef = useRef('');
const onSessionChangedRef = useRef(options.onSessionChanged);
useEffect(() => {
onSessionChangedRef.current = options.onSessionChanged;
}, [options.onSessionChanged]);
const setSession = useCallback(
(nextSessionOrUpdater: SetStateAction<TSession | null>) => {
rawSetSession(nextSessionOrUpdater);
if (typeof nextSessionOrUpdater !== 'function') {
onSessionChangedRef.current?.(nextSessionOrUpdater);
}
},
[],
);
const updateStreamingReplyText = useCallback((text: string) => {
latestStreamingReplyTextRef.current = text;
@@ -174,10 +191,10 @@ export function usePlatformCreationAgentFlowController<
createPayload ?? options.createPayload,
);
const nextSession = options.client.selectSession(response);
setSession(nextSession);
options.enterCreateTab();
options.onSessionOpened?.();
options.setSelectionStage(options.workspaceStage);
setSession(nextSession);
return nextSession;
} catch (caughtError) {
const errorMessage = options.resolveErrorMessage(
@@ -212,11 +229,11 @@ export function usePlatformCreationAgentFlowController<
try {
const response = await options.client.getSession(normalizedSessionId);
const nextSession = options.client.selectSession(response);
setSession(nextSession);
options.enterCreateTab();
options.setSelectionStage(
nextSession.draft ? options.resultStage : options.workspaceStage,
);
setSession(nextSession);
return nextSession;
} catch (caughtError) {
setError(