e6b3199490
之前遇到几次弹窗的样式不生效,bug 现在在画布agent的删除对话弹窗又出现了, 这个pr把类似的修改集中到最下层的UnifiedModal,适用于所有的modal, 一些特殊的modal有特殊处理. 画布agent的删除对话弹窗  --------- Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/122 Reviewed-by: 段舒康 <kdletters@qq.com> Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
import {
|
|
Bot,
|
|
ChevronDown,
|
|
Loader2,
|
|
MessageCircle,
|
|
Paperclip,
|
|
Plus,
|
|
Send,
|
|
Trash2,
|
|
X,
|
|
} from 'lucide-react';
|
|
import {
|
|
type FormEvent,
|
|
useEffect,
|
|
useState,
|
|
type WheelEvent as ReactWheelEvent,
|
|
} from 'react';
|
|
|
|
import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDangerConfirmDialog.tsx';
|
|
import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx';
|
|
import { AttachmentPicker } from '@/src/components/image-editor/EditorAgentConversation/AttachmentPicker.tsx';
|
|
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
|
|
import { EditorAgentDraftTextarea } from '@/src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.tsx';
|
|
import {
|
|
MessageBubble,
|
|
ThinkingBubble,
|
|
} from '@/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx';
|
|
import type {
|
|
CanvasLayer,
|
|
EditorAsset,
|
|
} from '@/src/components/image-editor/ImageCanvasEditorTypes.ts';
|
|
|
|
import { useImageCanvasContextStore } from '../useImageCanvasContextStore.ts';
|
|
import { useConversationAttachments } from './useConversationAttachments.ts';
|
|
import {
|
|
type EditorAgentConversationClient,
|
|
useEditorAgentConversation,
|
|
} from './useEditorAgentConversation';
|
|
|
|
type EditorAgentConversationPanelViewProps = {
|
|
open: boolean;
|
|
onToggleOpen: () => void;
|
|
layers?: CanvasLayer[];
|
|
assets?: EditorAsset[];
|
|
// TODO refactor: move the task list update seperate
|
|
onConfirmSent?: () => void;
|
|
client?: EditorAgentConversationClient;
|
|
};
|
|
|
|
function stopAgentPanelWheel(event: ReactWheelEvent<HTMLElement>) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
export function EditorAgentConversationPanelView({
|
|
open,
|
|
onToggleOpen,
|
|
layers = [],
|
|
assets = [],
|
|
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,
|
|
onConfirmSent,
|
|
});
|
|
const [draftText, setDraftText] = useState('');
|
|
const {
|
|
attachments,
|
|
attachmentError,
|
|
isPastingAttachment,
|
|
attachmentPickerOpen,
|
|
attachmentPickerTab,
|
|
draftAttachmentKeys,
|
|
canvasAttachmentOptions,
|
|
libraryAttachmentOptions,
|
|
setAttachmentPickerTab,
|
|
openAttachmentPicker,
|
|
closeAttachmentPicker,
|
|
toggleAttachmentKey,
|
|
applyAttachmentSelection,
|
|
referenceContextAsset,
|
|
handleInputPaste,
|
|
removeAttachment,
|
|
consumeAttachments,
|
|
restoreAttachments,
|
|
} = useConversationAttachments({ projectId, layers, assets });
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
|
|
const hasProject = Boolean(projectId?.trim());
|
|
const isConversationBusy = isWaiting || isToolCallActionPending;
|
|
const hasCurrentConversationContent = messages.length > 0;
|
|
const isConversationSelectDisabled =
|
|
!conversations.length ||
|
|
isLoadingConversations ||
|
|
isCreatingConversation ||
|
|
isConversationBusy;
|
|
const isMessageSubmissionBlocked =
|
|
isCreatingConversation ||
|
|
isLoadingConversations ||
|
|
isLoadingMessages ||
|
|
isWaiting ||
|
|
isToolCallActionPending ||
|
|
isPastingAttachment ||
|
|
!draftText.trim() ||
|
|
!hasProject;
|
|
const currentConversationTitle = activeConversation?.title ?? '新对话';
|
|
|
|
const handleCreateConversation = () => {
|
|
void createConversation().catch(() => undefined);
|
|
};
|
|
|
|
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
if (isMessageSubmissionBlocked) {
|
|
return;
|
|
}
|
|
const text = draftText.trim();
|
|
setDraftText('');
|
|
const nextAttachments = consumeAttachments();
|
|
void sendMessage(text, nextAttachments).catch(() => {
|
|
setDraftText((currentText) => (currentText ? currentText : text));
|
|
restoreAttachments(nextAttachments);
|
|
});
|
|
};
|
|
|
|
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}
|
|
onReferenceImage={referenceContextAsset}
|
|
onJobCompleted={() => {
|
|
void refreshActiveConversation();
|
|
}}
|
|
/>
|
|
))}
|
|
{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>
|
|
<EditorAgentDraftTextarea
|
|
value={draftText}
|
|
onChange={setDraftText}
|
|
onPaste={handleInputPaste}
|
|
/>
|
|
<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={isMessageSubmissionBlocked}
|
|
>
|
|
<Send className="h-3.5 w-3.5" aria-hidden="true" />
|
|
发送
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</aside>
|
|
<AttachmentPicker
|
|
open={attachmentPickerOpen}
|
|
tab={attachmentPickerTab}
|
|
canvasOptions={canvasAttachmentOptions}
|
|
libraryOptions={libraryAttachmentOptions}
|
|
selectedKeys={draftAttachmentKeys}
|
|
attachmentError={attachmentError}
|
|
onTabChange={setAttachmentPickerTab}
|
|
onToggleKey={toggleAttachmentKey}
|
|
onApply={applyAttachmentSelection}
|
|
onClose={closeAttachmentPicker}
|
|
/>
|
|
<PlatformDangerConfirmDialog
|
|
open={deleteConfirmOpen}
|
|
title="删除对话"
|
|
confirmLabel="确认删除"
|
|
busy={isDeletingConversation}
|
|
onClose={() => setDeleteConfirmOpen(false)}
|
|
onConfirm={() => {
|
|
void deleteActiveConversation().then(() =>
|
|
setDeleteConfirmOpen(false),
|
|
);
|
|
}}
|
|
>
|
|
确认删除这个对话吗?
|
|
</PlatformDangerConfirmDialog>
|
|
</>
|
|
);
|
|
}
|