454 lines
15 KiB
TypeScript
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;
|