Files
Genarrative/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx
T
k88936 e6b3199490
Project CI / Repository checks (push) Successful in 48s
Project CI / Frontend tests (push) Successful in 3m21s
Project CI / Backend tests (push) Successful in 3m53s
Project CI / Native shell tests (push) Successful in 11m16s
Fix/统一修复modal样式失效 (#122)
之前遇到几次弹窗的样式不生效,bug 现在在画布agent的删除对话弹窗又出现了,
这个pr把类似的修改集中到最下层的UnifiedModal,适用于所有的modal,
一些特殊的modal有特殊处理.

画布agent的删除对话弹窗
![shotmd-1785399342.jpg](/attachments/d63d7dc6-bf7e-4f13-bfe8-bc4716cd798f)

---------

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>
2026-08-03 17:55:47 +08:00

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>
</>
);
}