This commit is contained in:
2026-04-22 20:14:15 +08:00
parent 0773a0d0ca
commit 0e9c286a57
205 changed files with 25790 additions and 1623 deletions

View File

@@ -219,8 +219,16 @@ test('account panel includes merged security devices and audit sections', async
sessions: [
{
sessionId: 'session-1',
clientType: 'mobile',
clientRuntime: 'ios',
clientPlatform: 'wechat',
clientLabel: 'iPhone 15 Pro',
deviceDisplayName: 'iPhone 15 Pro / 微信',
miniProgramAppId: null,
miniProgramEnv: null,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)',
isCurrent: true,
createdAt: '2026-04-20T07:30:00.000Z',
lastSeenAt: '2026-04-20T09:00:00.000Z',
expiresAt: '2026-04-27T09:00:00.000Z',
ipMasked: '10.0.*.*',
@@ -229,10 +237,12 @@ test('account panel includes merged security devices and audit sections', async
auditLogs: [
{
id: 'log-1',
eventType: 'phone_login',
title: '登录成功',
detail: '通过手机号验证码完成登录。',
createdAt: '2026-04-20T08:00:00.000Z',
ipMasked: '10.0.*.*',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)',
},
],
});

View File

@@ -0,0 +1,102 @@
import type {
BigFishAnchorItemResponse,
BigFishSessionSnapshotResponse,
ExecuteBigFishActionRequest,
SendBigFishMessageRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { createCreationAgentClientMessageId } from '../../services/creation-agent';
import {
type CreationAgentAnchorView,
type CreationAgentSessionView,
type CreationAgentTheme,
CreationAgentWorkspace,
} from '../creation-agent';
type BigFishAgentWorkspaceProps = {
session: BigFishSessionSnapshotResponse | null;
streamingReplyText?: string;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitMessage: (payload: SendBigFishMessageRequest) => void;
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
};
const BIG_FISH_AGENT_THEME: CreationAgentTheme = {
accentTextClass: 'text-cyan-100/85',
accentBgClass: 'bg-cyan-200',
accentButtonClass: 'bg-cyan-200 shadow-cyan-950/20',
userBubbleClass: 'bg-cyan-600 text-white',
heroClass:
'border border-cyan-100/16 bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,0.22),transparent_32%),linear-gradient(135deg,rgba(8,47,73,0.96),rgba(13,24,38,0.96))]',
anchorGridClass: 'grid gap-2 sm:grid-cols-4',
};
function mapBigFishAnchor(
anchor: BigFishAnchorItemResponse,
): CreationAgentAnchorView {
return {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
};
}
function mapBigFishSession(
session: BigFishSessionSnapshotResponse,
): CreationAgentSessionView {
return {
sessionId: session.sessionId,
title: '大鱼吃小鱼共创',
assistantSummary:
session.lastAssistantReply ||
'先用一句灵感开始Agent 会收束成可编译的玩法锚点。',
currentTurn: session.currentTurn,
progressPercent: session.progressPercent,
anchors: [
session.anchorPack.gameplayPromise,
session.anchorPack.ecologyVisualTheme,
session.anchorPack.growthLadder,
session.anchorPack.riskTempo,
].map(mapBigFishAnchor),
messages: session.messages,
recommendedReplies: [],
};
}
export function BigFishAgentWorkspace({
session,
streamingReplyText = '',
isBusy = false,
error = null,
onBack,
onSubmitMessage,
onExecuteAction,
}: BigFishAgentWorkspaceProps) {
return (
<CreationAgentWorkspace
session={session ? mapBigFishSession(session) : null}
theme={BIG_FISH_AGENT_THEME}
loadingText="正在准备大鱼吃小鱼共创工作区..."
composerPlaceholder="说说这局的生态、成长或爽点..."
primaryActionLabel="生成结果页"
streamingReplyText={streamingReplyText}
isStreamingReply={Boolean(streamingReplyText)}
isBusy={isBusy}
error={error}
onBack={onBack}
onSubmitText={(text) => {
onSubmitMessage({
clientMessageId: createCreationAgentClientMessageId('big-fish'),
text,
});
}}
onPrimaryAction={() => {
onExecuteAction({ action: 'big_fish_compile_draft' });
}}
/>
);
}
export default BigFishAgentWorkspace;

View File

@@ -0,0 +1,471 @@
import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Loader2,
Play,
Sparkles,
Waves,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import type {
BigFishAssetSlotResponse,
BigFishGameDraftResponse,
BigFishLevelBlueprintResponse,
BigFishSessionSnapshotResponse,
ExecuteBigFishActionRequest,
} from '../../../packages/shared/src/contracts/bigFish';
type BigFishAssetStudioTarget =
| {
kind: 'level_main_image';
level: BigFishLevelBlueprintResponse;
}
| {
kind: 'level_motion';
level: BigFishLevelBlueprintResponse;
motionKey: 'idle_float' | 'move_swim';
}
| {
kind: 'stage_background';
};
type BigFishResultViewProps = {
session: BigFishSessionSnapshotResponse;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
onStartTestRun: () => void;
};
function findAssetSlot(
slots: BigFishAssetSlotResponse[],
assetKind: string,
level?: number,
motionKey?: string,
) {
return slots.find((slot) => {
if (slot.assetKind !== assetKind) {
return false;
}
if (level !== undefined && slot.level !== level) {
return false;
}
if (motionKey !== undefined && slot.motionKey !== motionKey) {
return false;
}
return true;
});
}
function assetReadyLabel(slot: BigFishAssetSlotResponse | undefined) {
return slot?.status === 'ready' ? '已生成' : '待生成';
}
function buildLevelAssetPreview(slot: BigFishAssetSlotResponse | undefined) {
if (slot?.assetUrl) {
return slot.assetUrl;
}
return null;
}
function BigFishAssetStudioModal({
draft,
target,
isBusy,
onClose,
onExecuteAction,
}: {
draft: BigFishGameDraftResponse;
target: BigFishAssetStudioTarget;
isBusy: boolean;
onClose: () => void;
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
}) {
const title =
target.kind === 'stage_background'
? '场地背景工坊'
: target.kind === 'level_main_image'
? `Lv.${target.level.level} 主图工坊`
: `Lv.${target.level.level} 动作工坊`;
const prompt =
target.kind === 'stage_background'
? draft.background.backgroundPromptSeed
: target.kind === 'level_main_image'
? target.level.visualPromptSeed
: `${target.level.motionPromptSeed} / ${target.motionKey}`;
const execute = () => {
if (target.kind === 'stage_background') {
onExecuteAction({ action: 'big_fish_generate_stage_background' });
return;
}
if (target.kind === 'level_main_image') {
onExecuteAction({
action: 'big_fish_generate_level_main_image',
level: target.level.level,
});
return;
}
onExecuteAction({
action: 'big_fish_generate_level_motion',
level: target.level.level,
motionKey: target.motionKey,
});
};
return (
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div className="platform-modal-shell w-full max-w-xl overflow-hidden rounded-[1.8rem]">
<div className="border-b border-[var(--platform-subpanel-border)] px-4 py-4">
<div className="text-lg font-black text-[var(--platform-text-strong)]">
{title}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{target.kind === 'stage_background'
? draft.background.theme
: target.level.oneLineFantasy}
</div>
</div>
<div className="space-y-4 px-4 py-4">
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
PROMPT
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-strong)]">
{prompt}
</div>
</div>
<div className="flex aspect-[9/5] items-center justify-center rounded-[1.4rem] border border-dashed border-cyan-300/50 bg-cyan-50/40 text-sm text-[var(--platform-text-base)]">
AI
</div>
</div>
<div className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-4">
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-[var(--platform-subpanel-border)] px-4 py-2 text-sm font-semibold text-[var(--platform-text-base)] disabled:opacity-45"
>
</button>
<button
type="button"
onClick={execute}
disabled={isBusy}
className="inline-flex items-center gap-2 rounded-full bg-cyan-600 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
</button>
</div>
</div>
</div>
);
}
function BigFishLevelCard({
level,
slots,
isBusy,
onOpenStudio,
}: {
level: BigFishLevelBlueprintResponse;
slots: BigFishAssetSlotResponse[];
isBusy: boolean;
onOpenStudio: (target: BigFishAssetStudioTarget) => void;
}) {
const mainImageSlot = findAssetSlot(
slots,
'level_main_image',
level.level,
);
const idleSlot = findAssetSlot(
slots,
'level_motion',
level.level,
'idle_float',
);
const moveSlot = findAssetSlot(
slots,
'level_motion',
level.level,
'move_swim',
);
const previewUrl = buildLevelAssetPreview(mainImageSlot);
return (
<article className="overflow-hidden rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-white/78">
<div className="flex gap-3 p-3">
<div className="flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-[1.15rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.28),transparent_68%),linear-gradient(145deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))] text-white">
{previewUrl ? (
<img
src={previewUrl}
alt={level.name}
className="h-full w-full object-cover"
/>
) : (
<Waves className="h-8 w-8 text-cyan-100/72" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-xs font-black tracking-[0.18em] text-cyan-700">
LV.{level.level}
</div>
<div className="mt-1 text-lg font-black text-[var(--platform-text-strong)]">
{level.name}
</div>
</div>
{level.isFinalLevel ? (
<span className="rounded-full bg-amber-100 px-2 py-1 text-xs font-bold text-amber-700">
</span>
) : null}
</div>
<div className="mt-2 line-clamp-2 text-sm leading-5 text-[var(--platform-text-base)]">
{level.oneLineFantasy}
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-[var(--platform-text-soft)]">
<span> {level.preyWindow.join('/') || '-'}</span>
<span> {level.threatWindow.join('/') || '-'}</span>
<span> {assetReadyLabel(mainImageSlot)}</span>
<span>
{[assetReadyLabel(idleSlot), assetReadyLabel(moveSlot)].join('/')}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 border-t border-[var(--platform-subpanel-border)] p-3">
<button
type="button"
disabled={isBusy}
onClick={() => {
onOpenStudio({ kind: 'level_main_image', level });
}}
className="rounded-full bg-cyan-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-45"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
onOpenStudio({
kind: 'level_motion',
level,
motionKey: 'idle_float',
});
}}
className="rounded-full border border-[var(--platform-subpanel-border)] px-3 py-2 text-xs font-bold text-[var(--platform-text-base)] disabled:opacity-45"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
onOpenStudio({
kind: 'level_motion',
level,
motionKey: 'move_swim',
});
}}
className="rounded-full border border-[var(--platform-subpanel-border)] px-3 py-2 text-xs font-bold text-[var(--platform-text-base)] disabled:opacity-45"
>
</button>
</div>
</article>
);
}
export function BigFishResultView({
session,
isBusy = false,
error = null,
onBack,
onExecuteAction,
onStartTestRun,
}: BigFishResultViewProps) {
const [studioTarget, setStudioTarget] =
useState<BigFishAssetStudioTarget | null>(null);
const draft = session.draft;
const backgroundSlot = findAssetSlot(session.assetSlots, 'stage_background');
const blockers = useMemo(
() => session.assetCoverage.blockers.filter(Boolean),
[session.assetCoverage.blockers],
);
if (!draft) {
return (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
稿
</div>
</div>
);
}
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div className="relative overflow-hidden rounded-[1.8rem] border border-cyan-100/16 bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,0.2),transparent_32%),linear-gradient(135deg,rgba(8,47,73,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex gap-2">
<button
type="button"
disabled={isBusy}
onClick={() => {
onStartTestRun();
}}
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
>
<Play className="h-4 w-4" />
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
onExecuteAction({ action: 'big_fish_publish_game' });
}}
className="inline-flex items-center gap-2 rounded-full bg-cyan-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<CheckCircle2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-6">
<div className="text-2xl font-black leading-tight sm:text-3xl">
{draft.title}
</div>
<div className="mt-2 max-w-2xl text-sm leading-6 text-cyan-50/76">
{draft.subtitle}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-cyan-50/78">
<span className="rounded-full bg-white/10 px-3 py-1">
{draft.coreFun}
</span>
<span className="rounded-full bg-white/10 px-3 py-1">
{draft.ecologyTheme}
</span>
<span className="rounded-full bg-white/10 px-3 py-1">
{draft.runtimeParams.levelCount}
</span>
</div>
</div>
{error ? (
<div className="rounded-[1.15rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
{error}
</div>
) : null}
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-h-0 overflow-y-auto pr-1">
<div className="grid gap-3 sm:grid-cols-2">
{draft.levels.map((level) => (
<BigFishLevelCard
key={level.level}
level={level}
slots={session.assetSlots}
isBusy={isBusy}
onOpenStudio={setStudioTarget}
/>
))}
</div>
</div>
<aside className="min-h-0 space-y-3 overflow-y-auto">
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-xs text-[var(--platform-text-soft)]">
{assetReadyLabel(backgroundSlot)}
</div>
</div>
<ImagePlus className="h-5 w-5 text-cyan-600" />
</div>
<div className="mt-3 aspect-[9/16] rounded-[1.2rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.2),transparent_62%),linear-gradient(180deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))]" />
<button
type="button"
disabled={isBusy}
onClick={() => {
setStudioTarget({ kind: 'stage_background' });
}}
className="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-full bg-cyan-600 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
>
<Sparkles className="h-4 w-4" />
</button>
</div>
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-3 space-y-2 text-sm text-[var(--platform-text-base)]">
<div>
{session.assetCoverage.levelMainImageReadyCount}/
{session.assetCoverage.requiredLevelCount}
</div>
<div>
{session.assetCoverage.levelMotionReadyCount}/
{session.assetCoverage.requiredLevelCount * 2}
</div>
<div>
{session.assetCoverage.backgroundReady ? '已完成' : '待生成'}
</div>
</div>
{blockers.length > 0 ? (
<div className="mt-3 space-y-1 text-xs leading-5 text-amber-700">
{blockers.slice(0, 4).map((blocker) => (
<div key={blocker}>{blocker}</div>
))}
</div>
) : (
<div className="mt-3 text-sm font-semibold text-emerald-600">
</div>
)}
</div>
</aside>
</div>
{studioTarget ? (
<BigFishAssetStudioModal
draft={draft}
target={studioTarget}
isBusy={isBusy}
onClose={() => {
setStudioTarget(null);
}}
onExecuteAction={(payload) => {
onExecuteAction(payload);
setStudioTarget(null);
}}
/>
) : null}
</div>
);
}
export default BigFishResultView;

View File

