Files
Genarrative/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx
2026-05-02 17:56:42 +08:00

454 lines
15 KiB
TypeScript

import { ArrowLeft, ImagePlus, Loader2, Sparkles, X } from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
} from './puzzleImageModelOptions';
import { PuzzleImageModelPicker } from './PuzzleImageModelPicker';
type PuzzleAgentWorkspaceProps = {
session: PuzzleAgentSessionSnapshot | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitMessage: (payload: SendPuzzleAgentMessageRequest) => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
onAutoSaveForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
};
type PuzzleFormState = {
workTitle: string;
workDescription: string;
pictureDescription: string;
referenceImageSrc: string;
referenceImageLabel: string;
imageModel: PuzzleImageModelId;
};
const EMPTY_FORM_STATE: PuzzleFormState = {
workTitle: '',
workDescription: '',
pictureDescription: '',
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
};
function resolveInitialFormState(
session: PuzzleAgentSessionSnapshot | null,
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
): PuzzleFormState {
const formDraft = session?.draft?.formDraft;
if (formDraft) {
return {
workTitle: formDraft.workTitle ?? '',
workDescription: formDraft.workDescription ?? '',
pictureDescription: formDraft.pictureDescription ?? '',
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择参考图'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
};
}
if (initialFormPayload) {
return {
workTitle:
initialFormPayload.workTitle ?? initialFormPayload.seedText ?? '',
workDescription: initialFormPayload.workDescription ?? '',
pictureDescription: initialFormPayload.pictureDescription ?? '',
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择参考图'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
};
}
if (!session) {
return EMPTY_FORM_STATE;
}
return {
workTitle:
session.draft?.workTitle ||
session.draft?.levelName ||
session.seedText ||
session.anchorPack.themePromise.value ||
session.messages.find((message) => message.role === 'user')?.text ||
'',
workDescription:
session.draft?.workDescription ||
session.anchorPack.themePromise.value ||
'',
pictureDescription:
session.draft?.summary || session.anchorPack.visualSubject.value || '',
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
};
}
/**
* 拼图创作入口已从 Agent 对话改为填表式。
* 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。
*/
export function PuzzleAgentWorkspace({
session,
isBusy = false,
error = null,
onBack,
onExecuteAction,
onCreateFromForm,
onAutoSaveForm,
initialFormPayload = null,
}: PuzzleAgentWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
);
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const previousSessionIdRef = useRef<string | null>(
session?.sessionId ?? null,
);
const appliedInitialFormKeyRef = useRef<string | null>(null);
useEffect(() => {
const currentSessionId = session?.sessionId ?? null;
if (
currentSessionId &&
previousSessionIdRef.current === null &&
appliedInitialFormKeyRef.current ===
JSON.stringify(initialFormPayload ?? null)
) {
previousSessionIdRef.current = currentSessionId;
return;
}
previousSessionIdRef.current = currentSessionId;
const nextInitialFormKey =
currentSessionId ?? JSON.stringify(initialFormPayload ?? null);
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
return;
}
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
}, [initialFormPayload, session?.sessionId]);
const workTitle = formState.workTitle.trim();
const workDescription = formState.workDescription.trim();
const pictureDescription = formState.pictureDescription.trim();
const canSubmit =
Boolean(workTitle && workDescription && pictureDescription) && !isBusy;
const autosavePayload = useMemo(
() => ({
seedText: workTitle,
workTitle,
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
}),
[
formState.referenceImageSrc,
formState.imageModel,
pictureDescription,
workDescription,
workTitle,
],
);
const autosaveSignature = JSON.stringify([
autosavePayload.workTitle,
autosavePayload.workDescription,
autosavePayload.pictureDescription,
autosavePayload.imageModel,
]);
const lastAutosaveSignatureRef = useRef(autosaveSignature);
const autosaveSessionIdRef = useRef(session?.sessionId ?? null);
useEffect(() => {
const currentSessionId = session?.sessionId ?? null;
if (autosaveSessionIdRef.current === currentSessionId) {
return;
}
autosaveSessionIdRef.current = currentSessionId;
lastAutosaveSignatureRef.current = autosaveSignature;
}, [autosaveSignature, session?.sessionId]);
useEffect(() => {
if (
!session ||
session.stage !== 'collecting_anchors' ||
!session.draft?.formDraft ||
!onAutoSaveForm ||
lastAutosaveSignatureRef.current === autosaveSignature
) {
return;
}
const timer = window.setTimeout(() => {
lastAutosaveSignatureRef.current = autosaveSignature;
onAutoSaveForm(autosavePayload);
}, 700);
return () => window.clearTimeout(timer);
}, [
autosavePayload,
autosaveSignature,
onAutoSaveForm,
session?.draft?.formDraft,
session?.stage,
session?.sessionId,
]);
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
setFormState((current) => ({
...current,
referenceImageSrc: dataUrl,
referenceImageLabel: file.name.trim() || '本地参考图',
}));
setReferenceImageError(null);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
const submitForm = () => {
if (!canSubmit) {
return;
}
const payload = {
seedText: workTitle,
workTitle,
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
};
if (!session && onCreateFromForm) {
onCreateFromForm(payload);
return;
}
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: pictureDescription,
workTitle,
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
candidateCount: 1,
});
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start 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>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="space-y-5">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.workTitle}
disabled={isBusy}
onChange={(event) =>
setFormState((current) => ({
...current,
workTitle: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="作品名称"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.workDescription}
disabled={isBusy}
rows={4}
onChange={(event) =>
setFormState((current) => ({
...current,
workDescription: event.target.value,
}))
}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="作品描述"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<div className="relative mt-2">
<textarea
value={formState.pictureDescription}
disabled={isBusy}
rows={10}
onChange={(event) =>
setFormState((current) => ({
...current,
pictureDescription: event.target.value,
}))
}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<PuzzleImageModelPicker
value={formState.imageModel}
disabled={isBusy}
onChange={(imageModel) =>
setFormState((current) => ({
...current,
imageModel,
}))
}
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={
formState.referenceImageSrc ? '更换参考图' : '添加参考图'
}
>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
</div>
</label>
{formState.referenceImageSrc ? (
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<img
src={formState.referenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{formState.referenceImageLabel || '已选择参考图'}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setFormState((current) => ({
...current,
referenceImageSrc: '',
referenceImageLabel: '',
}));
setReferenceImageError(null);
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
</div>
) : null}
{referenceImageError ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{referenceImageError}
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</div>
</section>
</div>
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled={!canSubmit}
onClick={submitForm}
className={`platform-button platform-button--primary ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
2
</span>
</span>
</button>
</div>
</div>
);
}
export default PuzzleAgentWorkspace;