This commit is contained in:
2026-05-01 22:16:01 +08:00
parent 8d46c05129
commit 33dd105630
36 changed files with 1999 additions and 236 deletions

View File

@@ -100,6 +100,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
workDescription: '一套雨夜猫街主题拼图。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
imageModel: 'original',
});
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
@@ -129,10 +130,44 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
workDescription: '雾港遗迹拼图',
pictureDescription: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
imageModel: 'original',
candidateCount: 1,
});
});
test('puzzle workspace switches the image model from the description box', () => {
const onCreateFromForm = vi.fn();
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(screen.getByLabelText('作品描述'), {
target: { value: '一套雨夜猫街主题拼图。' },
});
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: '图片模型' }));
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
imageModel: 'gemini-3.1-flash-image-preview',
}),
);
});
test('puzzle workspace restores form draft fields and autosaves edits', () => {
vi.useFakeTimers();
const onAutoSaveForm = vi.fn();
@@ -208,5 +243,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
workDescription: '旧街雨夜的拼图草稿。',
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
imageModel: 'original',
});
});

View File

@@ -8,6 +8,12 @@ import type {
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_ORIGINAL,
type PuzzleImageModelId,
} from './puzzleImageModelOptions';
import { PuzzleImageModelPicker } from './PuzzleImageModelPicker';
type PuzzleAgentWorkspaceProps = {
session: PuzzleAgentSessionSnapshot | null;
@@ -27,6 +33,7 @@ type PuzzleFormState = {
pictureDescription: string;
referenceImageSrc: string;
referenceImageLabel: string;
imageModel: PuzzleImageModelId;
};
const EMPTY_FORM_STATE: PuzzleFormState = {
@@ -35,6 +42,7 @@ const EMPTY_FORM_STATE: PuzzleFormState = {
pictureDescription: '',
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_ORIGINAL,
};
function resolveInitialFormState(
@@ -51,6 +59,7 @@ function resolveInitialFormState(
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择参考图'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
};
}
@@ -64,6 +73,7 @@ function resolveInitialFormState(
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择参考图'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
};
}
@@ -87,6 +97,7 @@ function resolveInitialFormState(
session.draft?.summary || session.anchorPack.visualSubject.value || '',
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_ORIGINAL,
};
}
@@ -151,9 +162,11 @@ export function PuzzleAgentWorkspace({
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
}),
[
formState.referenceImageSrc,
formState.imageModel,
pictureDescription,
workDescription,
workTitle,
@@ -163,6 +176,7 @@ export function PuzzleAgentWorkspace({
autosavePayload.workTitle,
autosavePayload.workDescription,
autosavePayload.pictureDescription,
autosavePayload.imageModel,
]);
const lastAutosaveSignatureRef = useRef(autosaveSignature);
const autosaveSessionIdRef = useRef(session?.sessionId ?? null);
@@ -240,6 +254,7 @@ export function PuzzleAgentWorkspace({
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
};
if (!session && onCreateFromForm) {
@@ -254,6 +269,7 @@ export function PuzzleAgentWorkspace({
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
candidateCount: 1,
});
};
@@ -332,6 +348,16 @@ export function PuzzleAgentWorkspace({
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={

View File

@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import {
getPuzzleImageModelLabel,
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_OPTIONS,
type PuzzleImageModelId,
} from './puzzleImageModelOptions';
type PuzzleImageModelPickerProps = {
value: PuzzleImageModelId;
disabled?: boolean;
onChange: (value: PuzzleImageModelId) => void;
};
export function PuzzleImageModelPicker({
value,
disabled = false,
onChange,
}: PuzzleImageModelPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const normalizedValue = normalizePuzzleImageModel(value);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
window.addEventListener('pointerdown', handlePointerDown);
return () => window.removeEventListener('pointerdown', handlePointerDown);
}, [isOpen]);
return (
<div ref={rootRef} className="absolute bottom-3 left-3 z-10">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen((current) => !current)}
className={`inline-flex min-h-8 max-w-[10rem] items-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 px-3 text-[11px] font-bold text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label="图片模型"
title="图片模型"
>
<span className="truncate">
{getPuzzleImageModelLabel(normalizedValue)}
</span>
</button>
{isOpen ? (
<div
role="menu"
className="absolute bottom-10 left-0 min-w-[11rem] overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/98 p-1 shadow-[0_16px_40px_rgba(0,0,0,0.18)]"
>
{PUZZLE_IMAGE_MODEL_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
role="menuitemradio"
aria-checked={option.id === normalizedValue}
onClick={() => {
onChange(option.id);
setIsOpen(false);
}}
className={`block min-h-9 w-full rounded-[0.8rem] px-3 text-left text-xs font-bold transition ${
option.id === normalizedValue
? 'bg-amber-100/80 text-amber-800'
: 'text-[var(--platform-text-base)] hover:bg-[var(--platform-subpanel-fill)]'
}`}
>
{option.label}
</button>
))}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,33 @@
export const PUZZLE_IMAGE_MODEL_ORIGINAL = 'original';
export const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const PUZZLE_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export type PuzzleImageModelId =
| typeof PUZZLE_IMAGE_MODEL_ORIGINAL
| typeof PUZZLE_IMAGE_MODEL_GPT_IMAGE_2
| typeof PUZZLE_IMAGE_MODEL_NANOBANANA2;
export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{
id: PuzzleImageModelId;
label: string;
}> = [
{ id: PUZZLE_IMAGE_MODEL_ORIGINAL, label: '原模型' },
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' },
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' },
];
export function normalizePuzzleImageModel(
value: string | null | undefined,
): PuzzleImageModelId {
return (
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === value)?.id ??
PUZZLE_IMAGE_MODEL_ORIGINAL
);
}
export function getPuzzleImageModelLabel(model: PuzzleImageModelId) {
return (
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ??
'原模型'
);
}