@@ -0,0 +1,230 @@
import { ArrowLeft, Loader2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type {
BigFishRuntimeEntityResponse,
BigFishRuntimeSnapshotResponse,
SubmitBigFishInputRequest,
} from '../../../packages/shared/src/contracts/bigFish';
type BigFishRuntimeShellProps = {
run: BigFishRuntimeSnapshotResponse | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitInput: (payload: SubmitBigFishInputRequest) => void;
};
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function normalizeVector(x: number, y: number) {
const length = Math.hypot(x, y);
if (length <= 0.001) {
return { x: 0, y: 0 };
}
const capped = Math.min(1, length);
return {
x: (x / length) * capped,
y: (y / length) * capped,
};
}
function projectEntity(
entity: BigFishRuntimeEntityResponse,
run: BigFishRuntimeSnapshotResponse,
) {
const viewportWidth = 360;
const viewportHeight = 640;
const worldWidth = 420;
const worldHeight = 760;
const x =
viewportWidth / 2 +
((entity.position.x - run.cameraCenter.x) / worldWidth) * viewportWidth;
const y =
viewportHeight / 2 +
((entity.position.y - run.cameraCenter.y) / worldHeight) * viewportHeight;
return {
left: `${clamp(x, -40, viewportWidth + 40)}px`,
top: `${clamp(y, -40, viewportHeight + 40)}px`,
width: `${Math.max(22, entity.radius * 2.2)}px`,
height: `${Math.max(22, entity.radius * 2.2)}px`,
};
}
function BigFishEntityDot({
entity,
run,
owned,
}: {
entity: BigFishRuntimeEntityResponse;
run: BigFishRuntimeSnapshotResponse;
owned: boolean;
}) {
const projected = projectEntity(entity, run);
const isLeader = run.leaderEntityId === entity.entityId;
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'
}`}
style={projected}
>
<span className="absolute inset-0 flex items-center justify-center text-[0.62rem] font-black text-slate-950">
{entity.level}
</span>
</div>
);
}
export function BigFishRuntimeShell({
run,
isBusy = false,
error = null,
onBack,
onSubmitInput,
}: BigFishRuntimeShellProps) {
const padRef = useRef<HTMLDivElement | null>(null);
const [stick, setStick] = useState({ x: 0, y: 0 });
const stickRef = useRef(stick);
useEffect(() => {
stickRef.current = stick;
}, [stick]);
useEffect(() => {
const timer = window.setInterval(() => {
const current = stickRef.current;
// 即使摇杆静止也持续回传当前输入,让后端持续推进刷怪、清理与胜负裁决。
onSubmitInput(current);
}, 220);
return () => {
window.clearInterval(timer);
};
}, [onSubmitInput]);
const updateStickFromPointer = (clientX: number, clientY: number) => {
const pad = padRef.current;
if (!pad) {
return;
}
const rect = pad.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const vector = normalizeVector(
(clientX - centerX) / (rect.width / 2),
(clientY - centerY) / (rect.height / 2),
);
setStick(vector);
onSubmitInput(vector);
};
if (!run) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950 text-white">
<div className="flex items-center gap-2 rounded-full bg-white/10 px-5 py-3 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</div>
);
}
const statusLabel =
run.status === 'won' ? '通关' : run.status === 'failed' ? '失败' : '进行中';
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)]">
<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">
<button
type="button"
onClick={onBack}
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 backdrop-blur"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="rounded-full bg-black/28 px-4 py-2 text-xs font-bold backdrop-blur">
Lv.{run.playerLevel}/{run.winLevel} · {statusLabel}
</div>
</div>
<div className="absolute left-1/2 top-1/2 h-[640px] w-[360px] -translate-x-1/2 -translate-y-1/2">
{run.wildEntities.map((entity) => (
<BigFishEntityDot
key={entity.entityId}
entity={entity}
run={run}
owned={false}
/>
))}
{run.ownedEntities.map((entity) => (
<BigFishEntityDot
key={entity.entityId}
entity={entity}
run={run}
owned
/>
))}
</div>
<div className="absolute bottom-6 left-4 z-30">
<div
ref={padRef}
role="presentation"
className="relative h-28 w-28 rounded-full border border-white/18 bg-black/24 backdrop-blur"
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
updateStickFromPointer(event.clientX, event.clientY);
}}
onPointerMove={(event) => {
if (event.buttons <= 0) {
return;
}
updateStickFromPointer(event.clientX, event.clientY);
}}
onPointerUp={() => {
setStick({ x: 0, y: 0 });
onSubmitInput({ x: 0, y: 0 });
}}
onPointerCancel={() => {
setStick({ x: 0, y: 0 });
onSubmitInput({ x: 0, y: 0 });
}}
>
<div
className="absolute left-1/2 top-1/2 h-11 w-11 -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-200 shadow-lg shadow-cyan-950/30"
style={{
transform: `translate(calc(-50% + ${stick.x * 34}px), calc(-50% + ${stick.y * 34}px))`,
}}
/>
</div>
</div>
<div className="absolute bottom-6 right-4 z-30 max-w-[13rem] space-y-2 text-right text-xs text-white/72">
{isBusy ? <div>...</div> : null}
{error ? <div className="text-rose-200">{error}</div> : null}
{run.eventLog.slice(-3).map((event) => (
<div key={event} className="rounded-full bg-black/22 px-3 py-1">
{event}
</div>
))}
</div>
</div>
</div>
);
}
export default BigFishRuntimeShell;

View File

@@ -0,0 +1,112 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { type CreationAgentTheme,CreationAgentWorkspace } from './CreationAgentWorkspace';
const testTheme: CreationAgentTheme = {
accentTextClass: 'text-emerald-100',
accentBgClass: 'bg-emerald-300',
accentButtonClass: 'bg-emerald-200',
userBubbleClass: 'bg-emerald-600 text-white',
heroClass: 'border border-emerald-100/20 bg-slate-900',
};
afterEach(() => {
vi.restoreAllMocks();
});
test('creation agent workspace filters duplicate recommended replies', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 2,
progressPercent: 40,
anchors: [],
messages: [
{
id: '',
role: 'assistant',
kind: 'chat',
text: '先把方向收一下。',
},
],
recommendedReplies: [
'',
'继续补充冲突',
'继续补充冲突',
' 先确定玩家身份 ',
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '继续补充冲突' })).toBeTruthy();
expect(screen.getByRole('button', { name: '先确定玩家身份' })).toBeTruthy();
expect(screen.queryByRole('button', { name: /^\s*$/u })).toBeNull();
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
call.some(
(arg) =>
typeof arg === 'string' &&
arg.includes('Encountered two children with the same key'),
),
);
expect(duplicateKeyCalls).toHaveLength(0);
});
test('creation agent workspace renders streaming assistant text', () => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 1,
progressPercent: 20,
anchors: [],
messages: [
{
id: 'message-1',
role: 'user',
kind: 'chat',
text: '我想做一个潮湿压抑的海上世界。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
streamingReplyText="那我先顺着这个方向收一下,开场时你更想让玩家撞上什么麻烦"
isStreamingReply
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.getByText(//u)).toBeTruthy();
});

View File

@@ -0,0 +1,493 @@
import { ArrowLeft, Send, Sparkles } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import {
type CreationAgentProgressCopy,
normalizeCreationAgentProgress,
resolveCreationAgentProgressHint,
resolveCreationAnchorStatusLabel,
} from '../../services/creation-agent';
export type CreationAgentAnchorView = {
key: string;
label: string;
value: string;
status: string;
};
export type CreationAgentMessageView = {
id: string;
role: string;
kind?: string;
text: string;
createdAt?: string;
};
export type CreationAgentOperationView = {
operationId?: string;
type?: string;
status: string;
phaseLabel: string;
phaseDetail?: string;
progress: number;
error?: string | null;
};
export type CreationAgentSessionView = {
sessionId: string;
title: string;
assistantSummary?: string | null;
currentTurn: number;
progressPercent: number;
anchors: CreationAgentAnchorView[];
messages: CreationAgentMessageView[];
recommendedReplies?: string[];
};
export type CreationAgentTheme = {
accentTextClass: string;
accentBgClass: string;
accentButtonClass: string;
userBubbleClass: string;
heroClass: string;
anchorGridClass?: string;
};
export type CreationAgentQuickAction = {
key: string;
label: string;
minTurn?: number;
minProgress?: number;
showWhenComplete?: boolean;
};
type CreationAgentWorkspaceProps = {
session: CreationAgentSessionView | null;
theme: CreationAgentTheme;
loadingText: string;
composerPlaceholder: string;
primaryActionLabel: string;
progressCopy?: CreationAgentProgressCopy;
activeOperation?: CreationAgentOperationView | null;
streamingReplyText?: string;
isStreamingReply?: boolean;
isBusy?: boolean;
error?: string | null;
quickActions?: CreationAgentQuickAction[];
onBack: () => void;
onSubmitText: (text: string, quickActionKey?: string) => void;
onPrimaryAction: () => void;
onQuickAction?: (action: CreationAgentQuickAction) => void;
};
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
return [...new Set(recommendedReplies.map((reply) => reply.trim()).filter(Boolean))].slice(
0,
3,
);
}
function CreationAgentOperationBanner({
operation,
}: {
operation: CreationAgentOperationView | null | undefined;
}) {
const [visibleOperation, setVisibleOperation] =
useState<CreationAgentOperationView | null>(operation ?? null);
useEffect(() => {
setVisibleOperation(operation ?? null);
if (operation?.status !== 'completed') {
return;
}
const timeoutId = window.setTimeout(() => {
setVisibleOperation((current) =>
current?.operationId === operation.operationId ? null : current,
);
}, 1200);
return () => window.clearTimeout(timeoutId);
}, [operation]);
if (!visibleOperation) {
return null;
}
const isFailed = visibleOperation.status === 'failed';
const isRunning =
visibleOperation.status === 'running' ||
visibleOperation.status === 'queued';
const bannerToneClass = isFailed
? 'platform-banner--danger'
: isRunning
? 'platform-banner--info'
: 'platform-banner--success';
const progress = normalizeCreationAgentProgress(visibleOperation.progress);
const progressFillStyle = isFailed
? { background: 'linear-gradient(90deg, #fb7185 0%, #f43f5e 100%)' }
: isRunning
? { background: 'var(--platform-button-primary-fill)' }
: { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' };
return (
<div
className={`platform-remap-surface platform-banner rounded-[1.4rem] px-4 py-4 ${bannerToneClass}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold">
{visibleOperation.phaseLabel}
</div>
<div className="text-xs opacity-80">{progress}%</div>
</div>
{visibleOperation.phaseDetail ? (
<div className="mt-1 text-xs opacity-80">
{visibleOperation.phaseDetail}
</div>
) : null}
{visibleOperation.error ? (
<div className="mt-2 text-sm opacity-90">{visibleOperation.error}</div>
) : null}
<div className="platform-progress-track mt-3 h-2 overflow-hidden rounded-full">
<div
className="h-full rounded-full transition-[width] duration-300"
style={{
width: `${Math.max(8, progress)}%`,
...progressFillStyle,
}}
/>
</div>
</div>
);
}
function CreationAgentMessageBubble({
message,
theme,
recommendedReplies,
onRecommendedReply,
}: {
message: CreationAgentMessageView;
theme: CreationAgentTheme;
recommendedReplies?: string[];
onRecommendedReply: (text: string) => void;
}) {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const visibleRecommendedReplies = isUser
? []
: uniqueRecommendedReplies(recommendedReplies);
const bubbleToneClass = isUser
? theme.userBubbleClass
: isSystem
? 'border border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
: 'platform-subpanel text-[var(--platform-text-strong)]';
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${bubbleToneClass}`}
>
<div className="whitespace-pre-wrap">{message.text}</div>
{visibleRecommendedReplies.length > 0 ? (
<div className="mt-2.5 flex flex-col gap-1.5">
{visibleRecommendedReplies.map((reply, replyIndex) => (
<button
key={`recommended-reply-${replyIndex}-${reply}`}
type="button"
onClick={() => onRecommendedReply(reply)}
className="platform-button platform-button--ghost min-h-0 justify-start rounded-[0.95rem] px-2.5 py-1.5 text-left text-[11px] leading-4.5 whitespace-normal"
>
{reply}
</button>
))}
</div>
) : null}
</div>
</div>
);
}
function CreationAgentAnchorChip({
anchor,
theme,
}: {
anchor: CreationAgentAnchorView;
theme: CreationAgentTheme;
}) {
return (
<div className="rounded-[1.25rem] border border-white/14 bg-white/8 px-3 py-3 text-left">
<div className="flex items-center justify-between gap-2">
<span
className={`text-xs font-semibold tracking-[0.18em] ${theme.accentTextClass}`}
>
{anchor.label}
</span>
<span className="rounded-full bg-white/12 px-2 py-1 text-[0.68rem] text-white/70">
{resolveCreationAnchorStatusLabel(anchor.status)}
</span>
</div>
<div className="mt-2 line-clamp-2 text-sm leading-5 text-white/86">
{anchor.value || '等待补齐'}
</div>
</div>
);
}
function shouldShowQuickAction(
action: CreationAgentQuickAction,
session: CreationAgentSessionView,
progress: number,
) {
if (action.showWhenComplete && progress < 100) {
return false;
}
if (!action.showWhenComplete && progress >= 100 && action.minProgress !== 100) {
return false;
}
if (typeof action.minTurn === 'number' && session.currentTurn < action.minTurn) {
return false;
}
if (typeof action.minProgress === 'number' && progress < action.minProgress) {
return false;
}
return true;
}
export function CreationAgentWorkspace({
session,
theme,
loadingText,
composerPlaceholder,
primaryActionLabel,
progressCopy,
activeOperation = null,
streamingReplyText = '',
isStreamingReply = false,
isBusy = false,
error = null,
quickActions = [],
onBack,
onSubmitText,
onPrimaryAction,
onQuickAction,
}: CreationAgentWorkspaceProps) {
const [draftText, setDraftText] = useState('');
const bottomRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
}, [session?.messages, streamingReplyText, isStreamingReply]);
if (!session) {
return (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{error || loadingText}
</div>
</div>
);
}
const progress = normalizeCreationAgentProgress(session.progressPercent);
const visibleQuickActions = quickActions.filter((action) =>
shouldShowQuickAction(action, session, progress),
);
const lastAssistantMessageIndex = session.messages.reduce(
(lastIndex, message, index) =>
message.role === 'assistant' ? index : lastIndex,
-1,
);
const submit = () => {
const text = draftText.trim();
if (!text || isBusy) {
return;
}
onSubmitText(text);
setDraftText('');
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div className={`relative overflow-hidden rounded-[1.8rem] px-4 py-4 text-white shadow-[0_20px_60px_rgba(15,23,42,0.18)] sm:px-5 ${theme.heroClass}`}>
<div className="flex items-start justify-between gap-3">
<button
type="button"
aria-label="返回"
onClick={onBack}
disabled={isBusy}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
disabled={isBusy}
onClick={onPrimaryAction}
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-bold text-slate-950 shadow-lg disabled:cursor-not-allowed disabled:opacity-50 ${theme.accentButtonClass}`}
>
<Sparkles className="h-4 w-4" />
{primaryActionLabel}
</button>
</div>
<div className="mt-6">
<div className="text-2xl font-black leading-tight sm:text-3xl">
{session.title}
</div>
{session.assistantSummary ? (
<div className="mt-2 max-w-2xl text-sm leading-6 text-white/76">
{session.assistantSummary}
</div>
) : null}
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-semibold tracking-[0.14em] text-white/72">
</span>
<span className="text-sm font-semibold text-white/88">
{progress}%
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/12">
<div
className={`h-full rounded-full transition-all ${theme.accentBgClass}`}
style={{ width: `${Math.max(6, progress)}%` }}
/>
</div>
<div className="mt-2 text-xs leading-5 text-white/64">
{resolveCreationAgentProgressHint(progress, progressCopy)}
</div>
</div>
{visibleQuickActions.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{visibleQuickActions.map((action) => (
<button
key={action.key}
type="button"
disabled={isBusy}
onClick={() => onQuickAction?.(action)}
className="rounded-full border border-white/14 bg-white/10 px-3 py-1.5 text-xs font-semibold text-white/78 disabled:cursor-not-allowed disabled:opacity-45"
>
{action.label}
</button>
))}
</div>
) : null}
</div>
{session.anchors.length > 0 ? (
<div className={theme.anchorGridClass || 'grid gap-2 sm:grid-cols-2 xl:grid-cols-4'}>
{session.anchors.map((anchor) => (
<CreationAgentAnchorChip
key={anchor.key}
anchor={anchor}
theme={theme}
/>
))}
</div>
) : null}
<CreationAgentOperationBanner operation={activeOperation} />
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.6rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4">
{session.messages.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
</div>
) : (
session.messages.map((message, index) => (
<CreationAgentMessageBubble
key={message.id || `message-${index}`}
message={message}
theme={theme}
recommendedReplies={
index === lastAssistantMessageIndex
? session.recommendedReplies
: []
}
onRecommendedReply={(text) => onSubmitText(text)}
/>
))
)}
{isStreamingReply ? (
<div className="flex justify-start">
<div className="platform-subpanel max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 text-[var(--platform-text-strong)] sm:max-w-[82%]">
{streamingReplyText ? (
<div className="whitespace-pre-wrap">
{streamingReplyText}
<span
className={`ml-1 inline-block h-4 w-1 animate-pulse rounded-full align-[-2px] ${theme.accentBgClass}`}
/>
</div>
) : (
<div className="flex items-center gap-1.5 py-1">
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
</div>
)}
</div>
</div>
) : null}
<div ref={bottomRef} />
</div>
{error ? (
<div className="mx-4 mb-3 rounded-[1rem] border border-red-200/70 bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</div>
) : null}
<div className="border-t border-[var(--platform-subpanel-border)] p-3">
<div className="flex items-end gap-2 rounded-[1.25rem] bg-white/80 p-2">
<textarea
value={draftText}
disabled={isBusy}
rows={2}
onChange={(event) => {
setDraftText(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
}}
placeholder={composerPlaceholder}
className="min-h-[3rem] flex-1 resize-none bg-transparent px-2 py-2 text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
type="button"
aria-label="发送"
disabled={isBusy || !draftText.trim()}
onClick={submit}
className={`inline-flex h-11 w-11 items-center justify-center rounded-full text-white disabled:cursor-not-allowed disabled:opacity-40 ${theme.userBubbleClass}`}
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
</div>
);
}
export default CreationAgentWorkspace;

View File

@@ -0,0 +1 @@
export * from './CreationAgentWorkspace';

View File

