1
This commit is contained in:
@@ -1,146 +1,299 @@
|
||||
import { ArrowLeft, ImagePlus, Loader2, Sparkles, X } from 'lucide-react';
|
||||
import { type ChangeEvent, useEffect, useState } from 'react';
|
||||
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleAgentActionRequest,
|
||||
PuzzleAgentOperationRecord,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import {
|
||||
buildCreationAgentChatMessage,
|
||||
createCreationAgentChatQuickActions,
|
||||
createCreationAgentClientMessageId,
|
||||
resolveCreationAgentQuickActionMessage,
|
||||
} 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;
|
||||
isStreamingReply?: boolean;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitMessage: (payload: SendPuzzleAgentMessageRequest) => void;
|
||||
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
|
||||
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
|
||||
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
|
||||
};
|
||||
|
||||
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',
|
||||
type PuzzleFormState = {
|
||||
title: string;
|
||||
pictureDescription: string;
|
||||
referenceImageSrc: string;
|
||||
referenceImageLabel: string;
|
||||
};
|
||||
|
||||
function mapPuzzleSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): CreationAgentSessionView {
|
||||
// 中文注释:生成进度与草稿写回记录不属于聊天历史,旧会话里的 action_result 也不再渲染为气泡。
|
||||
const chatMessages = session.messages.filter(
|
||||
(message) =>
|
||||
message.kind === 'chat' ||
|
||||
message.kind === 'summary' ||
|
||||
message.kind === 'warning',
|
||||
);
|
||||
const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||
title: '',
|
||||
pictureDescription: '',
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
};
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
// 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: session.currentTurn,
|
||||
progressPercent: session.progressPercent,
|
||||
anchors: [
|
||||
session.anchorPack.themePromise,
|
||||
session.anchorPack.visualSubject,
|
||||
session.anchorPack.visualMood,
|
||||
session.anchorPack.compositionHooks,
|
||||
session.anchorPack.tagsAndForbidden,
|
||||
],
|
||||
messages: chatMessages,
|
||||
recommendedReplies: [],
|
||||
};
|
||||
function readPuzzleReferenceImageAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('参考图读取失败,请重试。'));
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function mapPuzzleOperation(
|
||||
operation: PuzzleAgentOperationRecord | null | undefined,
|
||||
): CreationAgentOperationView | null {
|
||||
if (!operation) {
|
||||
return null;
|
||||
function resolveInitialFormState(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
|
||||
): PuzzleFormState {
|
||||
if (initialFormPayload) {
|
||||
return {
|
||||
title: initialFormPayload.seedText ?? '',
|
||||
pictureDescription: initialFormPayload.pictureDescription ?? '',
|
||||
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
|
||||
referenceImageLabel: initialFormPayload.referenceImageSrc
|
||||
? '已选择参考图'
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return EMPTY_FORM_STATE;
|
||||
}
|
||||
|
||||
return {
|
||||
operationId: operation.operationId,
|
||||
type: operation.type,
|
||||
status: operation.status,
|
||||
phaseLabel: operation.phaseLabel,
|
||||
phaseDetail: operation.phaseDetail,
|
||||
progress: operation.progress,
|
||||
error: operation.error,
|
||||
title:
|
||||
session.draft?.levelName ||
|
||||
session.anchorPack.themePromise.value ||
|
||||
session.messages.find((message) => message.role === 'user')?.text ||
|
||||
'',
|
||||
pictureDescription:
|
||||
session.draft?.summary || session.anchorPack.visualSubject.value || '',
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图 Agent 共创工作区只保留品类适配,聊天 UI 与进度管理统一走 CreationAgentWorkspace。
|
||||
* 拼图创作入口已从 Agent 对话改为填表式。
|
||||
* 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。
|
||||
*/
|
||||
export function PuzzleAgentWorkspace({
|
||||
session,
|
||||
activeOperation = null,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitMessage,
|
||||
onExecuteAction,
|
||||
onCreateFromForm,
|
||||
initialFormPayload = null,
|
||||
}: PuzzleAgentWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<PuzzleFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setReferenceImageError(null);
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const title = formState.title.trim();
|
||||
const pictureDescription = formState.pictureDescription.trim();
|
||||
const canSubmit = Boolean(title && pictureDescription) && !isBusy;
|
||||
|
||||
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: title,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
};
|
||||
|
||||
if (onCreateFromForm) {
|
||||
onCreateFromForm(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
onExecuteAction({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
candidateCount: 1,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CreationAgentWorkspace
|
||||
session={session ? mapPuzzleSession(session) : null}
|
||||
theme={PUZZLE_AGENT_THEME}
|
||||
loadingText="正在准备拼图共创工作区..."
|
||||
composerPlaceholder="说说题材、主体、气质或你不希望出现的元素..."
|
||||
primaryActionLabel="生成结果页"
|
||||
activeOperation={mapPuzzleOperation(activeOperation)}
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={isStreamingReply}
|
||||
isBusy={isBusy}
|
||||
error={error}
|
||||
quickActions={createCreationAgentChatQuickActions()}
|
||||
onBack={onBack}
|
||||
onSubmitText={(text) => {
|
||||
onSubmitMessage(
|
||||
buildCreationAgentChatMessage({
|
||||
clientMessageId: createCreationAgentClientMessageId('puzzle'),
|
||||
text,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onPrimaryAction={() => {
|
||||
onExecuteAction({ action: 'compile_puzzle_draft' });
|
||||
}}
|
||||
onQuickAction={(action) => {
|
||||
const quickActionMessage = resolveCreationAgentQuickActionMessage(
|
||||
action.key,
|
||||
'请总结一下当前已经成形的拼图设定。',
|
||||
);
|
||||
onSubmitMessage(
|
||||
buildCreationAgentChatMessage({
|
||||
clientMessageId: createCreationAgentClientMessageId('puzzle'),
|
||||
...quickActionMessage,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<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.title}
|
||||
disabled={isBusy}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
title: 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>
|
||||
<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="画面描述"
|
||||
/>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user