Refactor: split EditorAgent conversation components
This commit is contained in:
+6
-5
@@ -10,9 +10,10 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView';
|
||||
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
|
||||
import type { EditorAgentConversationClient } from './useEditorAgentConversation';
|
||||
import type { EditorAgentConversationClient } from '@/src/components/image-editor/useEditorAgentConversation.ts';
|
||||
import { useImageCanvasContextStore } from '@/src/components/image-editor/useImageCanvasContextStore.ts';
|
||||
|
||||
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView.tsx';
|
||||
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
@@ -20,7 +21,7 @@ const probeImageFileDimensionsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/image-editor/editorProjectClient')
|
||||
typeof import('../../../services/image-editor/editorProjectClient.ts')
|
||||
>('../../services/image-editor/editorProjectClient');
|
||||
return {
|
||||
...actual,
|
||||
@@ -33,7 +34,7 @@ vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./ImageCanvasFileModel', async () => {
|
||||
const actual = await vi.importActual<typeof import('./ImageCanvasFileModel')>(
|
||||
const actual = await vi.importActual<typeof import('../ImageCanvasFileModel.ts')>(
|
||||
'./ImageCanvasFileModel',
|
||||
);
|
||||
return {
|
||||
+20
-17
@@ -13,33 +13,40 @@ import {
|
||||
import {
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
type FormEvent,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
EDITOR_AGENT_MAX_ATTACHMENTS,
|
||||
type EditorAgentAttachmentRef,
|
||||
type EditorAgentGenerationResultEvent,
|
||||
type EditorAgentGenerationRecord,
|
||||
type EditorAgentMessage,
|
||||
type EditorAgentStage,
|
||||
} from '../../../packages/shared/src/contracts/editorAgent';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import { createEditorProjectResource } from '../../services/image-editor/editorProjectClient';
|
||||
import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes';
|
||||
import { probeImageFileDimensions } from './ImageCanvasFileModel';
|
||||
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
|
||||
} from '@/packages/shared/src/contracts';
|
||||
import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx';
|
||||
import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDangerConfirmDialog.tsx';
|
||||
import { UnifiedModal } from '@/src/components/common/UnifiedModal.tsx';
|
||||
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
|
||||
import {
|
||||
AttachmentChip,
|
||||
MessageBubble,
|
||||
} from '@/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
EditorAsset,
|
||||
} from '@/src/components/image-editor/ImageCanvasEditorTypes.ts';
|
||||
import { probeImageFileDimensions } from '@/src/components/image-editor/ImageCanvasFileModel.ts';
|
||||
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
|
||||
import { uploadEditorMediaAssetFile } from '@/src/services/image-editor/editorMediaAssetUploadClient.ts';
|
||||
import { createEditorProjectResource } from '@/src/services/image-editor/editorProjectClient.ts';
|
||||
|
||||
import {
|
||||
type EditorAgentConversationClient,
|
||||
useEditorAgentConversation,
|
||||
} from './useEditorAgentConversation';
|
||||
import { useImageCanvasContextStore } from '../useImageCanvasContextStore.ts';
|
||||
|
||||
type AttachmentPickerTab = 'canvas' | 'library';
|
||||
|
||||
@@ -62,10 +69,6 @@ function stopAgentPanelWheel(event: ReactWheelEvent<HTMLElement>) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function attachmentKey(attachment: EditorAgentAttachmentRef) {
|
||||
return `${attachment.source}:${attachment.referenceId}`;
|
||||
}
|
||||
|
||||
function isImageLayer(layer: CanvasLayer) {
|
||||
return (
|
||||
(layer.mediaType ?? 'image') === 'image' &&
|
||||
@@ -0,0 +1,247 @@
|
||||
import { FileAudio, Image as ImageIcon, Loader2, X } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
EditorAgentAttachmentRef,
|
||||
EditorAgentGenerationRecord,
|
||||
EditorAgentMessage,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
|
||||
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
|
||||
import { useResolvedAssetReadUrl } from '@/src/hooks/useResolvedAssetReadUrl.ts';
|
||||
|
||||
function toolLabel(toolName: EditorAgentGenerationRecord['toolName']) {
|
||||
if (toolName === 'edit_image') {
|
||||
return '修改图片';
|
||||
}
|
||||
if (toolName === 'generate_character') {
|
||||
return '生成角色';
|
||||
}
|
||||
if (toolName === 'generate_icon_spritesheet') {
|
||||
return '生成图标';
|
||||
}
|
||||
if (toolName === 'generate_ui_design') {
|
||||
return '生成 UI';
|
||||
}
|
||||
if (toolName === 'generate_video') {
|
||||
return '生成视频';
|
||||
}
|
||||
if (toolName === 'generate_sound_effect') {
|
||||
return '生成音效';
|
||||
}
|
||||
if (toolName === 'generate_background_music') {
|
||||
return '生成背景音乐';
|
||||
}
|
||||
return '生成图片';
|
||||
}
|
||||
|
||||
function messageRoleLabel(role: EditorAgentMessage['role']) {
|
||||
return role === 'user' ? '你' : 'Agent';
|
||||
}
|
||||
|
||||
export function AttachmentChip({
|
||||
attachment,
|
||||
onRemove,
|
||||
}: {
|
||||
attachment: EditorAgentAttachmentRef;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
const label = attachment.label?.trim() || attachment.referenceId;
|
||||
return (
|
||||
<span className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm">
|
||||
<ImageIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{label}</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-1 inline-flex h-5 w-5 items-center justify-center rounded-full text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||
aria-label={`移除附件 ${label}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
{attachment.thumbnailSrc || attachment.imageSrc ? (
|
||||
<span className="pointer-events-none absolute bottom-[calc(100%+0.4rem)] left-0 hidden rounded-2xl border border-white bg-white p-1 shadow-xl group-hover:block">
|
||||
<ResolvedAssetImage
|
||||
src={attachment.thumbnailSrc ?? attachment.imageSrc}
|
||||
objectKey={attachment.objectKey}
|
||||
refreshKey={attachment.referenceId}
|
||||
alt=""
|
||||
className="h-24 w-24 rounded-xl object-cover"
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerationRecordsView({
|
||||
generations,
|
||||
}: {
|
||||
generations: EditorAgentGenerationRecord[];
|
||||
}) {
|
||||
if (!generations.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{generations.map((generation) => (
|
||||
<div
|
||||
key={generation.toolCallId}
|
||||
className="rounded-2xl border border-slate-200 bg-white/80 p-2 text-xs text-slate-600"
|
||||
>
|
||||
{generation.status === 'generating' || generation.error ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{generation.status === 'generating' ? (
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
<span>{toolLabel(generation.toolName)}</span>
|
||||
{generation.model ? <span>{generation.model}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{generation.error ? (
|
||||
<div className="mt-1 text-red-600">{generation.error}</div>
|
||||
) : null}
|
||||
{generation.images.length ? (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
{generation.images.map((image, index) => (
|
||||
<div
|
||||
key={`${generation.toolCallId}-${image.resourceId ?? index}`}
|
||||
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={image.thumbnailSrc ?? image.imageSrc}
|
||||
objectKey={image.objectKey}
|
||||
refreshKey={
|
||||
image.resourceId ??
|
||||
generation.taskId ??
|
||||
generation.toolCallId
|
||||
}
|
||||
alt=""
|
||||
className="h-20 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{generation.videos.length ? (
|
||||
<div className="mt-2 grid grid-cols-1 gap-2">
|
||||
{generation.videos.map((video, index) => (
|
||||
<GenerationVideoPreview
|
||||
key={`${generation.toolCallId}-${video.resourceId ?? index}`}
|
||||
video={video}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{generation.audios.length ? (
|
||||
<div className="mt-2 grid grid-cols-1 gap-2">
|
||||
{generation.audios.map((audio, index) => (
|
||||
<GenerationAudioPreview
|
||||
key={`${generation.toolCallId}-${audio.resourceId ?? index}`}
|
||||
audio={audio}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerationVideoPreview({
|
||||
video,
|
||||
}: {
|
||||
video: EditorAgentGenerationRecord['videos'][number];
|
||||
}) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(video.videoSrc, {
|
||||
objectKey: video.objectKey,
|
||||
});
|
||||
const posterSrc = video.thumbnailSrc ?? undefined;
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-slate-950">
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
poster={posterSrc}
|
||||
// An empty string ("") was passed to the src attribute. This may cause the browser to download the whole page again over the network.
|
||||
src={resolvedUrl == '' ? null : resolvedUrl}
|
||||
className="h-24 w-full bg-slate-950 object-cover"
|
||||
aria-label="生成视频预览"
|
||||
/>
|
||||
{video.objectKey || video.durationSeconds ? (
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1 text-[10px] text-slate-300">
|
||||
<span className="truncate">{video.assetKind ?? 'video'}</span>
|
||||
{video.durationSeconds ? <span>{video.durationSeconds}s</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerationAudioPreview({
|
||||
audio,
|
||||
}: {
|
||||
audio: EditorAgentGenerationRecord['audios'][number];
|
||||
}) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(audio.audioSrc, {
|
||||
objectKey: audio.objectKey,
|
||||
});
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<FileAudio className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span className="truncate">{audio.audioKind ?? 'audio'}</span>
|
||||
{audio.durationSeconds ? <span>{audio.durationSeconds}s</span> : null}
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
preload="metadata"
|
||||
src={resolvedUrl == '' ? null : resolvedUrl}
|
||||
className="h-16 w-full"
|
||||
aria-label="生成音频预览"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: { message: EditorAgentMessage }) {
|
||||
const isUser = message.role === 'user';
|
||||
return (
|
||||
<article
|
||||
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
|
||||
aria-label={`${messageRoleLabel(message.role)}消息`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
|
||||
isUser
|
||||
? 'bg-slate-900 text-white'
|
||||
: message.kind === 'error'
|
||||
? 'border border-red-200 bg-red-50 text-red-700'
|
||||
: 'border border-slate-200 bg-white text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{message.text || (message.status === 'streaming' ? '...' : '')}
|
||||
</div>
|
||||
{message.attachments.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{message.attachments.map((attachment) => (
|
||||
<AttachmentChip
|
||||
key={attachmentKey(attachment)}
|
||||
attachment={attachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<GenerationRecordsView generations={message.generations} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { EditorAgentAttachmentRef } from '@/packages/shared/src/contracts';
|
||||
|
||||
export function attachmentKey(attachment: EditorAgentAttachmentRef) {
|
||||
return `${attachment.source}:${attachment.referenceId}`;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
|
||||
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView';
|
||||
import { EditorAgentConversationPanelView } from '@/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx';
|
||||
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
|
||||
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
|
||||
import type {
|
||||
|
||||
Reference in New Issue
Block a user