This commit is contained in:
2026-04-24 22:27:45 +08:00
35 changed files with 1862 additions and 237 deletions

View File

@@ -0,0 +1,104 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import { BigFishAgentWorkspace } from './BigFishAgentWorkspace';
const baseSession: BigFishSessionSnapshotResponse = {
sessionId: 'big-fish-session-1',
currentTurn: 3,
progressPercent: 64,
stage: 'collecting_anchors',
anchorPack: {
gameplayPromise: {
key: 'gameplayPromise',
label: '玩法承诺',
value: '从微光小鱼一路吞噬成长为深海巨兽',
status: 'confirmed',
},
ecologyVisualTheme: {
key: 'ecologyVisualTheme',
label: '生态视觉主题',
value: '幽蓝珊瑚海沟',
status: 'confirmed',
},
growthLadder: {
key: 'growthLadder',
label: '成长阶梯',
value: '',
status: 'missing',
},
riskTempo: {
key: 'riskTempo',
label: '风险节奏',
value: '',
status: 'missing',
},
},
draft: null,
assetSlots: [],
assetCoverage: {
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
requiredLevelCount: 8,
publishReady: false,
blockers: [],
},
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '爽点和生态已经清楚,继续补剩余关键词。',
createdAt: '2026-04-24T10:00:00.000Z',
},
],
lastAssistantReply: '爽点和生态已经清楚,继续补剩余关键词。',
publishReady: false,
updatedAt: '2026-04-24T10:00:00.000Z',
};
beforeEach(() => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
});
test('big fish workspace submits quick keyword fill request after two turns', async () => {
const user = userEvent.setup();
const onSubmitMessage = vi.fn();
render(
<BigFishAgentWorkspace
session={baseSession}
onBack={() => {}}
onSubmitMessage={onSubmitMessage}
onExecuteAction={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '补充剩余关键字' }));
expect(onSubmitMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: '请补充剩余关键字。',
}),
);
});
test('big fish workspace hides keyword fill before two turns', () => {
render(
<BigFishAgentWorkspace
session={{ ...baseSession, currentTurn: 1 }}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '补充剩余关键字' })).toBeNull();
});

View File