@@ -1,74 +0,0 @@
import type { RefObject } from 'react';
import { useState } from 'react';
import type { SendCustomWorldAgentMessageRequest } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentComposerProps = {
disabled: boolean;
onSubmit: (payload: SendCustomWorldAgentMessageRequest) => void;
textareaRef?: RefObject<HTMLTextAreaElement | null>;
};
function createClientMessageId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `client-message-${Date.now()}`;
}
export function CustomWorldAgentComposer({
disabled,
onSubmit,
textareaRef,
}: CustomWorldAgentComposerProps) {
const [text, setText] = useState('');
const submit = () => {
const nextText = text.trim();
if (!nextText || disabled) {
return;
}
onSubmit({
clientMessageId: createClientMessageId(),
text: nextText,
focusCardId: null,
selectedCardIds: [],
});
setText('');
};
return (
<div className="shrink-0">
<div className="platform-remap-surface platform-subpanel relative rounded-[1.5rem] p-1.5">
<textarea
ref={textareaRef}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
}}
rows={2}
disabled={disabled}
placeholder="输入消息"
className="platform-input min-h-[5.5rem] resize-none rounded-[1.2rem] pb-12 pr-20 pt-3 text-sm leading-5.5 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={submit}
disabled={disabled || !text.trim()}
className="platform-button platform-button--primary absolute bottom-3 right-3 h-9 min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
</div>
</div>
);
}

View File

@@ -1,17 +0,0 @@
type CustomWorldAgentHeaderProps = {
onBack: () => void;
};
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
return (
<div className="platform-remap-surface platform-subpanel flex items-center rounded-[1.5rem] px-4 py-3">
<button
type="button"
onClick={onBack}
className="platform-button platform-button--ghost h-9 min-h-0 rounded-full px-3 py-1.5 text-[11px]"
>
</button>
</div>
);
}

View File

