Files
Genarrative/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx
T

764 lines
25 KiB
TypeScript

import {
Bot,
ChevronDown,
Loader2,
MessageCircle,
Paperclip,
Plus,
Send,
Trash2,
X,
} from 'lucide-react';
import {
type ClipboardEvent as ReactClipboardEvent,
type FormEvent,
useEffect,
useMemo,
useState,
type WheelEvent as ReactWheelEvent,
} from 'react';
import {
EDITOR_AGENT_MAX_ATTACHMENTS,
type EditorAgentAttachmentRef,
} from '@/packages/shared/src/contracts';
import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx';
import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDangerConfirmDialog.tsx';
import { PlatformToolModalShell } from '@/src/components/common/PlatformToolModalShell.tsx';
import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx';
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
import {
MessageBubble,
ThinkingBubble,
} 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 { useImageCanvasContextStore } from '../useImageCanvasContextStore.ts';
import {
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation';
type AttachmentPickerTab = 'canvas' | 'library';
type EditorAgentAttachmentOption = {
key: string;
sourceLabel: string;
attachment: EditorAgentAttachmentRef;
};
type EditorAgentConversationPanelViewProps = {
open: boolean;
onToggleOpen: () => void;
layers?: CanvasLayer[];
assets?: EditorAsset[];
onCanvasRefreshRequested?: () => void;
// TODO refactor: move the task list update seperate
onConfirmSent?: () => void;
client?: EditorAgentConversationClient;
};
function stopAgentPanelWheel(event: ReactWheelEvent<HTMLElement>) {
event.stopPropagation();
}
function isImageLayer(layer: CanvasLayer) {
return (
(layer.mediaType ?? 'image') === 'image' &&
Boolean(layer.resourceId?.trim()) &&
layer.src.trim()
);
}
function isImageAsset(asset: EditorAsset) {
return (asset.mediaType ?? 'image') === 'image' && asset.src.trim();
}
function createCanvasAttachmentOptions(
layers: CanvasLayer[] = [],
): EditorAgentAttachmentOption[] {
return layers.filter(isImageLayer).map((layer) => {
const attachment: EditorAgentAttachmentRef = {
source: 'canvas_resource',
referenceId: layer.resourceId || layer.id,
objectKey: layer.objectKey ?? null,
imageSrc: layer.src,
thumbnailSrc: layer.thumbnailSrc ?? null,
label: layer.title,
width: layer.width,
height: layer.height,
};
return {
key: attachmentKey(attachment),
sourceLabel: '画布',
attachment,
};
});
}
function createLibraryAttachmentOptions(
assets: EditorAsset[] = [],
): EditorAgentAttachmentOption[] {
return assets.filter(isImageAsset).map((asset) => {
const attachment: EditorAgentAttachmentRef = {
source: 'library_asset',
referenceId: asset.id,
objectKey: asset.objectKey ?? null,
imageSrc: asset.src,
thumbnailSrc: asset.thumbnailSrc ?? null,
label: asset.label,
width: asset.width,
height: asset.height,
};
return {
key: attachmentKey(attachment),
sourceLabel: '素材库',
attachment,
};
});
}
function AttachmentPickerModal({
open,
tab,
canvasOptions,
libraryOptions,
selectedKeys,
attachmentError,
onTabChange,
onToggleKey,
onApply,
onClose,
}: {
open: boolean;
tab: AttachmentPickerTab;
canvasOptions: EditorAgentAttachmentOption[];
libraryOptions: EditorAgentAttachmentOption[];
selectedKeys: Set<string>;
attachmentError: string | null;
onTabChange: (tab: AttachmentPickerTab) => void;
onToggleKey: (key: string) => void;
onApply: () => void;
onClose: () => void;
}) {
const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions;
return (
<PlatformToolModalShell
open={open}
title="选择图片附件"
size="md"
onClose={onClose}
footer={
<>
<PlatformActionButton tone="ghost" size="sm" onClick={onClose}>
取消
</PlatformActionButton>
<PlatformActionButton tone="primary" size="sm" onClick={onApply}>
应用
</PlatformActionButton>
</>
}
>
<div className="flex min-h-[18rem] flex-col gap-3">
<div className="flex gap-2">
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
tab === 'canvas'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}
onClick={() => onTabChange('canvas')}
>
画布
</button>
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
tab === 'library'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}
onClick={() => onTabChange('library')}
>
素材库
</button>
</div>
{attachmentError ? (
<div className="rounded-2xl bg-amber-50 px-3 py-2 text-sm text-amber-700">
{attachmentError}
</div>
) : null}
<div className="grid grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
{visibleOptions.map((option) => {
const label =
option.attachment.label?.trim() || option.attachment.referenceId;
return (
<label
key={option.key}
className="flex cursor-pointer flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-2 text-sm text-slate-700 shadow-sm"
>
<ResolvedAssetImage
src={
option.attachment.thumbnailSrc ?? option.attachment.imageSrc
}
objectKey={option.attachment.objectKey}
refreshKey={option.attachment.referenceId}
alt=""
className="aspect-square rounded-xl bg-slate-100 object-cover"
/>
<span className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`选择${option.sourceLabel}图片 ${label}`}
checked={selectedKeys.has(option.key)}
onChange={() => onToggleKey(option.key)}
/>
<span className="truncate">{label}</span>
</span>
</label>
);
})}
</div>
</div>
</PlatformToolModalShell>
);
}
export function EditorAgentConversationPanelView({
open,
onToggleOpen,
layers = [],
assets = [],
onCanvasRefreshRequested,
onConfirmSent,
client,
}: EditorAgentConversationPanelViewProps) {
const [hasConversationMounted, setHasConversationMounted] = useState(open);
useEffect(() => {
if (open) {
setHasConversationMounted(true);
}
}, [open]);
const projectId = useImageCanvasContextStore((state) => state.projectId);
const effectiveProjectId = hasConversationMounted ? projectId : null;
const {
conversations,
activeConversation,
activeConversationId,
messages,
isLoadingConversations,
isLoadingMessages,
isCreatingConversation,
isDeletingConversation,
isWaiting,
isPatienceNoticeVisible,
toolCallAction,
isToolCallActionPending,
errorMessage,
createConversation,
selectConversation,
refreshActiveConversation,
sendMessage,
confirmToolCall,
cancelToolCall,
deleteActiveConversation,
} = useEditorAgentConversation({
projectId: effectiveProjectId,
client,
onCanvasRefreshRequested,
onConfirmSent,
});
const [draftText, setDraftText] = useState('');
const [attachments, setAttachments] = useState<EditorAgentAttachmentRef[]>(
[],
);
const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false);
const [attachmentPickerTab, setAttachmentPickerTab] =
useState<AttachmentPickerTab>('canvas');
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [isPastingAttachment, setIsPastingAttachment] = useState(false);
const [draftAttachmentKeys, setDraftAttachmentKeys] = useState<Set<string>>(
() => new Set(),
);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const canvasAttachmentOptions = useMemo(
() => createCanvasAttachmentOptions(layers),
[layers],
);
const libraryAttachmentOptions = useMemo(
() => createLibraryAttachmentOptions(assets),
[assets],
);
const attachmentOptionsByKey = useMemo(() => {
const optionMap = new Map<string, EditorAgentAttachmentOption>();
[...canvasAttachmentOptions, ...libraryAttachmentOptions].forEach(
(option) => optionMap.set(option.key, option),
);
return optionMap;
}, [canvasAttachmentOptions, libraryAttachmentOptions]);
const hasProject = Boolean(projectId?.trim());
const isConversationBusy = isWaiting || isToolCallActionPending;
const hasCurrentConversationContent = messages.length > 0;
const isConversationSelectDisabled =
!conversations.length || isLoadingConversations || isConversationBusy;
const currentConversationTitle = activeConversation?.title ?? '新对话';
const handleCreateConversation = () => {
void createConversation()
.then(() => {
setDraftText('');
setAttachments([]);
setAttachmentError(null);
setDraftAttachmentKeys(new Set());
setAttachmentPickerOpen(false);
})
.catch(() => undefined);
};
const openAttachmentPicker = () => {
setAttachmentError(null);
setDraftAttachmentKeys(new Set(attachments.map(attachmentKey)));
setAttachmentPickerOpen(true);
};
const toggleAttachmentKey = (key: string) => {
setDraftAttachmentKeys((currentKeys) => {
const nextKeys = new Set(currentKeys);
if (nextKeys.has(key)) {
nextKeys.delete(key);
} else {
nextKeys.add(key);
}
return nextKeys;
});
};
const applyAttachmentSelection = () => {
const nextAttachments = Array.from(draftAttachmentKeys)
.map((key) => attachmentOptionsByKey.get(key)?.attachment)
.filter((attachment): attachment is EditorAgentAttachmentRef =>
Boolean(attachment),
);
if (nextAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
return;
}
setAttachments(nextAttachments);
setAttachmentPickerOpen(false);
};
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isWaiting) {
return;
}
if (isPastingAttachment) {
return;
}
const text = draftText.trim();
if (!text && !attachments.length) {
return;
}
setDraftText('');
const nextAttachments = attachments;
setAttachments([]);
void sendMessage(text, nextAttachments).catch(() => {
setDraftText((currentText) => (currentText ? currentText : text));
setAttachments((currentAttachments) =>
currentAttachments.length ? currentAttachments : nextAttachments,
);
});
};
const appendAttachments = (nextAttachments: EditorAgentAttachmentRef[]) => {
function mergeAttachments(
currentAttachments: EditorAgentAttachmentRef[],
nextAttachments: EditorAgentAttachmentRef[],
) {
const merged = [...currentAttachments];
const existingKeys = new Set(currentAttachments.map(attachmentKey));
nextAttachments.forEach((attachment) => {
const key = attachmentKey(attachment);
if (!existingKeys.has(key)) {
existingKeys.add(key);
merged.push(attachment);
}
});
return merged;
}
const mergedAttachments = mergeAttachments(attachments, nextAttachments);
if (mergedAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
return false;
}
setAttachments(mergedAttachments);
setAttachmentError(null);
return true;
};
const createPastedAgentImageAttachment = async (
file: File,
): Promise<EditorAgentAttachmentRef> => {
if (!projectId?.trim()) {
throw new Error('缺少画布项目');
}
const [upload, dimensions] = await Promise.all([
uploadEditorMediaAssetFile(file, 'image', {
pathSegments: ['editor', 'agent-paste', 'image', `${Date.now()}`],
entityId: projectId,
metadata: {
source: 'agent-input-paste',
},
}),
probeImageFileDimensions(file),
]);
const width = dimensions?.width ?? 1;
const height = dimensions?.height ?? 1;
const resource = await createEditorProjectResource(projectId, {
imageSrc: upload.src,
objectKey: upload.objectKey,
assetObjectId: upload.assetObjectId,
width,
height,
sourceType: 'uploaded',
});
return {
source: 'canvas_resource',
referenceId: resource.resourceId,
objectKey: resource.objectKey ?? upload.objectKey,
imageSrc: resource.imageSrc,
thumbnailSrc: null,
label: resource.label ?? '粘贴图片',
width: resource.width,
height: resource.height,
};
};
function extractClipboardImageFiles(
clipboardData: DataTransfer | null,
): File[] {
if (!clipboardData) {
return [];
}
const fileItems = Array.from(clipboardData.files ?? []).filter((file) =>
file.type.startsWith('image/'),
);
return [...fileItems];
}
const handleInputPaste = (
event: ReactClipboardEvent<HTMLTextAreaElement>,
) => {
const imageFiles = extractClipboardImageFiles(event.clipboardData);
if (!imageFiles.length) {
return;
}
if (isPastingAttachment) {
return;
}
event.preventDefault();
if (attachments.length >= EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
return;
}
const remainingAttachmentSlots =
EDITOR_AGENT_MAX_ATTACHMENTS - attachments.length;
const uploadFiles = imageFiles.slice(0, remainingAttachmentSlots);
const hasOverflow = uploadFiles.length < imageFiles.length;
// TODO: deduplicate those existing assets
setIsPastingAttachment(true);
setAttachmentError('图片上传中');
void Promise.all(
uploadFiles.map((file) => createPastedAgentImageAttachment(file)),
)
.then((pastedAttachments) => {
if (appendAttachments(pastedAttachments) && hasOverflow) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
}
})
.catch(() => {
setAttachmentError('图片粘贴失败,请重试');
})
.finally(() => {
setIsPastingAttachment(false);
});
};
const removeAttachment = (targetAttachment: EditorAgentAttachmentRef) => {
const key = attachmentKey(targetAttachment);
setAttachments((currentAttachments) =>
currentAttachments.filter(
(attachment) => attachmentKey(attachment) !== key,
),
);
};
if (!open) {
return (
<button
type="button"
className="absolute bottom-24 right-4 z-40 inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/95 px-3 py-2 text-sm font-semibold text-slate-700 shadow-lg backdrop-blur hover:bg-white"
aria-label="打开画布 Agent"
onClick={onToggleOpen}
>
<MessageCircle className="h-4 w-4" aria-hidden="true" />
Agent
</button>
);
}
return (
<>
<aside
className="absolute inset-y-0 right-0 z-50 flex w-full flex-col border-l border-slate-200 bg-slate-50/95 shadow-2xl backdrop-blur sm:inset-y-3 sm:right-3 sm:w-[390px] sm:overflow-hidden sm:rounded-3xl sm:border"
aria-label="画布 Agent 对话"
onPointerDown={(event) => event.stopPropagation()}
onWheel={stopAgentPanelWheel}
onWheelCapture={stopAgentPanelWheel}
>
<header className="flex items-center gap-2 border-b border-slate-200 bg-white/90 px-3 py-3">
<Bot className="h-5 w-5 text-slate-700" aria-hidden="true" />
<div
className="relative min-w-0 flex-1 rounded-full focus-within:ring-2 focus-within:ring-slate-300"
title={currentConversationTitle}
>
<div
className={`pointer-events-none flex h-9 w-full items-center rounded-full border border-slate-200 bg-white px-3 pr-10 text-sm ${
isConversationSelectDisabled
? 'text-slate-400'
: 'text-slate-800'
}`}
aria-hidden="true"
>
<span
className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
data-testid="active-conversation-title"
>
{currentConversationTitle}
</span>
<ChevronDown
className="absolute right-[10px] top-1/2 h-4 w-4 -translate-y-1/2"
data-testid="conversation-switch-chevron"
aria-hidden="true"
/>
</div>
<select
className="absolute inset-0 z-10 h-full w-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed"
aria-label="当前对话"
value={activeConversationId ?? ''}
disabled={isConversationSelectDisabled}
onChange={(event) => {
const nextConversationId = event.currentTarget.value;
if (nextConversationId) {
void selectConversation(nextConversationId);
}
}}
>
{conversations.length ? (
conversations.map((conversation) => (
<option
key={conversation.conversationId}
value={conversation.conversationId}
>
{conversation.title}
</option>
))
) : (
<option value="">新对话</option>
)}
</select>
</div>
<button
type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-white disabled:opacity-45"
aria-label="新建对话"
disabled={
!hasProject ||
!hasCurrentConversationContent ||
isLoadingConversations ||
isLoadingMessages ||
isCreatingConversation ||
isConversationBusy ||
isPastingAttachment
}
onClick={handleCreateConversation}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-white text-slate-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-45"
aria-label="删除当前对话"
disabled={
!activeConversationId ||
isDeletingConversation ||
isConversationBusy
}
onClick={() => setDeleteConfirmOpen(true)}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-white text-slate-500 hover:bg-slate-100"
aria-label="收起画布 Agent"
onClick={onToggleOpen}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</header>
{isConversationBusy ? (
<div className="flex items-center gap-2 border-b border-slate-200 bg-white/60 px-4 py-2 text-xs text-slate-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
<span>
{toolCallAction?.action === 'confirm'
? '执行中'
: toolCallAction?.action === 'cancel'
? '取消中'
: '思考中'}
</span>
</div>
) : null}
<div
className="min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-3 py-4"
role="log"
aria-label="画布 Agent 消息流"
>
{isLoadingMessages || isLoadingConversations ? (
<div className="flex items-center gap-2 text-sm text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
加载中
</div>
) : messages.length ? (
<>
{messages.map((message, messageIndex) => (
<MessageBubble
key={`${message.createdAt}-${messageIndex}`}
message={message}
busyAction={
toolCallAction?.messageId === message.id
? toolCallAction.action
: null
}
onConfirmToolCall={confirmToolCall}
onCancelToolCall={cancelToolCall}
onJobCompleted={() => {
void refreshActiveConversation();
onCanvasRefreshRequested?.();
}}
/>
))}
{isWaiting ? (
<ThinkingBubble showPatienceNotice={isPatienceNoticeVisible} />
) : null}
</>
) : (
<div className="rounded-3xl border border-dashed border-slate-200 bg-white/70 px-4 py-8 text-center text-sm text-slate-400">
暂无消息
</div>
)}
</div>
{errorMessage ? (
<div className="mx-3 mb-2 rounded-2xl bg-red-50 px-3 py-2 text-sm text-red-600">
{errorMessage}
</div>
) : null}
{attachmentError ? (
<div className="mx-3 mb-2 rounded-2xl bg-amber-50 px-3 py-2 text-sm text-amber-700">
{attachmentError}
</div>
) : null}
<form
className="border-t border-slate-200 bg-white/95 p-3"
onSubmit={submitMessage}
>
{attachments.length ? (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((attachment) => (
<AttachmentChip
key={attachmentKey(attachment)}
attachment={attachment}
onRemove={() => removeAttachment(attachment)}
/>
))}
</div>
) : null}
<div className="flex items-end gap-2">
<button
type="button"
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200"
aria-label="添加附件"
onClick={openAttachmentPicker}
>
<Paperclip className="h-4 w-4" aria-hidden="true" />
</button>
<textarea
className="max-h-32 min-h-10 flex-1 resize-none overflow-y-auto overscroll-contain rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none focus:border-slate-400"
aria-label="发送给画布 Agent"
value={draftText}
rows={1}
onChange={(event) => setDraftText(event.currentTarget.value)}
onPaste={handleInputPaste}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<button
type="submit"
className="inline-flex h-10 min-w-16 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
disabled={
isWaiting ||
isToolCallActionPending ||
(!draftText.trim() && !attachments.length) ||
!hasProject
}
>
<Send className="h-3.5 w-3.5" aria-hidden="true" />
发送
</button>
</div>
</form>
</aside>
<AttachmentPickerModal
open={attachmentPickerOpen}
tab={attachmentPickerTab}
canvasOptions={canvasAttachmentOptions}
libraryOptions={libraryAttachmentOptions}
selectedKeys={draftAttachmentKeys}
attachmentError={attachmentError}
onTabChange={setAttachmentPickerTab}
onToggleKey={toggleAttachmentKey}
onApply={applyAttachmentSelection}
onClose={() => setAttachmentPickerOpen(false)}
/>
<PlatformDangerConfirmDialog
open={deleteConfirmOpen}
title="删除对话"
confirmLabel="确认删除"
busy={isDeletingConversation}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={() => {
void deleteActiveConversation().then(() =>
setDeleteConfirmOpen(false),
);
}}
/>
</>
);
}