314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
import { ArrowLeft, CheckCircle2, Puzzle } from 'lucide-react';
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
|
||
import type {
|
||
CreativeAgentInputPart,
|
||
CreativeAgentSessionSnapshot,
|
||
CreativeAgentSseEvent,
|
||
} from '../../../packages/shared/src/contracts/creativeAgent';
|
||
import type {
|
||
PuzzleCreativeTemplateProtocol,
|
||
PuzzleCreativeTemplateSelection,
|
||
} from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
|
||
import { CreativeAgentInputComposer } from './CreativeAgentInputComposer';
|
||
import { CreativeAgentProcessPanel } from './CreativeAgentProcessPanel';
|
||
import { CreativeAgentStageTimeline } from './CreativeAgentStageTimeline';
|
||
import { CreativeAgentTemplateConfirmPanel } from './CreativeAgentTemplateConfirmPanel';
|
||
import {
|
||
buildCreativeAgentProcessItems,
|
||
buildPuzzleTemplateSelectionFromProtocol,
|
||
createCreativeAgentClientMessageId,
|
||
CREATIVE_AGENT_STAGE_LABEL,
|
||
} from './creativeAgentViewModel';
|
||
|
||
type CreativeAgentWorkspaceProps = {
|
||
session: CreativeAgentSessionSnapshot | null;
|
||
isBusy: boolean;
|
||
isStreaming: boolean;
|
||
error: string | null;
|
||
eventLog: CreativeAgentSseEvent[];
|
||
onBack: () => void;
|
||
onSubmitMessage: (payload: {
|
||
clientMessageId: string;
|
||
content: CreativeAgentInputPart[];
|
||
}) => void;
|
||
onConfirmTemplate: (selection: PuzzleCreativeTemplateSelection) => void;
|
||
onCancelTemplate?: () => void;
|
||
onOpenTarget: () => void;
|
||
};
|
||
|
||
type CreativeAgentTemplateCatalogPanelProps = {
|
||
templates: PuzzleCreativeTemplateProtocol[];
|
||
isBusy: boolean;
|
||
onSelect: (template: PuzzleCreativeTemplateProtocol) => void;
|
||
};
|
||
|
||
function CreativeAgentTemplateCatalogPanel({
|
||
templates,
|
||
isBusy,
|
||
onSelect,
|
||
}: CreativeAgentTemplateCatalogPanelProps) {
|
||
if (templates.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
{templates.map((template) => (
|
||
<button
|
||
key={template.templateId}
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => onSelect(template)}
|
||
className="group min-h-[10.5rem] rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-3 text-left transition hover:-translate-y-0.5 hover:bg-white/88 disabled:opacity-55"
|
||
>
|
||
<div className="overflow-hidden rounded-[0.95rem] border border-white/70 bg-[radial-gradient(circle_at_28%_20%,rgba(255,255,255,0.92),transparent_32%),linear-gradient(135deg,rgba(255,194,123,0.86),rgba(255,93,132,0.82)_52%,rgba(92,186,255,0.78))]">
|
||
<div className="flex aspect-[16/9] items-center justify-center">
|
||
{template.previewImageSrc ? (
|
||
<img
|
||
src={template.previewImageSrc}
|
||
alt={template.title}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/84 text-[var(--platform-text-strong)] shadow-sm">
|
||
<Puzzle className="h-4 w-4" />
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 text-sm font-black text-[var(--platform-text-strong)]">
|
||
{template.title}
|
||
</div>
|
||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||
{template.summary}
|
||
</div>
|
||
<div className="mt-3 text-xs font-bold text-[var(--platform-text-soft)]">
|
||
{`${template.defaultLevelCount} 关 · ${template.costRange.minPoints}-${template.costRange.maxPoints} 泥点`}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function CreativeAgentWorkspace({
|
||
session,
|
||
isBusy,
|
||
isStreaming,
|
||
error,
|
||
eventLog,
|
||
onBack,
|
||
onSubmitMessage,
|
||
onConfirmTemplate,
|
||
onCancelTemplate,
|
||
onOpenTarget,
|
||
}: CreativeAgentWorkspaceProps) {
|
||
const stage = session?.stage ?? 'idle';
|
||
const messages = session?.messages ?? [];
|
||
const selection = session?.puzzleTemplateSelection ?? null;
|
||
const templateCatalog = session?.puzzleTemplateCatalog ?? [];
|
||
const targetBinding = session?.targetBinding ?? null;
|
||
const [pendingSelection, setPendingSelection] =
|
||
useState<PuzzleCreativeTemplateSelection | null>(null);
|
||
useEffect(() => {
|
||
// 中文注释:会话切换时清掉本地待确认模板,避免上一轮选择残留到新会话。
|
||
setPendingSelection(null);
|
||
}, [session?.sessionId]);
|
||
const processItems = useMemo(
|
||
() => buildCreativeAgentProcessItems(eventLog, session),
|
||
[eventLog, session],
|
||
);
|
||
const visibleSelection = targetBinding ? null : (selection ?? pendingSelection);
|
||
const shouldShowTemplateCatalog =
|
||
!targetBinding &&
|
||
!selection &&
|
||
templateCatalog.length > 0 &&
|
||
stage === 'waiting_template_confirmation';
|
||
|
||
return (
|
||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)] xl:px-1">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
disabled={isBusy}
|
||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||
>
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<ArrowLeft className="h-3.5 w-3.5" />
|
||
返回
|
||
</span>
|
||
</button>
|
||
<div className="platform-pill platform-pill--cool px-3 text-[11px]">
|
||
{CREATIVE_AGENT_STAGE_LABEL[stage]}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||
<div className="space-y-4 pb-4">
|
||
<section className="platform-surface platform-surface--hero relative overflow-hidden rounded-[1.6rem] px-4 py-5 sm:px-5">
|
||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||
<div className="relative z-10 flex items-end justify-between gap-4">
|
||
<div className="min-w-0">
|
||
<div className="text-2xl font-black leading-tight text-white sm:text-3xl">
|
||
智能创作
|
||
</div>
|
||
<div className="mt-2 max-w-xl text-sm font-semibold leading-6 text-zinc-100/86">
|
||
当前生成拼图草稿
|
||
</div>
|
||
</div>
|
||
<span className="hidden h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white/18 text-white sm:inline-flex">
|
||
<Puzzle className="h-5 w-5" />
|
||
</span>
|
||
</div>
|
||
</section>
|
||
|
||
<CreativeAgentStageTimeline stage={stage} />
|
||
|
||
{targetBinding ? (
|
||
<section className="platform-subpanel flex flex-col gap-3 rounded-[1.35rem] p-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]">
|
||
<CheckCircle2 className="h-5 w-5" />
|
||
</span>
|
||
<div>
|
||
<div className="text-base font-black text-[var(--platform-text-strong)]">
|
||
拼图草稿已就绪
|
||
</div>
|
||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||
{targetBinding.targetStage === 'puzzle-result'
|
||
? '可以进入结果页继续编辑'
|
||
: '可以进入拼图工作区继续处理'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={onOpenTarget}
|
||
className="platform-button platform-button--primary"
|
||
>
|
||
打开草稿
|
||
</button>
|
||
</section>
|
||
) : null}
|
||
|
||
{messages.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{messages.map((message) => (
|
||
<div
|
||
key={message.id}
|
||
className={`max-w-[86%] rounded-[1.15rem] px-4 py-3 text-sm leading-6 ${
|
||
message.role === 'user'
|
||
? 'ml-auto bg-[var(--platform-button-primary-solid)] text-[var(--platform-button-primary-text)]'
|
||
: 'platform-subpanel text-[var(--platform-text-base)]'
|
||
}`}
|
||
>
|
||
{message.text}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="platform-subpanel rounded-[1.35rem] p-4 text-sm font-semibold text-[var(--platform-text-base)]">
|
||
发一句想法,或加一张参考图。
|
||
</div>
|
||
)}
|
||
|
||
<CreativeAgentProcessPanel
|
||
items={processItems}
|
||
isStreaming={isStreaming}
|
||
/>
|
||
|
||
{shouldShowTemplateCatalog ? (
|
||
<CreativeAgentTemplateCatalogPanel
|
||
templates={templateCatalog}
|
||
isBusy={isBusy || isStreaming}
|
||
onSelect={(template) => {
|
||
setPendingSelection(
|
||
buildPuzzleTemplateSelectionFromProtocol(template),
|
||
);
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{session?.puzzleImageGenerationPlan ? (
|
||
<div className="platform-subpanel rounded-[1.35rem] p-4">
|
||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
关卡计划
|
||
</div>
|
||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||
{session.puzzleImageGenerationPlan.levels.map((level) => (
|
||
<div
|
||
key={level.levelId}
|
||
className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/58 px-3 py-3"
|
||
>
|
||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||
{level.levelName}
|
||
</div>
|
||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||
{level.pictureDescription}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{error ? (
|
||
<div className="platform-banner platform-banner--danger rounded-[1.25rem] text-sm leading-6">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||
<CreativeAgentInputComposer
|
||
isBusy={isBusy || isStreaming}
|
||
onSubmit={({ text, image }) => {
|
||
const content: CreativeAgentInputPart[] = [];
|
||
if (text) {
|
||
content.push({
|
||
type: 'input_text',
|
||
text,
|
||
});
|
||
}
|
||
if (image) {
|
||
content.push({
|
||
type: 'input_image',
|
||
imageUrl: image.imageUrl,
|
||
thumbnailUrl: image.thumbnailUrl,
|
||
assetId: null,
|
||
});
|
||
}
|
||
onSubmitMessage({
|
||
clientMessageId: createCreativeAgentClientMessageId(),
|
||
content,
|
||
});
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{visibleSelection && visibleSelection.requiresUserConfirmation ? (
|
||
<CreativeAgentTemplateConfirmPanel
|
||
selection={visibleSelection}
|
||
isBusy={isBusy || isStreaming}
|
||
onConfirm={(nextSelection) => {
|
||
setPendingSelection(null);
|
||
onConfirmTemplate(nextSelection);
|
||
}}
|
||
onCancel={() => {
|
||
setPendingSelection(null);
|
||
onCancelTemplate?.();
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default CreativeAgentWorkspace;
|