@@ -1,78 +0,0 @@
import { useEffect, useState } from 'react';
import type { CustomWorldAgentOperationRecord } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentOperationBannerProps = {
operation: CustomWorldAgentOperationRecord | null;
};
export function CustomWorldAgentOperationBanner({
operation,
}: CustomWorldAgentOperationBannerProps) {
const [visibleOperation, setVisibleOperation] =
useState<CustomWorldAgentOperationRecord | null>(operation);
useEffect(() => {
setVisibleOperation(operation);
if (operation?.status !== 'completed') {
return;
}
const timeoutId = window.setTimeout(() => {
setVisibleOperation((current) =>
current?.operationId === operation.operationId ? null : current,
);
}, 1200);
return () => window.clearTimeout(timeoutId);
}, [operation]);
if (!visibleOperation) {
return null;
}
const isFailed = visibleOperation.status === 'failed';
const isRunning =
visibleOperation.status === 'running' || visibleOperation.status === 'queued';
// 操作横幅直接复用平台状态横幅,亮暗主题都从同一套 token 取色。
const bannerToneClass = isFailed
? 'platform-banner--danger'
: isRunning
? 'platform-banner--info'
: 'platform-banner--success';
const progressFillStyle = isFailed
? { background: 'linear-gradient(90deg, #fb7185 0%, #f43f5e 100%)' }
: isRunning
? { background: 'var(--platform-button-primary-fill)' }
: { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' };
return (
<div
className={`platform-remap-surface platform-banner rounded-[1.4rem] px-4 py-4 ${bannerToneClass}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold">
{visibleOperation.phaseLabel}
</div>
<div className="text-xs opacity-80">
{Math.max(0, Math.min(100, Math.round(visibleOperation.progress)))}%
</div>
</div>
{visibleOperation.error ? (
<div className="mt-2 text-sm opacity-90">
{visibleOperation.error}
</div>
) : null}
<div className="platform-progress-track mt-3 h-2 overflow-hidden rounded-full">
<div
className="h-full rounded-full transition-[width] duration-300"
style={{
width: `${Math.max(8, Math.min(100, visibleOperation.progress))}%`,
...progressFillStyle,
}}
/>
</div>
</div>
);
}

View File

@@ -1,93 +0,0 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { CustomWorldAgentThread } from './CustomWorldAgentThread';
afterEach(() => {
vi.restoreAllMocks();
});
test('filters empty recommended replies and avoids duplicate key warnings', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
render(
<CustomWorldAgentThread
messages={[
{
id: '',
role: 'assistant',
kind: 'summary',
text: '先把世界骨架收出来。',
createdAt: '2026-04-16T10:00:00.000Z',
relatedOperationId: null,
},
{
id: '',
role: 'user',
kind: 'chat',
text: '继续。',
createdAt: '2026-04-16T10:01:00.000Z',
relatedOperationId: null,
},
]}
recommendedReplies={[
'',
'继续补充冲突',
'继续补充冲突',
' 先确定玩家身份 ',
]}
onRecommendedReply={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '继续补充冲突' })).toBeTruthy();
expect(screen.getByRole('button', { name: '先确定玩家身份' })).toBeTruthy();
expect(screen.queryByRole('button', { name: /^\s*$/u })).toBeNull();
expect(screen.getAllByRole('button')).toHaveLength(2);
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
call.some(
(arg) =>
typeof arg === 'string' &&
arg.includes('Encountered two children with the same key'),
),
);
expect(duplicateKeyCalls).toHaveLength(0);
});
test('renders a streaming assistant bubble without timestamps', () => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
render(
<CustomWorldAgentThread
messages={[
{
id: 'message-1',
role: 'user',
kind: 'chat',
text: '我想做一个潮湿压抑的海上世界。',
createdAt: '2026-04-16T10:01:00.000Z',
relatedOperationId: null,
},
]}
streamingReplyText="那我先顺着这个方向收一下,开场时你更想让玩家撞上什么麻烦"
isStreamingReply
/>,
);
expect(
screen.getByText(//u),
).toBeTruthy();
expect(screen.queryByText('10:01')).toBeNull();
});

View File

@@ -1,111 +0,0 @@
import { useEffect, useRef } from 'react';
import type { CustomWorldAgentMessage } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentThreadProps = {
messages: CustomWorldAgentMessage[];
recommendedReplies?: string[];
onRecommendedReply?: (text: string) => void;
streamingReplyText?: string;
isStreamingReply?: boolean;
};
export function CustomWorldAgentThread({
messages,
recommendedReplies = [],
onRecommendedReply,
streamingReplyText = '',
isStreamingReply = false,
}: CustomWorldAgentThreadProps) {
const bottomRef = useRef<HTMLDivElement | null>(null);
const visibleRecommendedReplies = [
...new Set(
recommendedReplies.map((reply) => reply.trim()).filter(Boolean),
),
].slice(0, 3);
const lastAssistantMessageIndex = messages.reduce(
(lastIndex, message, index) =>
message.role === 'assistant' ? index : lastIndex,
-1,
);
useEffect(() => {
bottomRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
}, [messages, streamingReplyText, isStreamingReply]);
return (
<div className="platform-remap-surface platform-subpanel flex h-full min-h-0 flex-1 flex-col overflow-y-auto rounded-[1.75rem] px-2 py-3 sm:px-3">
{messages.length === 0 ? (
<div className="m-auto text-sm text-[var(--platform-text-soft)]">
</div>
) : (
<div className="space-y-3">
{messages.map((message, index) => {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
// 聊天气泡统一映射到平台主题 token避免亮色主题继续透出历史深色底。
const bubbleToneClass = isUser
? 'border border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-text-strong)]'
: isSystem
? 'border border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
: 'platform-subpanel text-[var(--platform-text-strong)]';
return (
<div
key={message.id || `message-${index}`}
className={`flex ${
isUser ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${bubbleToneClass}`}
>
<div className="whitespace-pre-wrap">{message.text}</div>
{!isUser &&
index === lastAssistantMessageIndex &&
visibleRecommendedReplies.length > 0 ? (
<div className="mt-2.5 flex flex-col gap-1.5">
{visibleRecommendedReplies.map((reply, replyIndex) => (
<button
key={`recommended-reply-${replyIndex}-${reply}`}
type="button"
onClick={() => onRecommendedReply?.(reply)}
className="platform-button platform-button--ghost min-h-0 justify-start rounded-[0.95rem] px-2.5 py-1.5 text-left text-[11px] leading-4.5 whitespace-normal"
>
{reply}
</button>
))}
</div>
) : null}
</div>
</div>
);
})}
{isStreamingReply ? (
<div className="flex justify-start">
<div className="platform-subpanel max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 text-[var(--platform-text-strong)] sm:max-w-[82%]">
{streamingReplyText ? (
<div className="whitespace-pre-wrap">
{streamingReplyText}
<span className="ml-1 inline-block h-4 w-1 animate-pulse rounded-full bg-[var(--platform-cool-text)] align-[-2px]" />
</div>
) : (
<div className="flex items-center gap-1.5 py-1">
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
</div>
)}
</div>
</div>
) : null}
<div ref={bottomRef} />
</div>
)}
</div>
);
}

View File

@@ -4,11 +4,17 @@ import type {
CustomWorldAgentSessionSnapshot,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldAgentComposer } from './CustomWorldAgentComposer';
import { CustomWorldAgentHeader } from './CustomWorldAgentHeader';
import { CustomWorldAgentOperationBanner } from './CustomWorldAgentOperationBanner';
import { CustomWorldAgentThread } from './CustomWorldAgentThread';
import { EightAnchorProgressBar } from './EightAnchorProgressBar';
import {
createCreationAgentClientMessageId,
isCreationAgentOperationBusy,
} from '../../services/creation-agent';
import {
type CreationAgentAnchorView,
type CreationAgentOperationView,
type CreationAgentSessionView,
type CreationAgentTheme,
CreationAgentWorkspace,
} from '../creation-agent';
type CustomWorldAgentWorkspaceProps = {
session: CustomWorldAgentSessionSnapshot | null;
@@ -20,15 +26,132 @@ type CustomWorldAgentWorkspaceProps = {
onExecuteAction: (payload: CustomWorldAgentActionRequest) => void;
};
function createClientMessageId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
const CUSTOM_WORLD_AGENT_THEME: CreationAgentTheme = {
accentTextClass: 'text-emerald-100/86',
accentBgClass: 'bg-emerald-300',
accentButtonClass: 'bg-emerald-200 shadow-emerald-950/20',
userBubbleClass:
'border border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-text-strong)]',
heroClass:
'border border-emerald-100/18 bg-[radial-gradient(circle_at_top_left,rgba(52,211,153,0.2),transparent_32%),linear-gradient(135deg,rgba(6,78,59,0.95),rgba(24,33,39,0.96))]',
anchorGridClass: 'grid gap-2 sm:grid-cols-2 xl:grid-cols-4',
};
function stringifyAnchorValue(value: unknown): string {
if (!value) {
return '';
}
return `client-message-${Date.now()}`;
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value
.map((item): string => stringifyAnchorValue(item))
.filter((item): item is string => Boolean(item))
.join(' / ');
}
if (typeof value !== 'object') {
return String(value);
}
return Object.values(value as Record<string, unknown>)
.map((item): string => stringifyAnchorValue(item))
.filter((item): item is string => Boolean(item))
.join(' / ');
}
function buildCustomWorldAnchor(
key: string,
label: string,
value: unknown,
): CreationAgentAnchorView {
const text = stringifyAnchorValue(value);
return {
key,
label,
value: text,
status: text ? 'confirmed' : 'missing',
};
}
function mapCustomWorldSession(
session: CustomWorldAgentSessionSnapshot,
): CreationAgentSessionView {
return {
sessionId: session.sessionId,
title: '世界共创',
assistantSummary:
session.lastAssistantReply ||
'先说一个你最想让玩家记住的世界方向,我会帮你收束成可生成草稿的锚点。',
currentTurn: session.currentTurn,
progressPercent: session.progressPercent,
anchors: [
buildCustomWorldAnchor(
'worldPromise',
'世界承诺',
session.anchorContent.worldPromise,
),
buildCustomWorldAnchor(
'playerFantasy',
'玩家幻想',
session.anchorContent.playerFantasy,
),
buildCustomWorldAnchor(
'themeBoundary',
'主题边界',
session.anchorContent.themeBoundary,
),
buildCustomWorldAnchor(
'playerEntryPoint',
'开局切入',
session.anchorContent.playerEntryPoint,
),
buildCustomWorldAnchor(
'coreConflict',
'核心冲突',
session.anchorContent.coreConflict,
),
buildCustomWorldAnchor(
'keyRelationships',
'关键关系',
session.anchorContent.keyRelationships,
),
buildCustomWorldAnchor(
'hiddenLines',
'暗线',
session.anchorContent.hiddenLines,
),
buildCustomWorldAnchor(
'iconicElements',
'标志元素',
session.anchorContent.iconicElements,
),
],
messages: session.messages,
recommendedReplies: session.recommendedReplies,
};
}
function mapCustomWorldOperation(
operation: CustomWorldAgentOperationRecord | null,
): CreationAgentOperationView | null {
if (!operation || operation.type === 'process_message') {
return null;
}
return {
operationId: operation.operationId,
type: operation.type,
status: operation.status,
phaseLabel: operation.phaseLabel,
phaseDetail: operation.phaseDetail,
progress: operation.progress,
error: operation.error,
};
}
export function CustomWorldAgentWorkspace({
@@ -40,22 +163,12 @@ export function CustomWorldAgentWorkspace({
onSubmitMessage,
onExecuteAction,
}: CustomWorldAgentWorkspaceProps) {
if (!session) {
return (
<div className="platform-remap-surface platform-subpanel mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] px-6 py-8 text-center text-sm text-[var(--platform-text-soft)]">
</div>
);
}
const isBusy =
activeOperation?.status === 'queued' ||
activeOperation?.status === 'running' ||
isStreamingReply;
isCreationAgentOperationBusy(activeOperation) || isStreamingReply;
const submitMessage = (text: string, quickFillRequested = false) => {
onSubmitMessage({
clientMessageId: createClientMessageId(),
clientMessageId: createCreationAgentClientMessageId('custom-world'),
text,
quickFillRequested,
focusCardId: null,
@@ -64,48 +177,44 @@ export function CustomWorldAgentWorkspace({
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<CustomWorldAgentHeader onBack={onBack} />
<EightAnchorProgressBar
currentTurn={session.currentTurn}
progressPercent={session.progressPercent}
disabled={isBusy}
onSummaryClick={() => {
submitMessage('请总结一下当前已经成形的世界设定。');
}}
onQuickFill={() => {
<CreationAgentWorkspace
session={session ? mapCustomWorldSession(session) : null}
theme={CUSTOM_WORLD_AGENT_THEME}
loadingText="正在恢复"
composerPlaceholder="输入消息"
primaryActionLabel="生成游戏设定草稿"
activeOperation={mapCustomWorldOperation(activeOperation)}
streamingReplyText={streamingReplyText}
isStreamingReply={isStreamingReply}
isBusy={isBusy}
quickActions={[
{
key: 'summarize',
label: '总结当前设定',
},
{
key: 'quickFill',
label: '补全剩余设定',
minTurn: 2,
},
]}
onBack={onBack}
onSubmitText={(text) => {
submitMessage(text);
}}
onPrimaryAction={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
onQuickAction={(action) => {
if (action.key === 'quickFill') {
submitMessage('请补全剩余设定。', true);
}}
onGenerateDraft={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
/>
return;
}
{activeOperation?.type !== 'process_message' ? (
<CustomWorldAgentOperationBanner operation={activeOperation} />
) : null}
<div className="min-h-0 flex-1 overflow-hidden">
<div className="h-full min-h-[18rem] lg:min-h-0">
<CustomWorldAgentThread
messages={session.messages}
recommendedReplies={session.recommendedReplies}
onRecommendedReply={(text) => {
submitMessage(text);
}}
streamingReplyText={streamingReplyText}
isStreamingReply={isStreamingReply}
/>
</div>
</div>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
/>
</div>
submitMessage('请总结一下当前已经成形的世界设定。');
}}
/>
);
}

View File

@@ -1,111 +0,0 @@
type EightAnchorProgressBarProps = {
currentTurn: number;
progressPercent: number;
disabled: boolean;
onSummaryClick: () => void;
onQuickFill: () => void;
onGenerateDraft: () => void;
};
function clampProgress(progressPercent: number) {
if (!Number.isFinite(progressPercent)) {
return 0;
}
return Math.max(0, Math.min(100, Math.round(progressPercent)));
}
function resolveProgressHint(progressPercent: number) {
if (progressPercent >= 100) {
return '当前设定已经收束完成,可以进入草稿生成';
}
if (progressPercent >= 75) {
return '正在收束成一版可进入草稿的世界底子';
}
if (progressPercent >= 45) {
return '世界方向已经成形,继续补关键骨架';
}
if (progressPercent >= 15) {
return '先把玩家视角、开局和冲突线钉稳';
}
return '先抓住这个世界最关键的方向';
}
export function EightAnchorProgressBar({
currentTurn,
progressPercent,
disabled,
onSummaryClick,
onQuickFill,
onGenerateDraft,
}: EightAnchorProgressBarProps) {
const normalizedProgress = clampProgress(progressPercent);
const isCompleted = normalizedProgress >= 100;
const canQuickFill = currentTurn >= 2;
const progressFillStyle = isCompleted
? { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' }
: { background: 'var(--platform-button-primary-fill)' };
return (
<div className="platform-remap-surface platform-subpanel rounded-[1.75rem] p-4">
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-text-base)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{resolveProgressHint(normalizedProgress)}
</div>
</div>
<div className="text-lg font-semibold text-[var(--platform-text-strong)]">
{normalizedProgress}%
</div>
</div>
<div className="platform-progress-track h-3 overflow-hidden rounded-full">
<div
className="h-full rounded-full transition-[width] duration-500"
style={{
width: `${Math.max(6, normalizedProgress)}%`,
...progressFillStyle,
}}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<button
type="button"
onClick={onSummaryClick}
disabled={disabled}
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
{isCompleted ? (
<button
type="button"
onClick={onGenerateDraft}
disabled={disabled}
className="platform-button platform-button--primary min-h-[3rem] rounded-[1.1rem] px-4 py-3 text-sm disabled:cursor-not-allowed disabled:opacity-45"
>
稿
</button>
) : canQuickFill ? (
<button
type="button"
onClick={onQuickFill}
disabled={disabled}
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -72,3 +72,42 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.getByText('角色 5')).toBeTruthy();
expect(screen.getByText('地点 6')).toBeTruthy();
});
test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => {
render(
<CustomWorldCreationHub
items={[baseDraftItem]}
puzzleItems={[
{
workId: 'puzzle:work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '沉钟拼图',
summary: '拼图作品会与其他创作作品一起展示。',
themeTags: ['潮雾', '沉钟'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
publishReady: true,
},
]}
loading={false}
error={null}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('沉钟拼图')).toBeTruthy();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('我的拼图作品')).toBeNull();
expect(screen.queryByText('拼图玩法')).toBeNull();
});

View File

@@ -42,3 +42,41 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
});
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '潮雾拼图',
summary: '一张被切成拼图的潮雾港口主视觉。',
themeTags: ['潮雾', '港口'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T10:05:00.000Z').toISOString(),
playCount: 12,
publishReady: true,
},
]}
loading={false}
error={null}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(html).toContain('潮雾拼图');
expect(html).toContain('拼图');
expect(html).not.toContain('我的拼图作品');
expect(html).not.toContain('拼图玩法');
});

View File

@@ -1,8 +1,12 @@
import { useMemo, useState } from 'react';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
CustomWorldWorkCard,
type UnifiedCreationWorkItem,
} from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
CustomWorldWorkTabs,
@@ -17,6 +21,8 @@ type CustomWorldCreationHubProps = {
onCreateNew: () => void;
onOpenDraft: (item: CustomWorldWorkSummary) => void;
onEnterPublished: (profileId: string) => void;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (profileId: string) => void;
};
function EmptyState({ title }: { title: string }) {
@@ -38,19 +44,38 @@ export function CustomWorldCreationHub({
onCreateNew,
onOpenDraft,
onEnterPublished,
puzzleItems = [],
onOpenPuzzleDetail,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const draftCount = items.filter((item) => item.status === 'draft').length;
const publishedCount = items.filter(
(item) => item.status === 'published',
const unifiedItems = useMemo<UnifiedCreationWorkItem[]>(
() => [
...items.map((item) => ({ kind: 'rpg', item }) as const),
...puzzleItems.map((item) => ({ kind: 'puzzle', item }) as const),
],
[items, puzzleItems],
);
const draftCount = unifiedItems.filter((entry) =>
entry.kind === 'puzzle'
? entry.item.publicationStatus === 'draft'
: entry.item.status === 'draft',
).length;
const publishedCount = unifiedItems.filter((entry) =>
entry.kind === 'puzzle'
? entry.item.publicationStatus === 'published'
: entry.item.status === 'published',
).length;
const filteredItems = useMemo(
() =>
items.filter((item) =>
activeFilter === 'all' ? true : item.status === activeFilter,
unifiedItems.filter((entry) =>
activeFilter === 'all'
? true
: entry.kind === 'puzzle'
? entry.item.publicationStatus === activeFilter
: entry.item.status === activeFilter,
),
[activeFilter, items],
[activeFilter, unifiedItems],
);
return (
@@ -125,22 +150,30 @@ export function CustomWorldCreationHub({
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={item.workId}
key={`${item.kind}-${item.item.workId}`}
item={item}
onClick={() => {
if (item.sourceType === 'agent_session' && item.sessionId) {
onOpenDraft(item);
if (item.kind === 'puzzle') {
onOpenPuzzleDetail?.(item.item.profileId);
return;
}
if (item.profileId) {
onEnterPublished(item.profileId);
if (
item.item.sourceType === 'agent_session' &&
item.item.sessionId
) {
onOpenDraft(item.item);
return;
}
if (item.item.profileId) {
onEnterPublished(item.item.profileId);
}
}}
/>
))}
</div>
) : items.length === 0 ? (
) : unifiedItems.length === 0 ? (
<EmptyState title="还没有作品" />
) : (
<EmptyState title="当前筛选下没有内容" />

View File

@@ -1,4 +1,5 @@
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
function formatUpdatedAt(value: string) {
@@ -15,8 +16,18 @@ function formatUpdatedAt(value: string) {
}).format(date);
}
export type UnifiedCreationWorkItem =
| {
kind: 'rpg';
item: CustomWorldWorkSummary;
}
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
};
type CustomWorldWorkCardProps = {
item: CustomWorldWorkSummary;
item: UnifiedCreationWorkItem;
onClick: () => void;
};
@@ -24,24 +35,36 @@ export function CustomWorldWorkCard({
item,
onClick,
}: CustomWorldWorkCardProps) {
const isDraft = item.status === 'draft';
const hasFoundationDraft =
item.playableNpcCount > 0 || item.landmarkCount > 0;
const actionLabel = isDraft
? hasFoundationDraft
? '继续完善'
: '继续创作'
: '进入世界';
const roleCountLabel = isDraft ? '角色' : '可扮演角色';
const isPuzzle = item.kind === 'puzzle';
const isDraft =
item.kind === 'puzzle'
? item.item.publicationStatus === 'draft'
: item.item.status === 'draft';
const actionLabel = isPuzzle
? '查看详情'
: isDraft
? item.item.playableNpcCount > 0 || item.item.landmarkCount > 0
? '继续完善'
: '继续创作'
: '进入世界';
const title = isPuzzle ? item.item.levelName : item.item.title;
const subtitle = isPuzzle ? item.item.authorDisplayName : item.item.subtitle;
const summary = item.item.summary;
const updatedAt = item.item.updatedAt;
const coverImageSrc = item.item.coverImageSrc ?? null;
const coverRenderMode =
item.kind === 'rpg' ? item.item.coverRenderMode : 'image';
const coverCharacterImageSrcs =
item.kind === 'rpg' ? item.item.coverCharacterImageSrcs : [];
return (
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem]">
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
title={item.title}
imageSrc={coverImageSrc}
title={title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
renderMode={coverRenderMode}
characterImageSrcs={coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
@@ -57,52 +80,78 @@ export function CustomWorldWorkCard({
>
{isDraft ? '草稿' : '已发布'}
</span>
{item.stageLabel ? (
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{isPuzzle ? '拼图' : 'RPG'}
</span>
{!isPuzzle && item.item.stageLabel ? (
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.stageLabel}
{item.item.stageLabel}
</span>
) : null}
{isPuzzle
? item.item.themeTags.slice(0, 2).map((tag) => (
<span
key={`${item.item.profileId}-${tag}`}
className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]"
>
{tag}
</span>
))
: null}
</div>
<div className="shrink-0 text-[11px] text-[var(--platform-text-soft)]">
{formatUpdatedAt(item.updatedAt)}
{formatUpdatedAt(updatedAt)}
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
{item.title}
{title}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
{item.subtitle}
{subtitle}
</div>
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)]">
{item.summary}
{summary}
</div>
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-2">
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{roleCountLabel} {item.playableNpcCount}
</span>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.landmarkCount}
</span>
{item.roleVisualReadyCount ? (
<span className="platform-pill platform-pill--warm px-3 py-1 text-[10px]">
{item.roleVisualReadyCount}
</span>
) : null}
{item.roleAnimationReadyCount ? (
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
{item.roleAnimationReadyCount}
</span>
) : null}
{item.roleAssetSummaryLabel ? (
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.roleAssetSummaryLabel}
</span>
) : null}
{isPuzzle ? (
<>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.authorDisplayName}
</span>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.playCount}
</span>
</>
) : (
<>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{isDraft ? '角色' : '可扮演角色'} {item.item.playableNpcCount}
</span>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.landmarkCount}
</span>
{item.item.roleVisualReadyCount ? (
<span className="platform-pill platform-pill--warm px-3 py-1 text-[10px]">
{item.item.roleVisualReadyCount}
</span>
) : null}
{item.item.roleAnimationReadyCount ? (
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
{item.item.roleAnimationReadyCount}
</span>
) : null}
{item.item.roleAssetSummaryLabel ? (
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.roleAssetSummaryLabel}
</span>
) : null}
</>
)}
</div>
<button
type="button"

View File

@@ -0,0 +1,180 @@
import { ArrowRight, X } from 'lucide-react';
export interface PlatformEntryCreationTypeModalProps {
isOpen: boolean;
isBusy: boolean;
error: string | null;
onClose: () => void;
onSelectRpg: () => void;
onSelectBigFish: () => void;
onSelectPuzzle: () => void;
}
type CreationGameTypeCard = {
id: 'rpg' | 'big-fish' | 'puzzle' | 'airp' | 'visual-novel';
title: string;
subtitle: string;
badge: string;
locked: boolean;
};
const CREATION_GAME_TYPES: CreationGameTypeCard[] = [
{
id: 'rpg',
title: '角色扮演 RPG',
subtitle: 'Agent 共创',
badge: '可创建',
locked: false,
},
{
id: 'big-fish',
title: '大鱼吃小鱼',
subtitle: '实时成长玩法',
badge: '可创建',
locked: false,
},
{
id: 'puzzle',
title: '拼图玩法',
subtitle: '图像锚点共创',
badge: '可创建',
locked: false,
},
{
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '锁定',
locked: true,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '敬请期待',
badge: '锁定',
locked: true,
},
];
function CreationTypeCard(props: {
item: CreationGameTypeCard;
busy: boolean;
onSelect: () => void;
}) {
const { item, busy, onSelect } = props;
const disabled = item.locked || busy;
return (
<button
type="button"
disabled={disabled}
onClick={onSelect}
className={`platform-interactive-card relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
item.locked
? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]'
: 'border-[var(--platform-cool-border)] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_34%),linear-gradient(135deg,rgba(255,79,139,0.96),rgba(255,145,110,0.9))] text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex items-start justify-between gap-3">
<span
className={`platform-pill px-3 ${
item.locked
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
}`}
>
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
</span>
{item.locked ? (
<span className="text-lg leading-none text-white/45">·</span>
) : (
<ArrowRight className="h-4 w-4 text-white/80" />
)}
</div>
<div className="mt-8 text-xl font-black leading-tight text-inherit">
{item.title}
</div>
<div
className={`mt-2 text-sm ${
item.locked ? 'text-zinc-500' : 'text-zinc-200/82'
}`}
>
{item.subtitle}
</div>
</button>
);
}
/**
* 平台入口创作类型弹层。
* 多玩法入口统一在这里分流,避免把非 RPG 玩法写进 RPG 命名脚本。
*/
export function PlatformEntryCreationTypeModal({
isOpen,
isBusy,
error,
onClose,
onSelectRpg,
onSelectBigFish,
onSelectPuzzle,
}: PlatformEntryCreationTypeModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
<div className="bg-transparent">
<div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 py-4 sm:px-5 sm:py-5">
<div className="grid gap-3 sm:grid-cols-5">
{CREATION_GAME_TYPES.map((item) => (
<CreationTypeCard
key={item.id}
item={item}
busy={isBusy}
onSelect={() => {
if (item.id === 'rpg') {
onSelectRpg();
}
if (item.id === 'big-fish') {
onSelectBigFish();
}
if (item.id === 'puzzle') {
onSelectPuzzle();
}
}}
/>
))}
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { PlatformEntryFlowShellImpl } from './PlatformEntryFlowShellImpl';
import type {
PlatformEntryFlowShellProps,
SelectionStage,
} from './platformEntryTypes';
export type { PlatformEntryFlowShellProps, SelectionStage };
/**
* 平台入口通用壳层。
* RPG、Big Fish 等玩法创作入口在这里并列分流。
*/
export function PlatformEntryFlowShell(props: PlatformEntryFlowShellProps) {
return <PlatformEntryFlowShellImpl {...props} />;
}
export default PlatformEntryFlowShell;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
/**
* 平台首页视图的通用出口。
* 当前先复用成熟的首页实现,但避免让 `platform-entry` 继续直接依赖 RPG 命名组件。
*/
export {
RpgEntryHomeView as PlatformEntryHomeView,
type RpgEntryHomeViewProps as PlatformEntryHomeViewProps,
type PlatformHomeTab,
} from '../rpg-entry/RpgEntryHomeView';

View File

@@ -0,0 +1,8 @@
/**
* 平台作品详情视图的通用出口。
* 这里保留平台语义封装,减少 Big Fish 继续挂在 RPG 命名路径上的误导。
*/
export {
RpgEntryWorldDetailView as PlatformEntryWorldDetailView,
type RpgEntryWorldDetailViewProps as PlatformEntryWorldDetailViewProps,
} from '../rpg-entry/RpgEntryWorldDetailView';

View File

@@ -0,0 +1,9 @@
export {
PlatformEntryFlowShell,
type PlatformEntryFlowShellProps,
type SelectionStage,
} from './PlatformEntryFlowShell';
export {
PlatformEntryCreationTypeModal,
type PlatformEntryCreationTypeModalProps,
} from './PlatformEntryCreationTypeModal';

View File

@@ -0,0 +1,9 @@
/**
* 平台入口共享 helper 的通用封装层。
* 先复用既有实现,同时把多玩法入口依赖从 RPG 命名中隔离出来。
*/
export {
buildCreationHubFallbackItems,
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
} from '../rpg-entry/rpgEntryShared';

View File

@@ -0,0 +1,41 @@
import type {
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile, GameState } from '../../types';
export type SelectionStage =
| 'platform'
| 'detail'
| 'agent-workspace'
| 'big-fish-agent-workspace'
| 'big-fish-result'
| 'big-fish-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-result'
| 'puzzle-gallery-detail'
| 'puzzle-runtime'
| 'custom-world-generating'
| 'custom-world-result';
export type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
export type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null;
export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
export type SyncedAgentDraftResult = {
session: CustomWorldAgentSessionSnapshot | null;
profile: CustomWorldProfile | null;
};
export type PlatformEntryFlowShellProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
gameState: GameState;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
};

View File

@@ -0,0 +1,5 @@
/**
* 平台入口 bootstrap 通用封装。
* 现阶段逻辑仍复用既有实现,但对外暴露平台语义命名。
*/
export { useRpgEntryBootstrap as usePlatformEntryBootstrap } from '../rpg-entry/useRpgEntryBootstrap';

View File

@@ -0,0 +1,5 @@
/**
* 平台入口详情态编排通用封装。
* 通过平台语义出口避免 Big Fish 直接依赖 RPG 命名 hook。
*/
export { useRpgEntryLibraryDetail as usePlatformEntryLibraryDetail } from '../rpg-entry/useRpgEntryLibraryDetail';

View File

@@ -0,0 +1,5 @@
/**
* 平台入口导航通用封装。
* 多玩法统一从 `platform-entry` 暴露RPG 目录只保留兼容与 RPG 专属能力。
*/
export { useRpgEntryNavigation as usePlatformEntryNavigation } from '../rpg-entry/useRpgEntryNavigation';

View File

@@ -0,0 +1,118 @@
import type {
PuzzleAgentActionRequest,
PuzzleAgentOperationRecord,
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { createCreationAgentClientMessageId } from '../../services/creation-agent';
import {
type CreationAgentOperationView,
type CreationAgentSessionView,
type CreationAgentTheme,
CreationAgentWorkspace,
} from '../creation-agent';
type PuzzleAgentWorkspaceProps = {
session: PuzzleAgentSessionSnapshot | null;
activeOperation?: PuzzleAgentOperationRecord | null;
streamingReplyText?: string;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitMessage: (payload: SendPuzzleAgentMessageRequest) => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
};
const PUZZLE_AGENT_THEME: CreationAgentTheme = {
accentTextClass: 'text-amber-100/84',
accentBgClass: 'bg-amber-200',
accentButtonClass: 'bg-amber-200 shadow-amber-950/20',
userBubbleClass: 'bg-amber-600 text-white',
heroClass:
'border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.96),rgba(20,24,35,0.96))]',
anchorGridClass: 'grid gap-2 sm:grid-cols-2 xl:grid-cols-5',
};
function mapPuzzleSession(
session: PuzzleAgentSessionSnapshot,
): CreationAgentSessionView {
return {
sessionId: session.sessionId,
title: '拼图玩法共创',
assistantSummary:
session.lastAssistantReply ||
'先说一个你最想让玩家一眼记住的画面,我会帮你收束成拼图关卡。',
currentTurn: session.currentTurn,
progressPercent: session.progressPercent,
anchors: [
session.anchorPack.themePromise,
session.anchorPack.visualSubject,
session.anchorPack.visualMood,
session.anchorPack.compositionHooks,
session.anchorPack.tagsAndForbidden,
],
messages: session.messages,
recommendedReplies: [],
};
}
function mapPuzzleOperation(
operation: PuzzleAgentOperationRecord | null | undefined,
): CreationAgentOperationView | null {
if (!operation) {
return null;
}
return {
operationId: operation.operationId,
type: operation.type,
status: operation.status,
phaseLabel: operation.phaseLabel,
phaseDetail: operation.phaseDetail,
progress: operation.progress,
error: operation.error,
};
}
/**
* 拼图 Agent 共创工作区只保留品类适配,聊天 UI 与进度管理统一走 CreationAgentWorkspace。
*/
export function PuzzleAgentWorkspace({
session,
activeOperation = null,
streamingReplyText = '',
isBusy = false,
error = null,
onBack,
onSubmitMessage,
onExecuteAction,
}: PuzzleAgentWorkspaceProps) {
return (
<CreationAgentWorkspace
session={session ? mapPuzzleSession(session) : null}
theme={PUZZLE_AGENT_THEME}
loadingText="正在准备拼图共创工作区..."
composerPlaceholder="说说题材、主体、气质或你不希望出现的元素..."
primaryActionLabel="生成结果页"
activeOperation={mapPuzzleOperation(activeOperation)}
streamingReplyText={streamingReplyText}
isStreamingReply={Boolean(streamingReplyText)}
isBusy={isBusy}
error={error}
onBack={onBack}
onSubmitText={(text) => {
onSubmitMessage({
clientMessageId: createCreationAgentClientMessageId('puzzle'),
text,
});
}}
onPrimaryAction={() => {
onExecuteAction({ action: 'compile_puzzle_draft' });
}}
/>
);
}
export default PuzzleAgentWorkspace;

View File

@@ -0,0 +1,115 @@
import { ArrowLeft, Play, UserRound } from 'lucide-react';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleGalleryDetailViewProps = {
item: PuzzleWorkSummary;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onStartGame: () => void;
};
/**
* 拼图广场详情页。
* 展示最小信息并提供进入游戏动作,不扩展评论、收藏等非本轮需求。
*/
export function PuzzleGalleryDetailView({
item,
isBusy = false,
error = null,
onBack,
onStartGame,
}: PuzzleGalleryDetailViewProps) {
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div className="relative overflow-hidden rounded-[1.8rem] border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<button
type="button"
onClick={onBack}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
disabled={isBusy}
onClick={onStartGame}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<Play className="h-4 w-4" />
1
</button>
</div>
<div className="mt-6">
<div className="text-2xl font-black leading-tight sm:text-3xl">
{item.levelName}
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-amber-50/82">
<span className="inline-flex items-center gap-2">
<UserRound className="h-4 w-4" />
{item.authorDisplayName}
</span>
<span>{item.playCount} </span>
</div>
</div>
</div>
{error ? (
<div className="rounded-[1.15rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
{error}
</div>
) : null}
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1.05fr)_minmax(18rem,0.95fr)]">
<section className="min-h-0 overflow-hidden rounded-[1.5rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
<div className="aspect-square overflow-hidden">
{item.coverImageSrc ? (
<ResolvedAssetImage
src={item.coverImageSrc}
alt={item.levelName}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))] text-sm text-white/66">
</div>
)}
</div>
</section>
<aside className="space-y-3 overflow-y-auto">
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-3 flex flex-wrap gap-2">
{item.themeTags.map((tag) => (
<span
key={tag}
className="rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1 text-xs font-semibold text-amber-700"
>
{tag}
</span>
))}
</div>
</div>
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-3 text-sm leading-7 text-[var(--platform-text-base)]">
{item.summary}
</div>
</div>
</aside>
</div>
</div>
);
}
export default PuzzleGalleryDetailView;