@@ -84,6 +84,17 @@ export function BigFishAgentWorkspace({
isStreamingReply={Boolean(streamingReplyText)}
isBusy={isBusy}
error={error}
quickActions={[
{
key: 'summarize',
label: '总结当前设定',
},
{
key: 'quickFill',
label: '补充剩余关键字',
minTurn: 2,
},
]}
onBack={onBack}
onSubmitText={(text) => {
onSubmitMessage({
@@ -94,6 +105,15 @@ export function BigFishAgentWorkspace({
onPrimaryAction={() => {
onExecuteAction({ action: 'big_fish_compile_draft' });
}}
onQuickAction={(action) => {
onSubmitMessage({
clientMessageId: createCreationAgentClientMessageId('big-fish'),
text:
action.key === 'quickFill'
? '请补充剩余关键字。'
: '请总结一下当前已经成形的大鱼吃小鱼设定。',
});
}}
/>
);
}

View File

@@ -2,13 +2,16 @@ import { ArrowLeft, Loader2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type {
BigFishAssetSlotResponse,
BigFishRuntimeEntityResponse,
BigFishRuntimeSnapshotResponse,
SubmitBigFishInputRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type BigFishRuntimeShellProps = {
run: BigFishRuntimeSnapshotResponse | null;
assetSlots?: BigFishAssetSlotResponse[];
isBusy?: boolean;
error?: string | null;
onBack: () => void;
@@ -54,32 +57,87 @@ function projectEntity(
};
}
function findBigFishAssetSlot(
slots: BigFishAssetSlotResponse[],
assetKind: string,
level?: number,
motionKey?: string,
) {
return slots.find((slot) => {
if (slot.assetKind !== assetKind || slot.status !== 'ready') {
return false;
}
if (level !== undefined && slot.level !== level) {
return false;
}
if (motionKey !== undefined && slot.motionKey !== motionKey) {
return false;
}
return true;
});
}
function resolveRuntimeEntityAsset(
entity: BigFishRuntimeEntityResponse,
assetSlots: BigFishAssetSlotResponse[],
) {
return (
findBigFishAssetSlot(assetSlots, 'level_motion', entity.level, 'move_swim') ??
findBigFishAssetSlot(assetSlots, 'level_motion', entity.level, 'idle_float') ??
findBigFishAssetSlot(assetSlots, 'level_main_image', entity.level)
);
}
function BigFishEntityDot({
entity,
run,
owned,
assetSlots,
}: {
entity: BigFishRuntimeEntityResponse;
run: BigFishRuntimeSnapshotResponse;
owned: boolean;
assetSlots: BigFishAssetSlotResponse[];
}) {
const projected = projectEntity(entity, run);
const isLeader = run.leaderEntityId === entity.entityId;
const assetSlot = resolveRuntimeEntityAsset(entity, assetSlots);
const entityImageSrc = assetSlot?.assetUrl?.trim() || null;
return (
<div
className={`absolute -translate-x-1/2 -translate-y-1/2 rounded-full border shadow-lg transition-all ${
owned
? isLeader
? 'border-cyan-100 bg-cyan-300 shadow-cyan-950/30'
: 'border-cyan-100/70 bg-cyan-500/88 shadow-cyan-950/24'
: entity.level > run.playerLevel
? 'border-rose-100/70 bg-rose-500/88 shadow-rose-950/24'
: 'border-emerald-100/70 bg-emerald-400/88 shadow-emerald-950/20'
className={`absolute -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-full border shadow-lg transition-all ${
entityImageSrc
? owned
? isLeader
? 'border-cyan-50 shadow-cyan-950/40'
: 'border-cyan-100/80 shadow-cyan-950/28'
: entity.level > run.playerLevel
? 'border-rose-100/80 shadow-rose-950/28'
: 'border-emerald-100/80 shadow-emerald-950/24'
: owned
? isLeader
? 'border-cyan-100 bg-cyan-300 shadow-cyan-950/30'
: 'border-cyan-100/70 bg-cyan-500/88 shadow-cyan-950/24'
: entity.level > run.playerLevel
? 'border-rose-100/70 bg-rose-500/88 shadow-rose-950/24'
: 'border-emerald-100/70 bg-emerald-400/88 shadow-emerald-950/20'
}`}
style={projected}
>
<span className="absolute inset-0 flex items-center justify-center text-[0.62rem] font-black text-slate-950">
{entityImageSrc ? (
<>
<ResolvedAssetImage
src={entityImageSrc}
alt={`Lv.${entity.level} 实体`}
className={`h-full w-full object-cover ${
owned && isLeader ? 'scale-110' : ''
}`}
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,transparent_32%,rgba(2,6,23,0.18)_72%,rgba(2,6,23,0.36)_100%)]" />
</>
) : null}
<span className="absolute inset-0 flex items-center justify-center text-[0.62rem] font-black text-white [text-shadow:0_1px_2px_rgba(2,6,23,0.9)]">
{entity.level}
</span>
</div>
@@ -88,6 +146,7 @@ function BigFishEntityDot({
export function BigFishRuntimeShell({
run,
assetSlots = [],
isBusy = false,
error = null,
onBack,
@@ -142,10 +201,20 @@ export function BigFishRuntimeShell({
const statusLabel =
run.status === 'won' ? '通关' : run.status === 'failed' ? '失败' : '进行中';
const backgroundAsset =
findBigFishAssetSlot(assetSlots, 'stage_background')?.assetUrl?.trim() || null;
return (
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
<div className="relative h-full w-full max-w-[430px] overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(34,211,238,0.2),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(16,185,129,0.18),transparent_26%),linear-gradient(180deg,#082f49,#020617)]">
{backgroundAsset ? (
<ResolvedAssetImage
src={backgroundAsset}
alt="大鱼吃小鱼场地背景"
className="absolute inset-0 h-full w-full object-cover"
/>
) : null}
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(8,47,73,0.2),rgba(2,6,23,0.6))]" />
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:32px_32px] opacity-30" />
<div className="absolute left-0 top-0 z-20 flex w-full items-center justify-between px-4 py-4">
@@ -168,6 +237,7 @@ export function BigFishRuntimeShell({
entity={entity}
run={run}
owned={false}
assetSlots={assetSlots}
/>
))}
{run.ownedEntities.map((entity) => (
@@ -176,6 +246,7 @@ export function BigFishRuntimeShell({
entity={entity}
run={run}
owned
assetSlots={assetSlots}
/>
))}
</div>

View File

@@ -61,11 +61,11 @@ import {
} from '../../services/puzzle-agent';
import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery';
import {
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
startPuzzleRun,
swapPuzzlePieces,
} from '../../services/puzzle-runtime';
advanceLocalPuzzleLevel,
dragLocalPuzzlePiece,
startLocalPuzzleRun,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
@@ -1028,8 +1028,9 @@ export function PlatformEntryFlowShellImpl({
setPuzzleError(null);
try {
const { run } = await startPuzzleRun({ profileId });
setPuzzleRun(run);
const { item } = await getPuzzleGalleryDetail(profileId);
setSelectedPuzzleDetail(item);
setPuzzleRun(startLocalPuzzleRun(item));
setSelectionStage('puzzle-runtime');
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
@@ -1064,28 +1065,19 @@ export function PlatformEntryFlowShellImpl({
);
const swapPuzzlePiecesInRun = useCallback(
async (payload: { firstPieceId: string; secondPieceId: string }) => {
(payload: { firstPieceId: string; secondPieceId: string }) => {
if (!puzzleRun || isPuzzleBusy) {
return;
}
setIsPuzzleBusy(true);
setPuzzleError(null);
try {
const { run } = await swapPuzzlePieces(puzzleRun.runId, payload);
setPuzzleRun(run);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
} finally {
setIsPuzzleBusy(false);
}
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
},
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
[isPuzzleBusy, puzzleRun],
);
const dragPuzzlePiece = useCallback(
async (payload: {
(payload: {
pieceId: string;
targetRow: number;
targetCol: number;
@@ -1094,19 +1086,10 @@ export function PlatformEntryFlowShellImpl({
return;
}
setIsPuzzleBusy(true);
setPuzzleError(null);
try {
const { run } = await dragPuzzlePieceOrGroup(puzzleRun.runId, payload);
setPuzzleRun(run);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
} finally {
setIsPuzzleBusy(false);
}
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
},
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
[isPuzzleBusy, puzzleRun],
);
const advancePuzzleLevel = useCallback(async () => {
@@ -1114,18 +1097,9 @@ export function PlatformEntryFlowShellImpl({
return;
}
setIsPuzzleBusy(true);
setPuzzleError(null);
try {
const { run } = await advancePuzzleNextLevel(puzzleRun.runId);
setPuzzleRun(run);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '进入下一关失败。'));
} finally {
setIsPuzzleBusy(false);
}
}, [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage]);
setPuzzleRun(advanceLocalPuzzleLevel(puzzleRun));
}, [isPuzzleBusy, puzzleRun]);
const leaveAgentWorkspace = useCallback(() => {
enterCreateTab();
@@ -1872,6 +1846,7 @@ export function PlatformEntryFlowShellImpl({
>
<BigFishRuntimeShell
run={bigFishRun}
assetSlots={bigFishSession?.assetSlots ?? []}
isBusy={isBigFishBusy}
error={bigFishError}
onBack={() => {

View File

@@ -0,0 +1,103 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
const baseSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-1',
currentTurn: 3,
progressPercent: 62,
stage: 'collecting_anchors',
anchorPack: {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雾港遗迹拼图',
status: 'confirmed',
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '潮雾中的灯塔与断桥',
status: 'confirmed',
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '',
status: 'missing',
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '',
status: 'missing',
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '',
status: 'missing',
},
},
draft: null,
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '画面主体已经清楚,继续收束剩余关键词。',
createdAt: '2026-04-24T10:00:00.000Z',
},
],
lastAssistantReply: '画面主体已经清楚,继续收束剩余关键词。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-24T10:00:00.000Z',
};
beforeEach(() => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
});
test('puzzle workspace submits quick keyword fill request after two turns', async () => {
const user = userEvent.setup();
const onSubmitMessage = vi.fn();
render(
<PuzzleAgentWorkspace
session={baseSession}
onBack={() => {}}
onSubmitMessage={onSubmitMessage}
onExecuteAction={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '补充剩余关键字' }));
expect(onSubmitMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: '请补充剩余关键字。',
}),
);
});
test('puzzle workspace hides keyword fill before two turns', () => {
render(
<PuzzleAgentWorkspace
session={{ ...baseSession, currentTurn: 1 }}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '补充剩余关键字' })).toBeNull();
});

View File

@@ -100,6 +100,17 @@ export function PuzzleAgentWorkspace({
isStreamingReply={Boolean(streamingReplyText)}
isBusy={isBusy}
error={error}
quickActions={[
{
key: 'summarize',
label: '总结当前设定',
},
{
key: 'quickFill',
label: '补充剩余关键字',
minTurn: 2,
},
]}
onBack={onBack}
onSubmitText={(text) => {
onSubmitMessage({
@@ -110,6 +121,15 @@ export function PuzzleAgentWorkspace({
onPrimaryAction={() => {
onExecuteAction({ action: 'compile_puzzle_draft' });
}}
onQuickAction={(action) => {
onSubmitMessage({
clientMessageId: createCreationAgentClientMessageId('puzzle'),
text:
action.key === 'quickFill'
? '请补充剩余关键字。'
: '请总结一下当前已经成形的拼图设定。',
});
}}
/>
);
}

View File

@@ -76,6 +76,9 @@ export function useRpgCreationSessionController(
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
const currentAgentSessionIdRef = useRef<string | null>(null);
const activeAgentReplyAbortControllerRef = useRef<AbortController | null>(
null,
);
const latestAgentSessionSyncRequestIdRef = useRef(0);
const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false);
@@ -128,6 +131,11 @@ export function useRpgCreationSessionController(
latestAgentSessionSyncRequestIdRef.current += 1;
}, []);
const abortActiveAgentReplyStream = useCallback(() => {
activeAgentReplyAbortControllerRef.current?.abort();
activeAgentReplyAbortControllerRef.current = null;
}, []);
const mergePendingAgentUserMessageIntoSession = useCallback(
(
session: CustomWorldAgentSessionSnapshot | null,
@@ -223,8 +231,26 @@ export function useRpgCreationSessionController(
setSelectionStage('agent-workspace');
}, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]);
useEffect(() => {
if (
selectionStage !== 'agent-workspace' &&
selectionStage !== 'custom-world-generating'
) {
abortActiveAgentReplyStream();
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
}
}, [abortActiveAgentReplyStream, selectionStage]);
useEffect(() => {
return () => {
abortActiveAgentReplyStream();
};
}, [abortActiveAgentReplyStream]);
useEffect(() => {
if (!activeAgentSessionId) {
abortActiveAgentReplyStream();
invalidateAgentSessionSyncRequests();
setAgentSession(null);
setAgentOperation(null);
@@ -238,6 +264,7 @@ export function useRpgCreationSessionController(
}
if (!userId) {
abortActiveAgentReplyStream();
invalidateAgentSessionSyncRequests();
setAgentSession(null);
setAgentOperation(null);
@@ -255,6 +282,7 @@ export function useRpgCreationSessionController(
activeAgentSessionId === initialAgentUiStateRef.current.activeSessionId;
if (currentAgentSessionIdRef.current !== activeAgentSessionId) {
abortActiveAgentReplyStream();
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
@@ -306,6 +334,7 @@ export function useRpgCreationSessionController(
};
}, [
activeAgentSessionId,
abortActiveAgentReplyStream,
enterCreateTab,
invalidateAgentSessionSyncRequests,
persistAgentUiState,
@@ -540,6 +569,8 @@ export function useRpgCreationSessionController(
setStreamingAgentReplyText('');
setIsStreamingAgentReply(true);
setPendingAgentUserMessage(pendingMessagePayload);
const replyAbortController = new AbortController();
activeAgentReplyAbortControllerRef.current = replyAbortController;
setAgentSession((current) =>
mergePendingAgentUserMessageIntoSession(current, pendingMessagePayload),
);
@@ -550,10 +581,17 @@ export function useRpgCreationSessionController(
payload,
{
onUpdate: (text) => {
if (replyAbortController.signal.aborted) {
return;
}
setStreamingAgentReplyText(text);
},
signal: replyAbortController.signal,
},
);
if (replyAbortController.signal.aborted) {
return;
}
const mergedNextSession = mergePendingAgentUserMessageIntoSession(
nextSession,
pendingMessagePayload,
@@ -568,6 +606,9 @@ export function useRpgCreationSessionController(
hasServerEchoedPendingMessage ? null : pendingMessagePayload,
);
} catch (error) {
if (replyAbortController.signal.aborted) {
return;
}
const errorMessage = resolveRpgCreationErrorMessage(
error,
'发送共创消息失败。',
@@ -597,7 +638,12 @@ export function useRpgCreationSessionController(
setStreamingAgentReplyText('');
persistAgentUiState(activeAgentSessionId, null);
} finally {
setIsStreamingAgentReply(false);
if (activeAgentReplyAbortControllerRef.current === replyAbortController) {
activeAgentReplyAbortControllerRef.current = null;
}
if (!replyAbortController.signal.aborted) {
setIsStreamingAgentReply(false);
}
}
},
[