Refactor: split EditorAgent conversation components

This commit is contained in:
2026-07-08 10:30:28 +08:00
parent 19e6da8ff3
commit 5fb7ec03d1
5 changed files with 279 additions and 23 deletions
@@ -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 {
@@ -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 {