View File

@@ -0,0 +1,475 @@
import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Loader2,
Sparkles,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { AuthUser } from '../../services/authService';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleResultViewProps = {
session: PuzzleAgentSessionSnapshot;
author: AuthUser | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
};
type PuzzleImageStudioModalProps = {
draft: PuzzleResultDraft;
isBusy: boolean;
onClose: () => void;
onGenerate: (promptText?: string | null) => void;
onSelectCandidate: (candidateId: string) => void;
};
function normalizeThemeTagInput(value: string) {
return value
.split(/[\n,]/u)
.map((entry) => entry.trim())
.filter(Boolean);
}
function publishBlockedReason(session: PuzzleAgentSessionSnapshot) {
if (!session.resultPreview) {
return [];
}
return session.resultPreview.blockers.map((entry) => entry.message);
}
function PuzzleImageStudioModal({
draft,
isBusy,
onClose,
onGenerate,
onSelectCandidate,
}: PuzzleImageStudioModalProps) {
const [promptText, setPromptText] = useState(draft.summary);
return (
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div className="platform-modal-shell w-full max-w-5xl overflow-hidden rounded-[1.8rem]">
<div className="border-b border-[var(--platform-subpanel-border)] px-4 py-4">
<div className="text-lg font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<div className="grid gap-4 px-4 py-4 lg:grid-cols-[20rem_minmax(0,1fr)]">
<div className="space-y-4">
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
PROMPT
</div>
<textarea
value={promptText}
disabled={isBusy}
rows={7}
onChange={(event) => {
setPromptText(event.target.value);
}}
className="mt-3 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
<button
type="button"
disabled={isBusy}
onClick={() => {
onGenerate(promptText.trim() || undefined);
}}
className="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-2.5 text-sm font-bold text-white disabled:opacity-45"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
2
</button>
</div>
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 aspect-square overflow-hidden rounded-[1.2rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
{draft.coverImageSrc ? (
<ResolvedAssetImage
src={draft.coverImageSrc}
alt={draft.levelName}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-white/66">
</div>
)}
</div>
</div>
</div>
<div className="min-h-0">
{draft.candidates.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{draft.candidates.map((candidate) => (
<button
key={candidate.candidateId}
type="button"
disabled={isBusy}
onClick={() => {
onSelectCandidate(candidate.candidateId);
}}
className={`overflow-hidden rounded-[1.35rem] border text-left transition ${
candidate.selected
? 'border-amber-500 bg-amber-50 shadow-[0_18px_45px_rgba(180,83,9,0.14)]'
: 'border-[var(--platform-subpanel-border)] bg-white/78'
}`}
>
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={candidate.imageSrc}
alt={draft.levelName}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-2 px-4 py-4">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{candidate.candidateId.split('-').pop()}
</div>
{candidate.selected ? (
<span className="rounded-full bg-amber-600 px-2.5 py-1 text-[0.68rem] font-semibold text-white">
</span>
) : null}
</div>
<div className="line-clamp-3 text-xs leading-5 text-[var(--platform-text-base)]">
{candidate.actualPrompt || candidate.prompt}
</div>
</div>
</button>
))}
</div>
) : (
<div className="flex h-full min-h-[22rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-4">
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-[var(--platform-subpanel-border)] px-4 py-2 text-sm font-semibold text-[var(--platform-text-base)] disabled:opacity-45"
>
</button>
</div>
</div>
</div>
);
}
/**
* 拼图结果页最小工作台。
* 支持标题、摘要、标签编辑,候选图生成与发布,不额外扩成大表单系统。
*/
export function PuzzleResultView({
session,
author,
isBusy = false,
error = null,
onBack,
onExecuteAction,
}: PuzzleResultViewProps) {
const draft = session.draft;
const preview = session.resultPreview;
const [isStudioOpen, setIsStudioOpen] = useState(false);
const [levelName, setLevelName] = useState(draft?.levelName ?? '');
const [summary, setSummary] = useState(draft?.summary ?? '');
const [themeTagsText, setThemeTagsText] = useState(
draft?.themeTags.join('') ?? '',
);
useEffect(() => {
if (!draft) {
return;
}
setLevelName(draft.levelName);
setSummary(draft.summary);
setThemeTagsText(draft.themeTags.join(''));
}, [draft]);
const tagList = useMemo(
() => normalizeThemeTagInput(themeTagsText),
[themeTagsText],
);
const blockers = useMemo(() => publishBlockedReason(session), [session]);
const qualityFindings = preview?.qualityFindings ?? [];
const publishReady = Boolean(preview?.publishReady);
if (!draft) {
return (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
稿
</div>
</div>
);
}
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div className="relative overflow-hidden rounded-[1.8rem] border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex gap-2">
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsStudioOpen(true);
}}
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
>
<ImagePlus className="h-4 w-4" />
</button>
<button
type="button"
disabled={isBusy || !levelName.trim()}
onClick={() => {
onExecuteAction({
action: 'publish_puzzle_work',
levelName: levelName.trim(),
summary: summary.trim(),
themeTags: tagList,
});
}}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<CheckCircle2 className="h-4 w-4" />
广
</button>
</div>
</div>
<div className="mt-6">
<div className="text-2xl font-black leading-tight sm:text-3xl">
</div>
<div className="mt-2 max-w-2xl text-sm leading-6 text-amber-50/76">
广
</div>
</div>
</div>
{error ? (
<div className="rounded-[1.15rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
{error}
</div>
) : null}
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1fr)_22rem]">
<div className="min-h-0 overflow-y-auto pr-1">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<section className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 aspect-square overflow-hidden rounded-[1.35rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
{draft.coverImageSrc ? (
<ResolvedAssetImage
src={draft.coverImageSrc}
alt={levelName || draft.levelName}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-white/66">
</div>
)}
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsStudioOpen(true);
}}
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-2.5 text-sm font-bold text-white disabled:opacity-45"
>
<Sparkles className="h-4 w-4" />
</button>
</section>
<section className="space-y-3 rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<div>
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={levelName}
disabled={isBusy}
onChange={(event) => {
setLevelName(event.target.value);
}}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</div>
<div>
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
Agent
</div>
<textarea
value={summary}
disabled={isBusy}
rows={5}
onChange={(event) => {
setSummary(event.target.value);
}}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</div>
<div>
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={themeTagsText}
disabled={isBusy}
onChange={(event) => {
setThemeTagsText(event.target.value);
}}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm text-[var(--platform-text-strong)] outline-none"
/>
<div className="mt-3 flex flex-wrap gap-2">
{tagList.length > 0 ? (
tagList.map((tag) => (
<span
key={tag}
className="rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1 text-xs font-semibold text-amber-700"
>
{tag}
</span>
))
) : (
<span className="text-xs text-[var(--platform-text-soft)]">
3 6 使
</span>
)}
</div>
</div>
</section>
</div>
</div>
<aside className="min-h-0 space-y-3 overflow-y-auto">
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-3 rounded-[1.1rem] bg-white/76 px-4 py-4">
<div className="text-lg font-black text-[var(--platform-text-strong)]">
{author?.displayName || '玩家'}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
HUD
</div>
</div>
</div>
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
{publishReady ? (
<div className="mt-3 rounded-[1.1rem] bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
</div>
) : (
<div className="mt-3 space-y-2">
{blockers.length > 0 ? (
blockers.map((message) => (
<div
key={message}
className="rounded-[1rem] bg-amber-50 px-3 py-2 text-sm text-amber-700"
>
{message}
</div>
))
) : (
<div className="rounded-[1rem] bg-white/76 px-3 py-2 text-sm text-[var(--platform-text-base)]">
</div>
)}
</div>
)}
</div>
{qualityFindings.length > 0 ? (
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-3 space-y-2">
{qualityFindings.map((finding) => (
<div
key={finding.id}
className="rounded-[1rem] bg-white/76 px-3 py-2 text-sm text-[var(--platform-text-base)]"
>
{finding.message}
</div>
))}
</div>
</div>
) : null}
</aside>
</div>
{isStudioOpen ? (
<PuzzleImageStudioModal
draft={draft}
isBusy={isBusy}
onClose={() => {
setIsStudioOpen(false);
}}
onGenerate={(promptText) => {
onExecuteAction({
action: 'generate_puzzle_images',
promptText,
candidateCount: 2,
});
}}
onSelectCandidate={(candidateId) => {
onExecuteAction({
action: 'select_puzzle_image',
candidateId,
});
}}
/>
) : null}
</div>
);
}
export default PuzzleResultView;

View File

