This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -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>
);
}