Files
Genarrative/src/components/image-editor/EditorAgentConversationPanelView.tsx
T
kdletters 1671d35995 修复画布Agent聊天生成体验
让画布 Agent 消息统一走后端规划与 LLM 回复
修复生成图片在聊天、画布和素材库中的展示与恢复
补齐右键删除、画布焦点、聊天滚轮和面板避让交互
同步 Agent 事件契约、文档和定向回归测试
2026-07-04 16:41:33 +08:00

744 lines
24 KiB
TypeScript

import {
Bot,
Image as ImageIcon,
Loader2,
MessageCircle,
Paperclip,
Plus,
Send,
Square,
Trash2,
X,
} from 'lucide-react';
import {
type FormEvent,
type WheelEvent as ReactWheelEvent,
useEffect,
useMemo,
useState,
} 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 type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes';
import {
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation';
type AttachmentPickerTab = 'canvas' | 'library';
type EditorAgentAttachmentOption = {
key: string;
sourceLabel: string;
attachment: EditorAgentAttachmentRef;
};
type EditorAgentConversationPanelViewProps = {
projectId?: string | null;
open: boolean;
onToggleOpen: () => void;
layers?: CanvasLayer[];
assets?: EditorAsset[];
onGenerationResult?: (event: EditorAgentGenerationResultEvent) => void;
client?: EditorAgentConversationClient;
};
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' &&
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 stageLabel(stage: EditorAgentStage) {
if (stage === 'thinking') {
return '思考中';
}
if (stage === 'responding') {
return '回复中';
}
if (stage === 'generating') {
return '生成中';
}
if (stage === 'completed') {
return '完成';
}
if (stage === 'failed') {
return '失败';
}
return '';
}
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';
}
return '生成图片';
}
function messageRoleLabel(role: EditorAgentMessage['role']) {
return role === 'user' ? '你' : 'Agent';
}
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}
</div>
))}
</div>
);
}
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>
);
}
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 (
<UnifiedModal
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>
</UnifiedModal>
);
}
export function EditorAgentConversationPanelView({
projectId,
open,
onToggleOpen,
layers = [],
assets = [],
onGenerationResult,
client,
}: EditorAgentConversationPanelViewProps) {
const [hasConversationMounted, setHasConversationMounted] = useState(open);
useEffect(() => {
if (open) {
setHasConversationMounted(true);
}
}, [open]);
const effectiveProjectId = hasConversationMounted ? projectId : null;
const {
conversations,
activeConversationId,
messages,
stage,
isLoadingConversations,
isLoadingMessages,
isCreatingConversation,
isDeletingConversation,
isStreaming,
errorMessage,
createConversation,
selectConversation,
sendMessage,
stopCurrentTurn,
deleteActiveConversation,
} = useEditorAgentConversation({
projectId: effectiveProjectId,
client,
onGenerationResult,
});
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 [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 currentStageLabel = stageLabel(stage);
const hasProject = Boolean(projectId?.trim());
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 (isStreaming) {
stopCurrentTurn();
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 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" />
<select
className="min-w-0 flex-1 rounded-full border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
aria-label="当前对话"
value={activeConversationId ?? ''}
disabled={
!conversations.length || isLoadingConversations || isStreaming
}
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>
<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 || isCreatingConversation || isStreaming}
onClick={() => void createConversation()}
>
<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 || isStreaming
}
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>
{currentStageLabel ? (
<div className="flex items-center gap-2 border-b border-slate-200 bg-white/60 px-4 py-2 text-xs text-slate-500">
{isStreaming ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : null}
<span>{currentStageLabel}</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) => (
<MessageBubble
key={message.id}
message={message}
/>
))
) : (
<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}
<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)}
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={
!isStreaming &&
((!draftText.trim() && !attachments.length) || !hasProject)
}
>
{isStreaming ? (
<>
<Square className="h-3.5 w-3.5" aria-hidden="true" />
停止
</>
) : (
<>
<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),
);
}}
/>
</>
);
}