@@ -0,0 +1,397 @@
import { ArrowLeft, ArrowRight, Loader2 } from 'lucide-react';
import { useMemo, useRef, useState } from 'react';
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleRunSnapshot,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleRuntimeShellProps = {
run: PuzzleRunSnapshot | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
onAdvanceNextLevel: () => void;
};
type PuzzleBoardPieceViewModel = {
pieceId: string;
row: number;
col: number;
correctRow: number;
correctCol: number;
label: string;
};
function boardCellKey(position: PuzzleCellPosition) {
return `${position.row}:${position.col}`;
}
function buildBoardCells(board: PuzzleBoardSnapshot) {
return Array.from({ length: board.rows * board.cols }, (_, index) => ({
row: Math.floor(index / board.cols),
col: index % board.cols,
}));
}
function buildPieceLabel(pieceId: string) {
const fallback = pieceId.slice(-2).toUpperCase();
return fallback || '块';
}
/**
* 拼图运行时壳层。
* 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。
*/
export function PuzzleRuntimeShell({
run,
isBusy = false,
error = null,
onBack,
onSwapPieces,
onDragPiece,
onAdvanceNextLevel,
}: PuzzleRuntimeShellProps) {
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
const [dragState, setDragState] = useState<{
pieceId: string;
pointerId: number;
dragging: boolean;
startX: number;
startY: number;
} | null>(null);
const boardRef = useRef<HTMLDivElement | null>(null);
const currentLevel = run?.currentLevel ?? null;
const board = currentLevel?.board ?? null;
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
currentLevel?.coverImageSrc ?? null,
);
const pieces = useMemo<PuzzleBoardPieceViewModel[]>(() => {
if (!board) {
return [];
}
return board.pieces.map((piece) => ({
pieceId: piece.pieceId,
row: piece.currentRow,
col: piece.currentCol,
correctRow: piece.correctRow,
correctCol: piece.correctCol,
label: buildPieceLabel(piece.pieceId),
}));
}, [board]);
const mergedCellKeys = useMemo(() => {
if (!board) {
return new Set<string>();
}
return new Set(
board.mergedGroups.flatMap((group) =>
group.occupiedCells.map((cell) => boardCellKey(cell)),
),
);
}, [board]);
const pieceByCell = useMemo(() => {
const map = new Map<string, PuzzleBoardPieceViewModel>();
for (const piece of pieces) {
map.set(`${piece.row}:${piece.col}`, piece);
}
return map;
}, [pieces]);
if (!run || !currentLevel || !board) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950 text-white">
<div className="flex items-center gap-2 rounded-full bg-white/10 px-5 py-3 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</div>
);
}
const handlePieceClick = (pieceId: string) => {
if (isBusy) {
return;
}
if (!selectedPieceId) {
setSelectedPieceId(pieceId);
return;
}
if (selectedPieceId === pieceId) {
setSelectedPieceId(null);
return;
}
onSwapPieces({
firstPieceId: selectedPieceId,
secondPieceId: pieceId,
});
setSelectedPieceId(null);
};
const resolveBoardCellFromPointer = (clientX: number, clientY: number) => {
const boardElement = boardRef.current;
if (!boardElement) {
return null;
}
const rect = boardElement.getBoundingClientRect();
if (
clientX < rect.left ||
clientX > rect.right ||
clientY < rect.top ||
clientY > rect.bottom
) {
return null;
}
const relativeX = clientX - rect.left;
const relativeY = clientY - rect.top;
const col = Math.min(
board.cols - 1,
Math.max(0, Math.floor((relativeX / rect.width) * board.cols)),
);
const row = Math.min(
board.rows - 1,
Math.max(0, Math.floor((relativeY / rect.height) * board.rows)),
);
return { row, col };
};
const handlePiecePointerUp = (
pieceId: string,
event: React.PointerEvent<HTMLDivElement>,
) => {
const currentDragState = dragState;
if (!currentDragState || currentDragState.pieceId !== pieceId) {
return;
}
event.currentTarget.releasePointerCapture(event.pointerId);
if (currentDragState.dragging) {
const targetCell = resolveBoardCellFromPointer(
event.clientX,
event.clientY,
);
if (targetCell) {
onDragPiece({
pieceId,
targetRow: targetCell.row,
targetCol: targetCell.col,
});
}
setSelectedPieceId(null);
setDragState(null);
return;
}
setDragState(null);
handlePieceClick(pieceId);
};
const statusLabel =
currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`;
const nextAvailable =
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
return (
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
<div className="relative h-full w-full overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(251,191,36,0.18),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(249,115,22,0.16),transparent_26%),linear-gradient(180deg,#2d160e,#020617)]">
{currentLevel.coverImageSrc ? (
<ResolvedAssetImage
src={currentLevel.coverImageSrc}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover opacity-[0.16] blur-2xl"
/>
) : null}
<div className="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:34px_34px] opacity-20" />
<div className="absolute left-0 top-0 z-20 flex w-full items-start justify-between gap-3 px-4 py-4">
<button
type="button"
onClick={onBack}
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex max-w-[70vw] flex-col items-end gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-right backdrop-blur">
<div className="text-[0.68rem] font-semibold tracking-[0.2em] text-white/70">
PUZZLE
</div>
<div className="line-clamp-1 text-sm font-bold text-white">
{currentLevel.levelName}
</div>
<div className="text-xs text-white/74">
{currentLevel.authorDisplayName} · {currentLevel.levelIndex} ·{' '}
{statusLabel}
</div>
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center p-4 pt-24 pb-28">
<div
ref={boardRef}
className="grid aspect-square w-full max-w-[min(92vw,92vh)] rounded-[1.7rem] border border-white/12 bg-white/8 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm"
style={{
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
}}
>
{buildBoardCells(board).map((cell) => {
const piece = pieceByCell.get(`${cell.row}:${cell.col}`) ?? null;
const occupied = Boolean(piece);
const isMerged = mergedCellKeys.has(boardCellKey(cell));
const isSelected = piece?.pieceId === selectedPieceId;
return (
<div
key={`${cell.row}:${cell.col}`}
className="relative p-1"
>
<div
className={`flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
occupied
? isSelected
? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]'
: isMerged
? 'border-emerald-200/55 bg-emerald-300/26 text-white'
: 'border-white/18 bg-white/12 text-white'
: 'border-white/8 bg-black/18 text-white/20'
}`}
onPointerDown={(event) => {
if (!piece || isBusy) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
setDragState({
pieceId: piece.pieceId,
pointerId: event.pointerId,
dragging: false,
startX: event.clientX,
startY: event.clientY,
});
}}
onPointerMove={(event) => {
if (
!piece ||
!dragState ||
dragState.pieceId !== piece.pieceId ||
dragState.pointerId !== event.pointerId ||
dragState.dragging
) {
return;
}
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
if (Math.hypot(deltaX, deltaY) >= 8) {
setDragState((current) =>
current && current.pieceId === piece.pieceId
? {
...current,
dragging: true,
}
: current,
);
}
}}
onPointerUp={(event) => {
if (piece) {
handlePiecePointerUp(piece.pieceId, event);
}
}}
onPointerCancel={() => {
setDragState(null);
}}
>
{piece ? (
<div className="relative h-full w-full overflow-hidden rounded-[0.92rem]">
{resolvedCoverImage ? (
<div
className="absolute inset-0"
style={{
backgroundImage: `url("${resolvedCoverImage}")`,
backgroundSize: `${board.cols * 100}% ${board.rows * 100}%`,
backgroundPosition: `${
board.cols > 1
? (piece.correctCol / (board.cols - 1)) * 100
: 0
}% ${
board.rows > 1
? (piece.correctRow / (board.rows - 1)) * 100
: 0
}%`,
}}
/>
) : (
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
)}
<div className="absolute inset-0 bg-black/10" />
<div className="absolute bottom-1 right-1 rounded-full bg-black/38 px-1.5 py-0.5 text-[10px] font-black text-white/86">
{piece.label}
</div>
</div>
) : (
''
)}
</div>
</div>
);
})}
</div>
</div>
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-4 py-4">
<div className="max-w-[18rem] rounded-[1.1rem] bg-black/28 px-4 py-3 text-xs leading-6 text-white/74 backdrop-blur">
{selectedPieceId
? '已选择一块,再点另一块可交换;也可以直接拖到目标位置。'
: '点击两块可交换,拖动单块或合并块到目标格继续推进。'}
</div>
<div className="flex flex-col items-end gap-2">
{error ? (
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
{error}
</div>
) : null}
{nextAvailable ? (
<button
type="button"
disabled={isBusy}
onClick={onAdvanceNextLevel}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<ArrowRight className="h-4 w-4" />
</button>
) : (
<div className="rounded-full bg-black/28 px-4 py-2 text-xs text-white/72 backdrop-blur">
{isBusy
? '同步中...'
: currentLevel.status === 'cleared'
? '等待下一关候选'
: '完成整张图即可通关'}
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default PuzzleRuntimeShell;

View File

@@ -1,156 +1,4 @@
import { ArrowRight, X } from 'lucide-react';
export interface RpgEntryCreationTypeModalProps {
isOpen: boolean;
isBusy: boolean;
error: string | null;
onClose: () => void;
onSelectRpg: () => void;
}
type CreationGameTypeCard = {
id: 'rpg' | 'airp' | 'visual-novel';
title: string;
subtitle: string;
badge: string;
locked: boolean;
};
const CREATION_GAME_TYPES: CreationGameTypeCard[] = [
{
id: 'rpg',
title: '角色扮演 RPG',
subtitle: 'Agent 共创',
badge: '可创建',
locked: false,
},
{
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '锁定',
locked: true,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '敬请期待',
badge: '锁定',
locked: true,
},
];
function CreationTypeCard(props: {
item: CreationGameTypeCard;
busy: boolean;
onSelect: () => void;
}) {
const { item, busy, onSelect } = props;
const disabled = item.locked || busy;
return (
<button
type="button"
disabled={disabled}
onClick={onSelect}
className={`platform-interactive-card relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
item.locked
? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]'
: 'border-[var(--platform-cool-border)] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_34%),linear-gradient(135deg,rgba(255,79,139,0.96),rgba(255,145,110,0.9))] text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex items-start justify-between gap-3">
<span
className={`platform-pill px-3 ${
item.locked
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
}`}
>
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
</span>
{item.locked ? (
<span className="text-lg leading-none text-white/45">·</span>
) : (
<ArrowRight className="h-4 w-4 text-white/80" />
)}
</div>
<div className="mt-8 text-xl font-black leading-tight text-inherit">
{item.title}
</div>
<div
className={`mt-2 text-sm ${
item.locked ? 'text-zinc-500' : 'text-zinc-200/82'
}`}
>
{item.subtitle}
</div>
</button>
);
}
/**
* RPG 入口创作类型弹层真实入口。
* 第三批收口后入口主链不再直接依赖旧 `PlatformCreationTypeModal` 命名。
*/
export function RpgEntryCreationTypeModal({
isOpen,
isBusy,
error,
onClose,
onSelectRpg,
}: RpgEntryCreationTypeModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
<div className="bg-transparent">
<div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 py-4 sm:px-5 sm:py-5">
<div className="grid gap-3 sm:grid-cols-3">
{CREATION_GAME_TYPES.map((item) => (
<CreationTypeCard
key={item.id}
item={item}
busy={isBusy}
onSelect={() => {
if (item.id === 'rpg') {
onSelectRpg();
}
}}
/>
))}
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</div>
</div>
</div>
</div>
);
}
export {
PlatformEntryCreationTypeModal as RpgEntryCreationTypeModal,
type PlatformEntryCreationTypeModalProps as RpgEntryCreationTypeModalProps,
} from '../platform-entry/PlatformEntryCreationTypeModal';

View File

