1671d35995
让画布 Agent 消息统一走后端规划与 LLM 回复 修复生成图片在聊天、画布和素材库中的展示与恢复 补齐右键删除、画布焦点、聊天滚轮和面板避让交互 同步 Agent 事件契约、文档和定向回归测试
744 lines
24 KiB
TypeScript
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),
|
|
);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|