1
This commit is contained in:
@@ -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)',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
102
src/components/big-fish-creation/BigFishAgentWorkspace.tsx
Normal file
102
src/components/big-fish-creation/BigFishAgentWorkspace.tsx
Normal 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;
|
||||
471
src/components/big-fish-result/BigFishResultView.tsx
Normal file
471
src/components/big-fish-result/BigFishResultView.tsx
Normal 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;
|
||||
230
src/components/big-fish-runtime/BigFishRuntimeShell.tsx
Normal file
230
src/components/big-fish-runtime/BigFishRuntimeShell.tsx
Normal 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;
|
||||
112
src/components/creation-agent/CreationAgentWorkspace.test.tsx
Normal file
112
src/components/creation-agent/CreationAgentWorkspace.test.tsx
Normal 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();
|
||||
});
|
||||
493
src/components/creation-agent/CreationAgentWorkspace.tsx
Normal file
493
src/components/creation-agent/CreationAgentWorkspace.tsx
Normal 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;
|
||||
1
src/components/creation-agent/index.ts
Normal file
1
src/components/creation-agent/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './CreationAgentWorkspace';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('请总结一下当前已经成形的世界设定。');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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('拼图玩法');
|
||||
});
|
||||
|
||||
@@ -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="当前筛选下没有内容" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
180
src/components/platform-entry/PlatformEntryCreationTypeModal.tsx
Normal file
180
src/components/platform-entry/PlatformEntryCreationTypeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/platform-entry/PlatformEntryFlowShell.tsx
Normal file
17
src/components/platform-entry/PlatformEntryFlowShell.tsx
Normal 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;
|
||||
1504
src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
Normal file
1504
src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
Normal file
File diff suppressed because it is too large
Load Diff
9
src/components/platform-entry/PlatformEntryHomeView.tsx
Normal file
9
src/components/platform-entry/PlatformEntryHomeView.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 平台首页视图的通用出口。
|
||||
* 当前先复用成熟的首页实现,但避免让 `platform-entry` 继续直接依赖 RPG 命名组件。
|
||||
*/
|
||||
export {
|
||||
RpgEntryHomeView as PlatformEntryHomeView,
|
||||
type RpgEntryHomeViewProps as PlatformEntryHomeViewProps,
|
||||
type PlatformHomeTab,
|
||||
} from '../rpg-entry/RpgEntryHomeView';
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 平台作品详情视图的通用出口。
|
||||
* 这里保留平台语义封装,减少 Big Fish 继续挂在 RPG 命名路径上的误导。
|
||||
*/
|
||||
export {
|
||||
RpgEntryWorldDetailView as PlatformEntryWorldDetailView,
|
||||
type RpgEntryWorldDetailViewProps as PlatformEntryWorldDetailViewProps,
|
||||
} from '../rpg-entry/RpgEntryWorldDetailView';
|
||||
9
src/components/platform-entry/index.ts
Normal file
9
src/components/platform-entry/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
PlatformEntryFlowShell,
|
||||
type PlatformEntryFlowShellProps,
|
||||
type SelectionStage,
|
||||
} from './PlatformEntryFlowShell';
|
||||
export {
|
||||
PlatformEntryCreationTypeModal,
|
||||
type PlatformEntryCreationTypeModalProps,
|
||||
} from './PlatformEntryCreationTypeModal';
|
||||
9
src/components/platform-entry/platformEntryShared.ts
Normal file
9
src/components/platform-entry/platformEntryShared.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 平台入口共享 helper 的通用封装层。
|
||||
* 先复用既有实现,同时把多玩法入口依赖从 RPG 命名中隔离出来。
|
||||
*/
|
||||
export {
|
||||
buildCreationHubFallbackItems,
|
||||
normalizeAgentBackedProfile,
|
||||
resolveRpgCreationErrorMessage,
|
||||
} from '../rpg-entry/rpgEntryShared';
|
||||
41
src/components/platform-entry/platformEntryTypes.ts
Normal file
41
src/components/platform-entry/platformEntryTypes.ts
Normal 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;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 平台入口 bootstrap 通用封装。
|
||||
* 现阶段逻辑仍复用既有实现,但对外暴露平台语义命名。
|
||||
*/
|
||||
export { useRpgEntryBootstrap as usePlatformEntryBootstrap } from '../rpg-entry/useRpgEntryBootstrap';
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 平台入口详情态编排通用封装。
|
||||
* 通过平台语义出口避免 Big Fish 直接依赖 RPG 命名 hook。
|
||||
*/
|
||||
export { useRpgEntryLibraryDetail as usePlatformEntryLibraryDetail } from '../rpg-entry/useRpgEntryLibraryDetail';
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 平台入口导航通用封装。
|
||||
* 多玩法统一从 `platform-entry` 暴露,RPG 目录只保留兼容与 RPG 专属能力。
|
||||
*/
|
||||
export { useRpgEntryNavigation as usePlatformEntryNavigation } from '../rpg-entry/useRpgEntryNavigation';
|
||||
118
src/components/puzzle-agent/PuzzleAgentWorkspace.tsx
Normal file
118
src/components/puzzle-agent/PuzzleAgentWorkspace.tsx
Normal 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;
|
||||
115
src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx
Normal file
115
src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx
Normal 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;
|
||||
475
src/components/puzzle-result/PuzzleResultView.tsx
Normal file
475
src/components/puzzle-result/PuzzleResultView.tsx
Normal 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;
|
||||
397
src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
Normal file
397
src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
147
src/services/big-fish-creation/bigFishCreationClient.ts
Normal file
147
src/services/big-fish-creation/bigFishCreationClient.ts
Normal 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,
|
||||
};
|
||||
8
src/services/big-fish-creation/index.ts
Normal file
8
src/services/big-fish-creation/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
bigFishCreationClient,
|
||||
createBigFishCreationSession,
|
||||
executeBigFishCreationAction,
|
||||
getBigFishCreationSession,
|
||||
sendBigFishCreationMessage,
|
||||
streamBigFishCreationMessage,
|
||||
} from './bigFishCreationClient';
|
||||
68
src/services/big-fish-runtime/bigFishRuntimeClient.ts
Normal file
68
src/services/big-fish-runtime/bigFishRuntimeClient.ts
Normal 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,
|
||||
};
|
||||
6
src/services/big-fish-runtime/index.ts
Normal file
6
src/services/big-fish-runtime/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
bigFishRuntimeClient,
|
||||
getBigFishRuntimeRun,
|
||||
startBigFishRuntimeRun,
|
||||
submitBigFishRuntimeInput,
|
||||
} from './bigFishRuntimeClient';
|
||||
77
src/services/creation-agent/creationAgentProgress.ts
Normal file
77
src/services/creation-agent/creationAgentProgress.ts
Normal 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()}`;
|
||||
}
|
||||
105
src/services/creation-agent/creationAgentSse.ts
Normal file
105
src/services/creation-agent/creationAgentSse.ts
Normal 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;
|
||||
}
|
||||
2
src/services/creation-agent/index.ts
Normal file
2
src/services/creation-agent/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './creationAgentProgress';
|
||||
export * from './creationAgentSse';
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
5
src/services/platform-entry/index.ts
Normal file
5
src/services/platform-entry/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 平台入口服务通用封装。
|
||||
* 先复用既有资料看板读取逻辑,但对 `platform-entry` 暴露通用命名。
|
||||
*/
|
||||
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry';
|
||||
8
src/services/puzzle-agent/index.ts
Normal file
8
src/services/puzzle-agent/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createPuzzleAgentSession,
|
||||
executePuzzleAgentAction,
|
||||
getPuzzleAgentSession,
|
||||
puzzleAgentClient,
|
||||
sendPuzzleAgentMessage,
|
||||
streamPuzzleAgentMessage,
|
||||
} from './puzzleAgentClient';
|
||||
168
src/services/puzzle-agent/puzzleAgentClient.ts
Normal file
168
src/services/puzzle-agent/puzzleAgentClient.ts
Normal 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,
|
||||
};
|
||||
5
src/services/puzzle-gallery/index.ts
Normal file
5
src/services/puzzle-gallery/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getPuzzleGalleryDetail,
|
||||
listPuzzleGallery,
|
||||
puzzleGalleryClient,
|
||||
} from './puzzleGalleryClient';
|
||||
49
src/services/puzzle-gallery/puzzleGalleryClient.ts
Normal file
49
src/services/puzzle-gallery/puzzleGalleryClient.ts
Normal 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,
|
||||
};
|
||||
8
src/services/puzzle-runtime/index.ts
Normal file
8
src/services/puzzle-runtime/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
advancePuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
getPuzzleRun,
|
||||
puzzleRuntimeClient,
|
||||
startPuzzleRun,
|
||||
swapPuzzlePieces,
|
||||
} from './puzzleRuntimeClient';
|
||||
120
src/services/puzzle-runtime/puzzleRuntimeClient.ts
Normal file
120
src/services/puzzle-runtime/puzzleRuntimeClient.ts
Normal 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,
|
||||
};
|
||||
6
src/services/puzzle-works/index.ts
Normal file
6
src/services/puzzle-works/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getPuzzleWorkDetail,
|
||||
listPuzzleWorks,
|
||||
puzzleWorksClient,
|
||||
updatePuzzleWork,
|
||||
} from './puzzleWorksClient';
|
||||
85
src/services/puzzle-works/puzzleWorksClient.ts
Normal file
85
src/services/puzzle-works/puzzleWorksClient.ts
Normal 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,
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user