@@ -1,15 +1,15 @@
import { RpgEntryFlowShellImpl } from './RpgEntryFlowShellImpl';
import { PlatformEntryFlowShell } from '../platform-entry';
import type { RpgEntryFlowShellProps } from './rpgEntryTypes';
import type { SelectionStage } from './rpgEntryTypes';
export type { RpgEntryFlowShellProps, SelectionStage };
/**
* RPG 入口域真实壳层入口
* 入口主链已收口到 `rpg-entry` 命名根,不再保留旧入口脚本。
* 兼容旧 RPG 入口导入路径
* 多玩法入口真实实现已迁移到 `platform-entry`,避免非 RPG 玩法写入 RPG 脚本。
*/
export function RpgEntryFlowShell(props: RpgEntryFlowShellProps) {
return <RpgEntryFlowShellImpl {...props} />;
return <PlatformEntryFlowShell {...props} />;
}
/**

View File

@@ -1,718 +1,5 @@
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { readCustomWorldAgentUiState } from '../../services/customWorldAgentUiState';
import { getRpgProfileDashboard } from '../../services/rpg-entry';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub';
import { RpgEntryCreationTypeModal } from './RpgEntryCreationTypeModal';
import { RpgEntryHomeView } from './RpgEntryHomeView';
import {
buildCreationHubFallbackItems,
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
} from './rpgEntryShared';
import type { RpgEntryFlowShellProps } from './rpgEntryTypes';
import { RpgEntryWorldDetailView } from './RpgEntryWorldDetailView';
import { useRpgCreationAgentOperationPolling } from './useRpgCreationAgentOperationPolling';
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
import { useRpgCreationSessionController } from './useRpgCreationSessionController';
import { useRpgEntryBootstrap } from './useRpgEntryBootstrap';
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
import { useRpgEntryNavigation } from './useRpgEntryNavigation';
const CustomWorldGenerationView = lazy(async () => {
const module = await import('../CustomWorldGenerationView');
return {
default: module.CustomWorldGenerationView,
};
});
const RpgCreationResultView = lazy(async () => {
const module = await import('../rpg-creation-result/RpgCreationResultView');
return {
default: module.RpgCreationResultView,
};
});
const CustomWorldAgentWorkspace = lazy(async () => {
const module = await import(
'../custom-world-agent/CustomWorldAgentWorkspace'
);
return {
default: module.CustomWorldAgentWorkspace,
};
});
function LazyPanelFallback({ label }: { label: string }) {
return (
<div className="flex h-full min-h-0 items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{label}
</div>
</div>
);
}
export function RpgEntryFlowShellImpl({
selectionStage,
setSelectionStage,
hasSavedGame,
savedSnapshot,
handleContinueGame,
handleStartNewGame,
handleCustomWorldSelect,
}: RpgEntryFlowShellProps) {
const authUi = useAuthUi();
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
CustomWorldLibraryEntry<CustomWorldProfile> | null
>(null);
const hasInitialAgentSession = Boolean(
readCustomWorldAgentUiState().activeSessionId,
);
const platformBootstrap = useRpgEntryBootstrap({
user: authUi?.user,
getProfileDashboard: getRpgProfileDashboard,
handleContinueGame,
hasInitialAgentSession,
});
const entryNavigation = useRpgEntryNavigation({
setSelectionStage,
setSelectedDetailEntry,
});
const enterCreateTab = useCallback(() => {
platformBootstrap.setPlatformTab('create');
}, [platformBootstrap]);
const sessionController = useRpgCreationSessionController({
userId: authUi?.user?.id,
openLoginModal: authUi?.openLoginModal,
selectionStage,
setSelectionStage,
enterCreateTab,
onSessionOpened: () => {
setShowCreationTypeModal(false);
},
});
useRpgCreationAgentOperationPolling({
activeAgentSessionId: sessionController.activeAgentSessionId,
activeAgentOperationId: sessionController.activeAgentOperationId,
userId: authUi?.user?.id,
setAgentOperation: sessionController.setAgentOperation,
persistAgentUiState: sessionController.persistAgentUiState,
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
});
const autosaveCoordinator = useRpgCreationResultAutosave({
selectionStage,
activeAgentSessionId: sessionController.activeAgentSessionId,
agentSession: sessionController.agentSession,
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
isAgentDraftResultView: sessionController.isAgentDraftResultView,
userId: authUi?.user?.id,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
setAgentOperation: sessionController.setAgentOperation,
setSavedCustomWorldEntries: platformBootstrap.setSavedCustomWorldEntries,
setSelectedDetailEntry,
refreshCustomWorldWorks: platformBootstrap.refreshCustomWorldWorks,
persistAgentUiState: sessionController.persistAgentUiState,
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
rpgCreationPreviewAdapter.buildPreviewFromSession(session),
});
const detailNavigation = useRpgEntryLibraryDetail({
userId: authUi?.user?.id,
selectedDetailEntry,
setSelectedDetailEntry,
savedCustomWorldEntries: platformBootstrap.savedCustomWorldEntries,
setSavedCustomWorldEntries: platformBootstrap.setSavedCustomWorldEntries,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
setCustomWorldError: sessionController.setCustomWorldError,
setCustomWorldAutoSaveError: autosaveCoordinator.setCustomWorldAutoSaveError,
setCustomWorldAutoSaveState: autosaveCoordinator.setCustomWorldAutoSaveState,
setCustomWorldGenerationViewSource:
sessionController.setCustomWorldGenerationViewSource,
setCustomWorldResultViewSource:
sessionController.setCustomWorldResultViewSource,
setSelectionStage,
setPlatformTabToCreate: enterCreateTab,
setPlatformError: platformBootstrap.setPlatformError,
appendBrowseHistoryEntry: platformBootstrap.appendBrowseHistoryEntry,
refreshCustomWorldWorks: platformBootstrap.refreshCustomWorldWorks,
refreshPublishedGallery: platformBootstrap.refreshPublishedGallery,
persistAgentUiState: sessionController.persistAgentUiState,
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
rpgCreationPreviewAdapter.buildPreviewFromSession(session),
suppressAgentDraftResultAutoOpen:
sessionController.suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression:
sessionController.releaseAgentDraftResultAutoOpenSuppression,
resetAutoSaveTrackingToIdle: autosaveCoordinator.resetAutoSaveTrackingToIdle,
markAutoSavedProfile: autosaveCoordinator.markAutoSavedProfile,
});
const enterWorldCoordinator = useRpgCreationEnterWorld({
isAgentDraftResultView: sessionController.isAgentDraftResultView,
activeAgentSessionId: sessionController.activeAgentSessionId,
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
agentSessionProfile: sessionController.agentDraftResultProfile,
agentSession: sessionController.agentSession,
handleCustomWorldSelect,
executePublishWorld: () =>
autosaveCoordinator.executeAgentActionAndWait({
action: 'publish_world',
}),
syncAgentDraftResultProfile: autosaveCoordinator.syncAgentDraftResultProfile,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
});
const previewCustomWorldCharacters = useMemo(
() =>
sessionController.generatedCustomWorldProfile
? buildCustomWorldPlayableCharacters(
sessionController.generatedCustomWorldProfile,
)
: [],
[sessionController.generatedCustomWorldProfile],
);
const agentResultPreview = sessionController.agentSession?.resultPreview ?? null;
const agentResultPreviewBlockers = useMemo(
() => agentResultPreview?.blockers?.map((entry) => entry.message) ?? [],
[agentResultPreview],
);
const agentResultPreviewQualityFindings = useMemo(
() => agentResultPreview?.qualityFindings ?? [],
[agentResultPreview],
);
const agentResultPreviewSourceLabel = useMemo(() => {
if (!agentResultPreview?.source) {
return null;
}
if (agentResultPreview.source === 'published_profile') {
return '已发布世界';
}
if (agentResultPreview.source === 'session_preview') {
return '会话预览';
}
return '服务端预览';
}, [agentResultPreview]);
const featuredGalleryEntries = useMemo(
() => platformBootstrap.publishedGalleryEntries.slice(0, 6),
[platformBootstrap.publishedGalleryEntries],
);
const creationHubItems =
platformBootstrap.customWorldWorkEntries.length > 0
? platformBootstrap.customWorldWorkEntries
: buildCreationHubFallbackItems(platformBootstrap.savedCustomWorldEntries);
const resultViewError =
autosaveCoordinator.customWorldAutoSaveError ??
sessionController.customWorldError;
useEffect(() => {
if (
selectionStage === 'custom-world-result' &&
!sessionController.generatedCustomWorldProfile
) {
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
}
}, [
selectedDetailEntry,
selectionStage,
sessionController.generatedCustomWorldProfile,
setSelectionStage,
]);
const runProtectedAction = useCallback(
(action: () => void) => {
if (!authUi?.requireAuth) {
action();
return;
}
authUi.requireAuth(action);
},
[authUi],
);
const openCreationTypePicker = useCallback(() => {
if (sessionController.isCreatingAgentSession) {
return;
}
if (!hasSavedGame) {
handleStartNewGame();
}
sessionController.setCreationTypeError(null);
setShowCreationTypeModal(true);
}, [
handleStartNewGame,
hasSavedGame,
sessionController,
]);
const leaveAgentWorkspace = useCallback(() => {
enterCreateTab();
sessionController.resetSessionViewState();
sessionController.setGeneratedCustomWorldProfile(null);
autosaveCoordinator.resetAutoSaveTrackingToIdle();
sessionController.persistAgentUiState(
sessionController.activeAgentSessionId,
null,
);
setSelectionStage('platform');
}, [
autosaveCoordinator,
enterCreateTab,
sessionController,
setSelectionStage,
]);
const leaveAgentDraftGeneration = useCallback(() => {
if (sessionController.isActiveGenerationRunning) {
return;
}
sessionController.setAgentDraftGenerationStartedAt(null);
sessionController.setCustomWorldGenerationViewSource(null);
setSelectionStage('agent-workspace');
}, [sessionController, setSelectionStage]);
const leaveAgentDraftResult = useCallback(() => {
sessionController.suppressAgentDraftResultAutoOpen();
sessionController.setGeneratedCustomWorldProfile(null);
sessionController.setCustomWorldError(null);
autosaveCoordinator.resetAutoSaveTrackingToIdle();
sessionController.setCustomWorldGenerationViewSource(null);
sessionController.setCustomWorldResultViewSource(null);
enterCreateTab();
setSelectionStage('platform');
}, [
autosaveCoordinator,
enterCreateTab,
sessionController,
setSelectionStage,
]);
const leaveCustomWorldResult = useCallback(() => {
sessionController.setGeneratedCustomWorldProfile(null);
sessionController.setCustomWorldError(null);
autosaveCoordinator.resetAutoSaveTrackingToIdle();
sessionController.setCustomWorldGenerationViewSource(null);
sessionController.setCustomWorldResultViewSource(null);
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
}, [
autosaveCoordinator,
selectedDetailEntry,
sessionController,
setSelectionStage,
]);
const handleStartSelectedWorld = useCallback(() => {
if (!selectedDetailEntry) {
return;
}
runProtectedAction(() => {
handleCustomWorldSelect(selectedDetailEntry.profile);
});
}, [handleCustomWorldSelect, runProtectedAction, selectedDetailEntry]);
const creationHubContent = (
<CustomWorldCreationHub
items={creationHubItems}
loading={platformBootstrap.isLoadingPlatform}
error={
platformBootstrap.isLoadingPlatform
? null
: platformBootstrap.platformError ?? sessionController.creationTypeError
}
onBack={() => {
platformBootstrap.setPlatformTab('home');
}}
onRetry={() => {
platformBootstrap.setPlatformError(null);
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
platformBootstrap.setPlatformError(
resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'),
);
});
}}
onCreateNew={openCreationTypePicker}
onOpenDraft={(item) => {
runProtectedAction(() => {
void detailNavigation.handleOpenCreationWork(item);
});
}}
onEnterPublished={(profileId) => {
runProtectedAction(() => {
const matchedWork = creationHubItems.find(
(entry) => entry.profileId === profileId,
);
if (!matchedWork) {
return;
}
void detailNavigation.handleOpenCreationWork(matchedWork);
});
}}
/>
);
return (
<>
<AnimatePresence mode="wait">
{selectionStage === 'platform' && (
<motion.div
key="platform-home"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<RpgEntryHomeView
activeTab={platformBootstrap.platformTab}
onTabChange={platformBootstrap.setPlatformTab}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
saveEntries={platformBootstrap.saveEntries}
saveError={platformBootstrap.saveError}
featuredEntries={featuredGalleryEntries}
latestEntries={platformBootstrap.publishedGalleryEntries}
myEntries={platformBootstrap.savedCustomWorldEntries}
historyEntries={platformBootstrap.historyEntries}
profileDashboard={platformBootstrap.profileDashboard}
isLoadingPlatform={platformBootstrap.isLoadingPlatform}
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
platformError={
platformBootstrap.isLoadingPlatform
? null
: platformBootstrap.platformError ??
sessionController.creationTypeError
}
dashboardError={
platformBootstrap.isLoadingDashboard
? null
: platformBootstrap.dashboardError
}
createTabContent={creationHubContent}
onContinueGame={handleContinueGame}
onResumeSave={(entry) => {
void platformBootstrap.handleResumeSaveEntry(entry);
}}
onOpenCreateWorld={openCreationTypePicker}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
runProtectedAction(() => {
void detailNavigation.openGalleryDetail(entry);
});
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
detailNavigation.openLibraryDetail(entry);
});
}}
onOpenProfileDashboardCard={() => {
if (platformBootstrap.dashboardError) {
void platformBootstrap.refreshProfileDashboard();
}
}}
/>
</motion.div>
)}
{selectionStage === 'detail' && (
<motion.div
key="platform-detail"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
{detailNavigation.isDetailLoading || !selectedDetailEntry ? (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{detailNavigation.detailError || '正在读取作品详情...'}
</div>
</div>
) : (
<RpgEntryWorldDetailView
entry={selectedDetailEntry}
isMutating={detailNavigation.isMutatingDetail}
error={detailNavigation.detailError}
onBack={() => {
detailNavigation.setDetailError(null);
entryNavigation.backToPlatformHome();
}}
onStartGame={handleStartSelectedWorld}
onContinueEdit={
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
detailNavigation.openSavedCustomWorldEditor(
selectedDetailEntry,
);
});
}
: null
}
onPublish={
selectedDetailEntry.visibility === 'draft' &&
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void detailNavigation.handlePublishSelectedWorld();
});
}
: null
}
onUnpublish={
selectedDetailEntry.visibility === 'published' &&
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void detailNavigation.handleUnpublishSelectedWorld();
});
}
: null
}
onDelete={
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void detailNavigation.handleDeleteSelectedWorld();
});
}
: null
}
/>
)}
</motion.div>
)}
{selectionStage === 'agent-workspace' && (
<motion.div
key="agent-workspace"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={
<LazyPanelFallback label="正在加载 Agent 共创工作区..." />
}
>
{sessionController.agentSession ? (
<CustomWorldAgentWorkspace
session={sessionController.agentSession}
activeOperation={sessionController.agentOperation}
streamingReplyText={sessionController.streamingAgentReplyText}
isStreamingReply={sessionController.isStreamingAgentReply}
onBack={leaveAgentWorkspace}
onSubmitMessage={(payload) => {
void sessionController.submitAgentMessage(payload);
}}
onExecuteAction={(payload) => {
void sessionController.executeAgentAction(payload);
}}
/>
) : (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{sessionController.isLoadingAgentSession
? '正在准备 Agent 共创工作区...'
: sessionController.creationTypeError || '正在恢复创作工作区...'}
</div>
</div>
)}
</Suspense>
</motion.div>
)}
{selectionStage === 'custom-world-generating' && (
<motion.div
key="custom-world-generating"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载世界生成面板..." />}
>
<CustomWorldGenerationView
settingText={sessionController.agentDraftSettingPreview}
anchorEntries={sessionController.agentDraftAnchorPreviewEntries}
progress={sessionController.agentDraftGenerationProgress}
isGenerating={sessionController.isActiveGenerationRunning}
error={sessionController.activeGenerationError}
onBack={leaveAgentDraftGeneration}
onEditSetting={leaveAgentDraftGeneration}
onRetry={() => {
void sessionController.executeAgentAction({
action: 'draft_foundation',
});
}}
onInterrupt={undefined}
backLabel="返回工作区"
settingActionLabel={null}
retryLabel="重新生成草稿"
settingTitle="当前世界信息"
settingDescription={null}
progressTitle="世界草稿生成进度"
activeBadgeLabel="草稿编译中"
pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区"
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'custom-world-result' &&
sessionController.generatedCustomWorldProfile && (
<motion.div
key="custom-world-result"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载世界编辑器..." />}
>
<RpgCreationResultView
profile={sessionController.generatedCustomWorldProfile}
previewCharacters={previewCustomWorldCharacters}
isGenerating={false}
progress={0}
progressLabel=""
error={resultViewError}
onProfileChange={(profile) => {
sessionController.setGeneratedCustomWorldProfile(
normalizeAgentBackedProfile(profile),
);
}}
onBack={
sessionController.isAgentDraftResultView
? () => {
void (async () => {
const currentProfile =
sessionController.generatedCustomWorldProfile;
if (!currentProfile) {
leaveAgentDraftResult();
return;
}
await autosaveCoordinator.syncAgentDraftResultProfile(
currentProfile,
);
leaveAgentDraftResult();
})().catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'返回创作前同步草稿失败。',
),
);
});
}
: leaveCustomWorldResult
}
onEditSetting={undefined}
onRegenerate={undefined}
onContinueExpand={undefined}
onEnterWorld={() => {
runProtectedAction(() => {
void enterWorldCoordinator
.enterWorldFromCurrentResult()
.catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'发布并进入世界失败。',
),
);
});
});
}}
readOnly={false}
compactAgentResultMode={sessionController.isAgentDraftResultView}
backLabel={
sessionController.isAgentDraftResultView
? '返回创作'
: undefined
}
editActionLabel="继续调整设定"
enterWorldActionLabel={
sessionController.isAgentDraftResultView &&
sessionController.agentSession?.stage !== 'published'
? '发布并进入世界'
: '进入世界'
}
publishReady={
sessionController.isAgentDraftResultView
? Boolean(agentResultPreview?.publishReady)
: true
}
publishBlockers={
sessionController.isAgentDraftResultView
? agentResultPreviewBlockers
: []
}
qualityFindings={
sessionController.isAgentDraftResultView
? agentResultPreviewQualityFindings
: []
}
previewSourceLabel={
sessionController.isAgentDraftResultView
? agentResultPreviewSourceLabel
: null
}
autoSaveState={autosaveCoordinator.customWorldAutoSaveState}
/>
</Suspense>
</motion.div>
)}
</AnimatePresence>
<RpgEntryCreationTypeModal
isOpen={showCreationTypeModal}
isBusy={sessionController.isCreatingAgentSession}
error={sessionController.creationTypeError}
onClose={() => {
if (sessionController.isCreatingAgentSession) {
return;
}
setShowCreationTypeModal(false);
}}
onSelectRpg={() => {
runProtectedAction(() => {
void sessionController.openRpgAgentWorkspace();
});
}}
/>
</>
);
}
export const RpgCreationShellImpl = RpgEntryFlowShellImpl;
export default RpgEntryFlowShellImpl;
export {
PlatformEntryFlowShellImpl as RpgCreationShellImpl,
PlatformEntryFlowShellImpl as RpgEntryFlowShellImpl,
} from '../platform-entry/PlatformEntryFlowShellImpl';
export { PlatformEntryFlowShellImpl as default } from '../platform-entry/PlatformEntryFlowShellImpl';

View File

@@ -1,39 +1,9 @@
import type {
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile, GameState } from '../../types';
export type SelectionStage =
| 'platform'
| 'detail'
| 'agent-workspace'
| 'custom-world-generating'
| 'custom-world-result';
export type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
export type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null;
export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
export type SyncedAgentDraftResult = {
session: CustomWorldAgentSessionSnapshot | null;
profile: CustomWorldProfile | null;
};
export type RpgEntryFlowShellProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
gameState: GameState;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
};
/**
* 兼容旧创作链入口的 props 命名,避免并行工作包在迁移期间断开引用。
*/
export type RpgCreationShellProps = RpgEntryFlowShellProps;
export type {
CustomWorldAutoSaveState,
CustomWorldGenerationViewSource,
CustomWorldResultViewSource,
PlatformEntryFlowShellProps as RpgEntryFlowShellProps,
PlatformEntryFlowShellProps as RpgCreationShellProps,
SelectionStage,
SyncedAgentDraftResult,
} from '../platform-entry/platformEntryTypes';

View File

@@ -20,7 +20,7 @@ import type {
} from '../../types';
import { UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import type { SelectionStage } from '../rpg-entry';
import type { SelectionStage } from '../platform-entry';
import type { RpgAdventureStatistics } from './types';
const RpgEntryCharacterSelectView = lazy(async () => {
@@ -30,10 +30,10 @@ const RpgEntryCharacterSelectView = lazy(async () => {
};
});
const RpgEntryFlowShell = lazy(async () => {
const module = await import('../rpg-entry');
const PlatformEntryFlowShell = lazy(async () => {
const module = await import('../platform-entry');
return {
default: module.RpgEntryFlowShell,
default: module.PlatformEntryFlowShell,
};
});
@@ -169,7 +169,7 @@ export function RpgRuntimeStageRouter({
<Suspense
fallback={<MainContentLoadingFallback label="正在加载平台首页..." />}
>
<RpgEntryFlowShell
<PlatformEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={gameState}

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import type { GameState } from '../../types';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import type { SelectionStage } from '../rpg-entry';
import type { SelectionStage } from '../platform-entry';
type OverlayPanel = 'character' | 'inventory' | null;

View File

@@ -51,6 +51,7 @@ import {
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
} from './affinityLevels';
import { coerceWorldAttributeSchema } from './attributeValidation';
import { normalizeCustomWorldSceneRelativePosition } from './customWorldSceneGraph';
import {
type CustomWorldLandmarkDraft,
normalizeCustomWorldLandmarks,
@@ -877,8 +878,11 @@ function normalizeCampScene(
connections: toRecordArray(value.connections)
.map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
relativePosition:
toText(connection.relativePosition) || toText(connection.position) || 'forward',
relativePosition: normalizeCustomWorldSceneRelativePosition(
toText(connection.relativePosition) ||
toText(connection.position) ||
'forward',
),
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkId),
@@ -920,8 +924,9 @@ function normalizeLandmarkDraft(
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
relativePosition: normalizeCustomWorldSceneRelativePosition(
toText(connection.relativePosition) || toText(connection.position),
),
summary: toText(connection.summary) || toText(connection.description),
})),
};

View File

@@ -18,9 +18,7 @@ export function useResolvedAssetReadUrl(
const normalizedSource = source?.trim() ?? '';
const shouldResolve =
enabled && Boolean(normalizedSource) && isGeneratedLegacyPath(normalizedSource);
const [resolvedUrl, setResolvedUrl] = useState(
shouldResolve ? '' : normalizedSource,
);
const [resolvedUrl, setResolvedUrl] = useState(normalizedSource);
useEffect(() => {
if (!normalizedSource) {
@@ -34,7 +32,8 @@ export function useResolvedAssetReadUrl(
}
let cancelled = false;
setResolvedUrl('');
// 生成资源的签名 URL 还没回来前,先保留原始路径占位,避免结果页/运行时首屏出现空白图块。
setResolvedUrl(normalizedSource);
void resolveAssetReadUrl(normalizedSource, {
expireSeconds: options.expireSeconds,

View File

@@ -0,0 +1,147 @@
import type {
BigFishActionResponse,
BigFishSessionResponse,
BigFishSessionSnapshotResponse,
CreateBigFishSessionRequest,
ExecuteBigFishActionRequest,
SendBigFishMessageRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
import type { TextStreamOptions } from '../aiTypes';
import {
type ApiRetryOptions,
fetchWithApiAuth,
requestJson,
} from '../apiClient';
import { readCreationAgentSessionFromSse } from '../creation-agent';
const BIG_FISH_AGENT_API_BASE = '/api/runtime/big-fish/agent/sessions';
const BIG_FISH_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 180,
maxDelayMs: 480,
};
const BIG_FISH_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 240,
maxDelayMs: 640,
retryUnsafeMethods: true,
};
export async function createBigFishCreationSession(
payload: CreateBigFishSessionRequest = {},
) {
return requestJson<BigFishSessionResponse>(
BIG_FISH_AGENT_API_BASE,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'创建大鱼吃小鱼共创会话失败',
{
retry: BIG_FISH_WRITE_RETRY,
},
);
}
export async function getBigFishCreationSession(sessionId: string) {
return requestJson<BigFishSessionResponse>(
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}`,
{
method: 'GET',
},
'读取大鱼吃小鱼共创会话失败',
{
retry: BIG_FISH_READ_RETRY,
},
);
}
export async function sendBigFishCreationMessage(
sessionId: string,
payload: SendBigFishMessageRequest,
) {
return requestJson<BigFishSessionResponse>(
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'发送大鱼吃小鱼共创消息失败',
{
retry: BIG_FISH_WRITE_RETRY,
},
);
}
export async function streamBigFishCreationMessage(
sessionId: string,
payload: SendBigFishMessageRequest,
options: TextStreamOptions = {},
) {
const response = await openBigFishCreationSsePost(
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
payload,
'发送大鱼吃小鱼共创消息失败',
);
return readCreationAgentSessionFromSse<BigFishSessionSnapshotResponse>(
response,
{
...options,
fallbackMessage: '发送大鱼吃小鱼共创消息失败',
incompleteMessage: '大鱼吃小鱼共创消息流式结果不完整',
},
);
}
export async function executeBigFishCreationAction(
sessionId: string,
payload: ExecuteBigFishActionRequest,
) {
return requestJson<BigFishActionResponse>(
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/actions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'执行大鱼吃小鱼共创操作失败',
{
retry: BIG_FISH_WRITE_RETRY,
},
);
}
async function openBigFishCreationSsePost(
url: string,
payload: unknown,
fallbackMessage: string,
) {
const response = await fetchWithApiAuth(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
return response;
}
export const bigFishCreationClient = {
createSession: createBigFishCreationSession,
getSession: getBigFishCreationSession,
sendMessage: sendBigFishCreationMessage,
streamMessage: streamBigFishCreationMessage,
executeAction: executeBigFishCreationAction,
};

View File

@@ -0,0 +1,8 @@
export {
bigFishCreationClient,
createBigFishCreationSession,
executeBigFishCreationAction,
getBigFishCreationSession,
sendBigFishCreationMessage,
streamBigFishCreationMessage,
} from './bigFishCreationClient';

View File

@@ -0,0 +1,68 @@
import type {
BigFishRunResponse,
SubmitBigFishInputRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const BIG_FISH_RUNTIME_API_BASE = '/api/runtime/big-fish';
const BIG_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
export async function startBigFishRuntimeRun(sessionId: string) {
return requestJson<BigFishRunResponse>(
`${BIG_FISH_RUNTIME_API_BASE}/sessions/${encodeURIComponent(sessionId)}/runs`,
{
method: 'POST',
},
'启动大鱼吃小鱼测试玩法失败',
{
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
},
);
}
export async function getBigFishRuntimeRun(runId: string) {
return requestJson<BigFishRunResponse>(
`${BIG_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}`,
{
method: 'GET',
},
'读取大鱼吃小鱼运行快照失败',
{
retry: BIG_FISH_RUNTIME_READ_RETRY,
},
);
}
export async function submitBigFishRuntimeInput(
runId: string,
payload: SubmitBigFishInputRequest,
) {
return requestJson<BigFishRunResponse>(
`${BIG_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/input`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'提交大鱼吃小鱼移动输入失败',
{
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
},
);
}
export const bigFishRuntimeClient = {
startRun: startBigFishRuntimeRun,
getRun: getBigFishRuntimeRun,
submitInput: submitBigFishRuntimeInput,
};

View File

@@ -0,0 +1,6 @@
export {
bigFishRuntimeClient,
getBigFishRuntimeRun,
startBigFishRuntimeRun,
submitBigFishRuntimeInput,
} from './bigFishRuntimeClient';

View File

@@ -0,0 +1,77 @@
export type CreationAgentOperationLike = {
status?: string | null;
};
export type CreationAgentProgressCopy = {
completed?: string;
high?: string;
medium?: string;
low?: string;
initial?: string;
};
export function normalizeCreationAgentProgress(progressPercent: number) {
if (!Number.isFinite(progressPercent)) {
return 0;
}
return Math.max(0, Math.min(100, Math.round(progressPercent)));
}
export function isCreationAgentOperationBusy(
operation: CreationAgentOperationLike | null | undefined,
) {
return operation?.status === 'queued' || operation?.status === 'running';
}
export function resolveCreationAgentProgressHint(
progressPercent: number,
copy: CreationAgentProgressCopy = {},
) {
const normalizedProgress = normalizeCreationAgentProgress(progressPercent);
if (normalizedProgress >= 100) {
return copy.completed || '当前设定已经收束完成,可以进入结果页生成';
}
if (normalizedProgress >= 75) {
return copy.high || '关键锚点基本成形,正在收束成可生成草稿的版本';
}
if (normalizedProgress >= 45) {
return copy.medium || '方向已经成形,继续补齐会影响体验的关键锚点';
}
if (normalizedProgress >= 15) {
return copy.low || '先把玩家一眼能感知的核心体验钉稳';
}
return copy.initial || '先抓住这个创作品类最关键的方向';
}
export function resolveCreationAnchorStatusLabel(status: string) {
if (status === 'locked') {
return '已锁定';
}
if (status === 'confirmed') {
return '已确认';
}
if (status === 'inferred') {
return '推断中';
}
return '待补充';
}
export function createCreationAgentClientMessageId(prefix: string) {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `${prefix}-client-message-${Date.now()}`;
}

View File

@@ -0,0 +1,105 @@
import type { TextStreamOptions } from '../aiTypes';
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
fallbackMessage: string;
incompleteMessage: string;
resolveSession?: (rawSession: unknown) => TSession | null;
};
function parseSseEventBlock(eventBlock: string) {
let eventName = 'message';
const dataLines: string[] = [];
for (const rawLine of eventBlock.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line.startsWith('event:')) {
eventName = line.slice(6).trim() || 'message';
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
return {
eventName,
data: dataLines.join('\n'),
};
}
function parseJsonObject(data: string) {
try {
return JSON.parse(data) as Record<string, unknown>;
} catch {
return null;
}
}
export async function readCreationAgentSessionFromSse<TSession>(
response: Response,
options: CreationAgentSseOptions<TSession>,
) {
const streamBody = response.body;
if (!streamBody) {
throw new Error('streaming response body is unavailable');
}
const reader = streamBody.getReader();
const decoder = new TextDecoder('utf-8');
const resolveSession =
options.resolveSession ??
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
let buffer = '';
let finalSession: TSession | null = null;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n');
const eventBlock = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const { eventName, data } = parseSseEventBlock(eventBlock);
if (!data) {
continue;
}
const parsed = parseJsonObject(data);
if (eventName === 'reply_delta' && parsed) {
const text = parsed.text;
if (typeof text === 'string') {
options.onUpdate?.(text);
}
continue;
}
if (eventName === 'session' && parsed?.session) {
finalSession = resolveSession(parsed.session);
continue;
}
if (eventName === 'error' && parsed) {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: options.fallbackMessage;
throw new Error(message);
}
}
}
if (!finalSession) {
throw new Error(options.incompleteMessage);
}
return finalSession;
}

View File

@@ -0,0 +1,2 @@
export * from './creationAgentProgress';
export * from './creationAgentSse';

View File

@@ -118,12 +118,17 @@ describe('resolveCustomWorldCoverPresentation', () => {
it('当第一幕图片缺失时按营地图与地标图顺序回退', () => {
const profile = createBaseProfile();
const firstSceneChapter = profile.sceneChapterBlueprints?.[0];
const firstSceneAct = firstSceneChapter?.acts[0];
if (!firstSceneChapter || !firstSceneAct) {
throw new Error('expected base profile to provide an opening scene chapter');
}
profile.sceneChapterBlueprints = [
{
...profile.sceneChapterBlueprints![0],
...firstSceneChapter,
acts: [
{
...profile.sceneChapterBlueprints![0]!.acts[0]!,
...firstSceneAct,
backgroundImageSrc: null,
backgroundAssetId: null,
},

View File

@@ -0,0 +1,5 @@
/**
* 平台入口服务通用封装。
* 先复用既有资料看板读取逻辑,但对 `platform-entry` 暴露通用命名。
*/
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry';

View File

@@ -0,0 +1,8 @@
export {
createPuzzleAgentSession,
executePuzzleAgentAction,
getPuzzleAgentSession,
puzzleAgentClient,
sendPuzzleAgentMessage,
streamPuzzleAgentMessage,
} from './puzzleAgentClient';

View File

@@ -0,0 +1,168 @@
import type {
PuzzleAgentActionRequest,
PuzzleAgentActionResponse,
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
CreatePuzzleAgentSessionRequest,
CreatePuzzleAgentSessionResponse,
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
import type { TextStreamOptions } from '../aiTypes';
import {
type ApiRetryOptions,
fetchWithApiAuth,
requestJson,
} from '../apiClient';
import { readCreationAgentSessionFromSse } from '../creation-agent';
const PUZZLE_AGENT_API_BASE = '/api/runtime/puzzle/agent/sessions';
const PUZZLE_AGENT_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 180,
maxDelayMs: 480,
};
const PUZZLE_AGENT_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 240,
maxDelayMs: 640,
retryUnsafeMethods: true,
};
/**
* 创建拼图 Agent 共创会话。
* 首版继续走 Axum facade前端不直连 SpacetimeDB。
*/
export async function createPuzzleAgentSession(
payload: CreatePuzzleAgentSessionRequest = {},
) {
return requestJson<CreatePuzzleAgentSessionResponse>(
PUZZLE_AGENT_API_BASE,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'创建拼图共创会话失败',
{
retry: PUZZLE_AGENT_WRITE_RETRY,
},
);
}
/**
* 读取拼图 Agent 会话快照。
*/
export async function getPuzzleAgentSession(sessionId: string) {
return requestJson<CreatePuzzleAgentSessionResponse>(
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}`,
{
method: 'GET',
},
'读取拼图共创会话失败',
{
retry: PUZZLE_AGENT_READ_RETRY,
},
);
}
/**
* 非流式发送拼图 Agent 消息。
* 当前 UI 主链使用 SSE但保留普通接口便于后续降级。
*/
export async function sendPuzzleAgentMessage(
sessionId: string,
payload: SendPuzzleAgentMessageRequest,
) {
return requestJson<{ session: PuzzleAgentSessionSnapshot }>(
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'发送拼图共创消息失败',
{
retry: PUZZLE_AGENT_WRITE_RETRY,
},
);
}
/**
* 流式发送拼图 Agent 消息。
* 后端当前会先回传一段 assistant 文本,再附上最新 session 快照。
*/
export async function streamPuzzleAgentMessage(
sessionId: string,
payload: SendPuzzleAgentMessageRequest,
options: TextStreamOptions = {},
) {
const response = await openPuzzleAgentSsePost(
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
payload,
'发送拼图共创消息失败',
);
return readCreationAgentSessionFromSse<PuzzleAgentSessionSnapshot>(
response,
{
...options,
fallbackMessage: '发送拼图共创消息失败',
incompleteMessage: '拼图共创消息流式结果不完整',
},
);
}
/**
* 执行拼图结果页相关操作。
* 后端会返回 operation 记录,前端再按需刷新 session 或 works/gallery。
*/
export async function executePuzzleAgentAction(
sessionId: string,
payload: PuzzleAgentActionRequest,
) {
return requestJson<PuzzleAgentActionResponse>(
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/actions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'执行拼图共创操作失败',
{
retry: PUZZLE_AGENT_WRITE_RETRY,
},
);
}
async function openPuzzleAgentSsePost(
url: string,
payload: unknown,
fallbackMessage: string,
) {
const response = await fetchWithApiAuth(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
return response;
}
export const puzzleAgentClient = {
createSession: createPuzzleAgentSession,
getSession: getPuzzleAgentSession,
sendMessage: sendPuzzleAgentMessage,
streamMessage: streamPuzzleAgentMessage,
executeAction: executePuzzleAgentAction,
};

View File

@@ -0,0 +1,5 @@
export {
getPuzzleGalleryDetail,
listPuzzleGallery,
puzzleGalleryClient,
} from './puzzleGalleryClient';

View File

@@ -0,0 +1,49 @@
import type {
PuzzleWorksResponse,
PuzzleWorkSummary,
} from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const PUZZLE_GALLERY_API_BASE = '/api/runtime/puzzle/gallery';
const PUZZLE_GALLERY_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
/**
* 读取拼图广场列表。
*/
export async function listPuzzleGallery() {
return requestJson<PuzzleWorksResponse>(
PUZZLE_GALLERY_API_BASE,
{
method: 'GET',
},
'读取拼图广场失败',
{
retry: PUZZLE_GALLERY_READ_RETRY,
},
);
}
/**
* 读取拼图广场详情。
*/
export async function getPuzzleGalleryDetail(profileId: string) {
return requestJson<{ item: PuzzleWorkSummary }>(
`${PUZZLE_GALLERY_API_BASE}/${encodeURIComponent(profileId)}`,
{
method: 'GET',
},
'读取拼图广场详情失败',
{
retry: PUZZLE_GALLERY_READ_RETRY,
},
);
}
export const puzzleGalleryClient = {
getDetail: getPuzzleGalleryDetail,
list: listPuzzleGallery,
};

View File

@@ -0,0 +1,8 @@
export {
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun,
puzzleRuntimeClient,
startPuzzleRun,
swapPuzzlePieces,
} from './puzzleRuntimeClient';

View File

@@ -0,0 +1,120 @@
import type {
DragPuzzlePieceRequest,
PuzzleRunResponse,
StartPuzzleRunRequest,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 从某个已发布拼图作品开始一次 run。
*/
export async function startPuzzleRun(payload: StartPuzzleRunRequest) {
return requestJson<PuzzleRunResponse>(
PUZZLE_RUNTIME_API_BASE,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'启动拼图玩法失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
/**
* 读取拼图运行态快照。
*/
export async function getPuzzleRun(runId: string) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}`,
{
method: 'GET',
},
'读取拼图运行快照失败',
{
retry: PUZZLE_RUNTIME_READ_RETRY,
},
);
}
/**
* 提交两块交换请求。
*/
export async function swapPuzzlePieces(
runId: string,
payload: SwapPuzzlePiecesRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/swap`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'交换拼图块失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
/**
* 提交单块或合并块拖动请求。
*/
export async function dragPuzzlePieceOrGroup(
runId: string,
payload: DragPuzzlePieceRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'拖动拼图块失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
/**
* 进入推荐出的下一关。
*/
export async function advancePuzzleNextLevel(runId: string) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
{
method: 'POST',
},
'进入下一关失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
export const puzzleRuntimeClient = {
advanceNextLevel: advancePuzzleNextLevel,
drag: dragPuzzlePieceOrGroup,
getRun: getPuzzleRun,
startRun: startPuzzleRun,
swap: swapPuzzlePieces,
};

View File

@@ -0,0 +1,6 @@
export {
getPuzzleWorkDetail,
listPuzzleWorks,
puzzleWorksClient,
updatePuzzleWork,
} from './puzzleWorksClient';

View File

@@ -0,0 +1,85 @@
import type {
PuzzleWorkDetailResponse,
PuzzleWorkMutationResponse,
PuzzleWorksResponse,
} from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const PUZZLE_WORKS_API_BASE = '/api/runtime/puzzle/works';
const PUZZLE_WORKS_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const PUZZLE_WORKS_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 读取当前用户的拼图作品列表。
*/
export async function listPuzzleWorks() {
return requestJson<PuzzleWorksResponse>(
PUZZLE_WORKS_API_BASE,
{
method: 'GET',
},
'读取拼图作品列表失败',
{
retry: PUZZLE_WORKS_READ_RETRY,
},
);
}
/**
* 读取拼图作品详情。
*/
export async function getPuzzleWorkDetail(profileId: string) {
return requestJson<PuzzleWorkDetailResponse>(
`${PUZZLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{
method: 'GET',
},
'读取拼图作品详情失败',
{
retry: PUZZLE_WORKS_READ_RETRY,
},
);
}
/**
* 更新已发布或草稿态拼图作品的轻量字段。
* 只覆盖结果页约定的标题、摘要、标签与正式图。
*/
export async function updatePuzzleWork(
profileId: string,
payload: {
levelName: string;
summary: string;
themeTags: string[];
coverImageSrc?: string | null;
coverAssetId?: string | null;
},
) {
return requestJson<PuzzleWorkMutationResponse>(
`${PUZZLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'更新拼图作品失败',
{
retry: PUZZLE_WORKS_WRITE_RETRY,
},
);
}
export const puzzleWorksClient = {
getDetail: getPuzzleWorkDetail,
list: listPuzzleWorks,
update: updatePuzzleWork,
};

View File

@@ -9,6 +9,7 @@ import type {
} from '../../../packages/shared/src';
import type { RpgAgentActionRequest } from '../../../packages/shared/src';
import type { TextStreamOptions } from '../aiTypes';
import { readCreationAgentSessionFromSse } from '../creation-agent';
import {
openRpgCreationSsePost,
requestRpgCreationPostJson,
@@ -63,84 +64,11 @@ export async function streamRpgCreationMessage(
'发送共创消息失败',
);
const streamBody = response.body;
if (!streamBody) {
throw new Error('streaming response body is unavailable');
}
const reader = streamBody.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalSession: RpgAgentSessionSnapshot | null = null;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n');
const eventBlock = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
let eventName = 'message';
const dataLines: string[] = [];
for (const rawLine of eventBlock.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line.startsWith('event:')) {
eventName = line.slice(6).trim() || 'message';
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
if (dataLines.length === 0) {
continue;
}
const data = dataLines.join('\n');
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(data) as Record<string, unknown>;
} catch {
parsed = null;
}
if (eventName === 'reply_delta' && parsed) {
const text = parsed.text;
if (typeof text === 'string') {
options.onUpdate?.(text);
}
continue;
}
if (eventName === 'session' && parsed?.session) {
finalSession = parsed.session as RpgAgentSessionSnapshot;
continue;
}
if (eventName === 'error' && parsed) {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: '发送共创消息失败';
throw new Error(message);
}
}
}
if (!finalSession) {
throw new Error('共创消息流式结果不完整');
}
return finalSession;
return readCreationAgentSessionFromSse<RpgAgentSessionSnapshot>(response, {
...options,
fallbackMessage: '发送共创消息失败',
incompleteMessage: '共创消息流式结果不完整',
});
}
export async function executeRpgCreationAction(