711 lines
24 KiB
TypeScript
711 lines
24 KiB
TypeScript
import { ArrowLeft, ImagePlus, Paperclip, Send, Sparkles, X } from 'lucide-react';
|
|
import type { ChangeEvent } from 'react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import {
|
|
type CreationAgentProgressCopy,
|
|
normalizeCreationAgentProgress,
|
|
parseCreationAgentDocumentInput,
|
|
resolveCreationAgentProgressHint,
|
|
} from '../../services/creation-agent';
|
|
|
|
export type CreationAgentAnchorView = {
|
|
key: string;
|
|
label: string;
|
|
value: string;
|
|
status: string;
|
|
};
|
|
|
|
export type CreationAgentMessageView = {
|
|
id: string;
|
|
role: string;
|
|
kind?: string;
|
|
text: string;
|
|
createdAt?: string;
|
|
};
|
|
|
|
export type CreationAgentOperationView = {
|
|
operationId?: string;
|
|
type?: string;
|
|
status: string;
|
|
phaseLabel: string;
|
|
phaseDetail?: string;
|
|
progress: number;
|
|
error?: string | null;
|
|
};
|
|
|
|
export type CreationAgentSessionView = {
|
|
sessionId: string;
|
|
title?: string | null;
|
|
assistantSummary?: string | null;
|
|
currentTurn: number;
|
|
progressPercent: number;
|
|
anchors: CreationAgentAnchorView[];
|
|
messages: CreationAgentMessageView[];
|
|
recommendedReplies?: string[];
|
|
};
|
|
|
|
export type CreationAgentTheme = {
|
|
accentTextClass: string;
|
|
accentBgClass: string;
|
|
accentButtonClass: string;
|
|
userBubbleClass: string;
|
|
heroClass: string;
|
|
anchorGridClass?: string;
|
|
};
|
|
|
|
export type CreationAgentQuickAction = {
|
|
key: string;
|
|
label: string;
|
|
minTurn?: number;
|
|
minProgress?: number;
|
|
showWhenComplete?: boolean;
|
|
};
|
|
|
|
type CreationAgentWorkspaceProps = {
|
|
session: CreationAgentSessionView | null;
|
|
theme: CreationAgentTheme;
|
|
loadingText: string;
|
|
composerPlaceholder: string;
|
|
primaryActionLabel: string;
|
|
progressCopy?: CreationAgentProgressCopy;
|
|
activeOperation?: CreationAgentOperationView | null;
|
|
streamingReplyText?: string;
|
|
isStreamingReply?: boolean;
|
|
isBusy?: boolean;
|
|
error?: string | null;
|
|
quickActions?: CreationAgentQuickAction[];
|
|
referenceImagePreviewSrc?: string | null;
|
|
referenceImageLabel?: string | null;
|
|
referenceImageError?: string | null;
|
|
onBack: () => void;
|
|
onSubmitText: (text: string, quickActionKey?: string) => void;
|
|
onPrimaryAction: () => void;
|
|
onQuickAction?: (action: CreationAgentQuickAction) => void;
|
|
onReferenceImageChange?: (file: File) => Promise<void> | void;
|
|
onClearReferenceImage?: () => void;
|
|
};
|
|
|
|
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
|
|
const DOCUMENT_INPUT_ACCEPT =
|
|
'.txt,.md,.markdown,.docx,.csv,.json,text/plain,text/markdown,text/csv,application/json,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
const REFERENCE_IMAGE_INPUT_ACCEPT = 'image/png,image/jpeg,image/webp';
|
|
|
|
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
|
|
return [
|
|
...new Set(recommendedReplies.map((reply) => reply.trim()).filter(Boolean)),
|
|
].slice(0, 3);
|
|
}
|
|
|
|
function CreationAgentOperationBanner({
|
|
operation,
|
|
}: {
|
|
operation: CreationAgentOperationView | null | undefined;
|
|
}) {
|
|
const [visibleOperation, setVisibleOperation] =
|
|
useState<CreationAgentOperationView | null>(operation ?? null);
|
|
|
|
useEffect(() => {
|
|
setVisibleOperation(operation ?? null);
|
|
|
|
if (operation?.status !== 'completed') {
|
|
return;
|
|
}
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
setVisibleOperation((current) =>
|
|
current?.operationId === operation.operationId ? null : current,
|
|
);
|
|
}, 1200);
|
|
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, [operation]);
|
|
|
|
if (!visibleOperation) {
|
|
return null;
|
|
}
|
|
|
|
const isFailed = visibleOperation.status === 'failed';
|
|
const isRunning =
|
|
visibleOperation.status === 'running' ||
|
|
visibleOperation.status === 'queued';
|
|
const bannerToneClass = isFailed
|
|
? 'platform-banner--danger'
|
|
: isRunning
|
|
? 'platform-banner--info'
|
|
: 'platform-banner--success';
|
|
const progress = normalizeCreationAgentProgress(visibleOperation.progress);
|
|
const progressFillStyle = isFailed
|
|
? { background: 'linear-gradient(90deg, #c7653d 0%, #a6402f 100%)' }
|
|
: isRunning
|
|
? { background: 'var(--platform-button-primary-fill)' }
|
|
: { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' };
|
|
|
|
return (
|
|
<div
|
|
className={`platform-remap-surface platform-banner rounded-[1.4rem] px-4 py-4 ${bannerToneClass}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-sm font-semibold">
|
|
{visibleOperation.phaseLabel}
|
|
</div>
|
|
<div className="text-xs opacity-80">{progress}%</div>
|
|
</div>
|
|
{visibleOperation.phaseDetail ? (
|
|
<div className="mt-1 text-xs opacity-80">
|
|
{visibleOperation.phaseDetail}
|
|
</div>
|
|
) : null}
|
|
{visibleOperation.error ? (
|
|
<div className="mt-2 text-sm opacity-90">{visibleOperation.error}</div>
|
|
) : null}
|
|
<div className="platform-progress-track mt-3 h-2 overflow-hidden rounded-full">
|
|
<div
|
|
className="h-full rounded-full transition-[width] duration-300"
|
|
style={{
|
|
width: `${Math.max(8, progress)}%`,
|
|
...progressFillStyle,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CreationAgentMessageBubble({
|
|
message,
|
|
theme,
|
|
recommendedReplies,
|
|
isStreaming = false,
|
|
onRecommendedReply,
|
|
}: {
|
|
message: CreationAgentMessageView;
|
|
theme: CreationAgentTheme;
|
|
recommendedReplies?: string[];
|
|
isStreaming?: boolean;
|
|
onRecommendedReply: (text: string) => void;
|
|
}) {
|
|
const isUser = message.role === 'user';
|
|
const isSystem = message.role === 'system';
|
|
const visibleRecommendedReplies =
|
|
isUser || isStreaming ? [] : uniqueRecommendedReplies(recommendedReplies);
|
|
const bubbleToneClass = isUser
|
|
? theme.userBubbleClass
|
|
: isSystem
|
|
? 'border border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
|
|
: 'platform-subpanel text-[var(--platform-text-strong)]';
|
|
|
|
return (
|
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
|
<div
|
|
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${bubbleToneClass}`}
|
|
>
|
|
{isStreaming ? (
|
|
message.text ? (
|
|
<div className="whitespace-pre-wrap">
|
|
{message.text}
|
|
<span
|
|
className={`ml-1 inline-block h-4 w-1 animate-pulse rounded-full align-[-2px] ${theme.accentBgClass}`}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
data-testid="creation-agent-waiting-dots"
|
|
aria-label="等待回复"
|
|
className="flex items-center gap-1.5 py-1"
|
|
>
|
|
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
|
|
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
|
|
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="whitespace-pre-wrap">{message.text}</div>
|
|
)}
|
|
{visibleRecommendedReplies.length > 0 ? (
|
|
<div className="mt-2.5 flex flex-col gap-1.5">
|
|
{visibleRecommendedReplies.map((reply, replyIndex) => (
|
|
<button
|
|
key={`recommended-reply-${replyIndex}-${reply}`}
|
|
type="button"
|
|
onClick={() => onRecommendedReply(reply)}
|
|
className="platform-button platform-button--ghost min-h-0 justify-start rounded-[0.95rem] px-2.5 py-1.5 text-left text-[11px] leading-4.5 whitespace-normal"
|
|
>
|
|
{reply}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function shouldShowQuickAction(
|
|
action: CreationAgentQuickAction,
|
|
session: CreationAgentSessionView,
|
|
progress: number,
|
|
) {
|
|
if (action.showWhenComplete && progress < 100) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
typeof action.minTurn === 'number' &&
|
|
session.currentTurn < action.minTurn
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof action.minProgress === 'number' && progress < action.minProgress) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function isMessageListNearBottom(container: HTMLDivElement) {
|
|
return (
|
|
container.scrollHeight - container.scrollTop - container.clientHeight <=
|
|
AUTO_SCROLL_FOLLOW_THRESHOLD_PX
|
|
);
|
|
}
|
|
|
|
function scrollMessageListToBottom(container: HTMLDivElement) {
|
|
if (typeof container.scrollTo === 'function') {
|
|
container.scrollTo({
|
|
top: container.scrollHeight,
|
|
behavior: 'auto',
|
|
});
|
|
return;
|
|
}
|
|
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
export function CreationAgentWorkspace({
|
|
session,
|
|
theme,
|
|
loadingText,
|
|
composerPlaceholder,
|
|
primaryActionLabel,
|
|
progressCopy,
|
|
activeOperation = null,
|
|
streamingReplyText = '',
|
|
isStreamingReply = false,
|
|
isBusy = false,
|
|
error = null,
|
|
quickActions = [],
|
|
referenceImagePreviewSrc = null,
|
|
referenceImageLabel = null,
|
|
referenceImageError = null,
|
|
onBack,
|
|
onSubmitText,
|
|
onPrimaryAction,
|
|
onQuickAction,
|
|
onReferenceImageChange,
|
|
onClearReferenceImage,
|
|
}: CreationAgentWorkspaceProps) {
|
|
const [draftText, setDraftText] = useState('');
|
|
const [documentInputError, setDocumentInputError] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
|
|
const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false);
|
|
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
|
|
const messageListRef = useRef<HTMLDivElement | null>(null);
|
|
const documentInputRef = useRef<HTMLInputElement | null>(null);
|
|
const referenceImageInputRef = useRef<HTMLInputElement | null>(null);
|
|
const shouldAutoScrollRef = useRef(true);
|
|
|
|
useEffect(() => {
|
|
const container = messageListRef.current;
|
|
if (!container || !shouldAutoScrollRef.current) {
|
|
return;
|
|
}
|
|
|
|
scrollMessageListToBottom(container);
|
|
}, [
|
|
session?.sessionId,
|
|
session?.messages,
|
|
streamingReplyText,
|
|
isStreamingReply,
|
|
]);
|
|
|
|
if (!session) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
|
{error || loadingText}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const progress = normalizeCreationAgentProgress(session.progressPercent);
|
|
const progressFillWidth = progress <= 0 ? '0%' : `${Math.max(6, progress)}%`;
|
|
const hasHeroCopy = Boolean(session.title || session.assistantSummary);
|
|
const canShowPrimaryAction = progress >= 100;
|
|
const visibleQuickActions = quickActions.filter((action) =>
|
|
shouldShowQuickAction(action, session, progress),
|
|
);
|
|
const streamingMessageId = `streaming-assistant-${session.sessionId}`;
|
|
// 用户消息提交后、首个流式文本到达前,也要立刻展示等待气泡。
|
|
const shouldShowStreamingReply = isStreamingReply;
|
|
const displayedMessages = shouldShowStreamingReply
|
|
? [
|
|
...session.messages,
|
|
{
|
|
id: streamingMessageId,
|
|
role: 'assistant',
|
|
kind: 'chat',
|
|
text: streamingReplyText,
|
|
} satisfies CreationAgentMessageView,
|
|
]
|
|
: session.messages;
|
|
const lastAssistantMessageIndex = session.messages.reduce(
|
|
(lastIndex, message, index) =>
|
|
message.role === 'assistant' ? index : lastIndex,
|
|
-1,
|
|
);
|
|
|
|
const armAutoScrollToBottom = () => {
|
|
shouldAutoScrollRef.current = true;
|
|
};
|
|
|
|
const handleMessageListScroll = () => {
|
|
const container = messageListRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
shouldAutoScrollRef.current = isMessageListNearBottom(container);
|
|
};
|
|
|
|
const submitRecommendedReply = (text: string) => {
|
|
armAutoScrollToBottom();
|
|
onSubmitText(text);
|
|
};
|
|
|
|
const submit = () => {
|
|
const text = draftText.trim();
|
|
if (!text || isBusy || isParsingDocumentInput || isReadingReferenceImage) {
|
|
return;
|
|
}
|
|
|
|
armAutoScrollToBottom();
|
|
onSubmitText(text);
|
|
setDraftText('');
|
|
setDocumentInputError(null);
|
|
};
|
|
|
|
const appendDocumentInputText = (text: string) => {
|
|
setDraftText((current) => {
|
|
const currentText = current.trimEnd();
|
|
const nextText = text.trim();
|
|
|
|
return currentText ? `${currentText}\n\n${nextText}` : nextText;
|
|
});
|
|
};
|
|
|
|
const openDocumentInputPicker = () => {
|
|
documentInputRef.current?.click();
|
|
};
|
|
|
|
const openReferenceImagePicker = () => {
|
|
referenceImageInputRef.current?.click();
|
|
};
|
|
|
|
const handleDocumentInputChange = async (
|
|
event: ChangeEvent<HTMLInputElement>,
|
|
) => {
|
|
const file = event.target.files?.[0] ?? null;
|
|
event.target.value = '';
|
|
|
|
if (!file || isBusy || isParsingDocumentInput) {
|
|
return;
|
|
}
|
|
|
|
setIsParsingDocumentInput(true);
|
|
setDocumentInputError(null);
|
|
|
|
try {
|
|
const response = await parseCreationAgentDocumentInput(file);
|
|
appendDocumentInputText(response.document.text);
|
|
} catch (parseError) {
|
|
setDocumentInputError(
|
|
parseError instanceof Error
|
|
? parseError.message
|
|
: '解析文档失败,请重新选择文件。',
|
|
);
|
|
} finally {
|
|
setIsParsingDocumentInput(false);
|
|
}
|
|
};
|
|
|
|
const handleReferenceImageInputChange = async (
|
|
event: ChangeEvent<HTMLInputElement>,
|
|
) => {
|
|
const file = event.target.files?.[0] ?? null;
|
|
event.target.value = '';
|
|
|
|
if (!file || isBusy || isReadingReferenceImage || !onReferenceImageChange) {
|
|
return;
|
|
}
|
|
|
|
setIsReadingReferenceImage(true);
|
|
|
|
try {
|
|
await onReferenceImageChange(file);
|
|
} finally {
|
|
setIsReadingReferenceImage(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
|
<div
|
|
className={`relative overflow-hidden rounded-[1.8rem] px-4 py-4 text-white shadow-[0_20px_60px_rgba(15,23,42,0.18)] sm:px-5 ${theme.heroClass}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<button
|
|
type="button"
|
|
aria-label="返回"
|
|
onClick={onBack}
|
|
disabled={isBusy}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</button>
|
|
{canShowPrimaryAction ? (
|
|
<button
|
|
type="button"
|
|
disabled={isBusy}
|
|
onClick={onPrimaryAction}
|
|
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-bold text-slate-950 shadow-lg disabled:cursor-not-allowed disabled:opacity-50 ${theme.accentButtonClass}`}
|
|
>
|
|
<Sparkles className="h-4 w-4" />
|
|
{primaryActionLabel}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{hasHeroCopy ? (
|
|
<div className="mt-6">
|
|
{session.title ? (
|
|
<div className="text-2xl font-black leading-tight sm:text-3xl">
|
|
{session.title}
|
|
</div>
|
|
) : null}
|
|
{session.assistantSummary ? (
|
|
<div className="mt-2 max-w-2xl text-sm leading-6 text-white/76">
|
|
{session.assistantSummary}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className={hasHeroCopy ? 'mt-4' : 'mt-6'}>
|
|
<div className="mb-2 flex items-center justify-between gap-3">
|
|
<span className="text-xs font-semibold tracking-[0.14em] text-white/72">
|
|
创作进度
|
|
</span>
|
|
<span className="text-sm font-semibold text-white/88">
|
|
{progress}%
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="h-2 overflow-hidden rounded-full bg-white/12"
|
|
role="progressbar"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={progress}
|
|
>
|
|
<div
|
|
className={`h-full rounded-full transition-all ${theme.accentBgClass}`}
|
|
style={{ width: progressFillWidth }}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 text-xs leading-5 text-white/64">
|
|
{resolveCreationAgentProgressHint(progress, progressCopy)}
|
|
</div>
|
|
</div>
|
|
|
|
{visibleQuickActions.length > 0 ? (
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{visibleQuickActions.map((action) => (
|
|
<button
|
|
key={action.key}
|
|
type="button"
|
|
disabled={isBusy}
|
|
onClick={() => onQuickAction?.(action)}
|
|
className="rounded-full border border-white/14 bg-white/10 px-3 py-1.5 text-xs font-semibold text-white/78 disabled:cursor-not-allowed disabled:opacity-45"
|
|
>
|
|
{action.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<CreationAgentOperationBanner operation={activeOperation} />
|
|
|
|
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.6rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
<div
|
|
ref={messageListRef}
|
|
data-testid="creation-agent-message-list"
|
|
onScroll={handleMessageListScroll}
|
|
className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4"
|
|
>
|
|
{displayedMessages.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
|
|
暂无消息
|
|
</div>
|
|
) : (
|
|
displayedMessages.map((message, index) => (
|
|
<CreationAgentMessageBubble
|
|
key={message.id || `message-${index}`}
|
|
message={message}
|
|
theme={theme}
|
|
isStreaming={message.id === streamingMessageId}
|
|
recommendedReplies={
|
|
message.id !== streamingMessageId &&
|
|
index === lastAssistantMessageIndex
|
|
? session.recommendedReplies
|
|
: []
|
|
}
|
|
onRecommendedReply={submitRecommendedReply}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{referenceImagePreviewSrc ? (
|
|
<div className="mx-4 mb-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
|
|
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-[0.9rem] bg-[var(--platform-track-fill)]">
|
|
<img
|
|
src={referenceImagePreviewSrc}
|
|
alt="参考图"
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
|
{referenceImageLabel || '已选择参考图'}
|
|
</div>
|
|
{onClearReferenceImage ? (
|
|
<button
|
|
type="button"
|
|
disabled={isBusy || isReadingReferenceImage}
|
|
onClick={onClearReferenceImage}
|
|
className="platform-icon-button h-9 w-9"
|
|
aria-label="移除参考图"
|
|
title="移除参考图"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{documentInputError || referenceImageError || error ? (
|
|
<div className="mx-4 mb-3 rounded-[1rem] border border-red-200/70 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
{documentInputError || referenceImageError || error}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="border-t border-[var(--platform-subpanel-border)] p-3">
|
|
<div className="flex items-end gap-2 rounded-[1.25rem] bg-white/80 p-2">
|
|
<input
|
|
ref={documentInputRef}
|
|
type="file"
|
|
accept={DOCUMENT_INPUT_ACCEPT}
|
|
className="hidden"
|
|
onChange={handleDocumentInputChange}
|
|
/>
|
|
{onReferenceImageChange ? (
|
|
<input
|
|
ref={referenceImageInputRef}
|
|
type="file"
|
|
accept={REFERENCE_IMAGE_INPUT_ACCEPT}
|
|
className="hidden"
|
|
onChange={handleReferenceImageInputChange}
|
|
/>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
aria-label={
|
|
isParsingDocumentInput ? '正在解析文档' : '上传文档'
|
|
}
|
|
title={isParsingDocumentInput ? '正在解析文档' : '上传文档'}
|
|
aria-busy={isParsingDocumentInput}
|
|
disabled={isBusy || isParsingDocumentInput}
|
|
onClick={openDocumentInputPicker}
|
|
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[var(--platform-text-base)] hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
<Paperclip
|
|
className={`h-4 w-4 ${isParsingDocumentInput ? 'animate-pulse' : ''}`}
|
|
/>
|
|
</button>
|
|
{onReferenceImageChange ? (
|
|
<button
|
|
type="button"
|
|
aria-label={
|
|
isReadingReferenceImage ? '正在读取参考图' : '上传参考图'
|
|
}
|
|
title={
|
|
isReadingReferenceImage ? '正在读取参考图' : '上传参考图'
|
|
}
|
|
aria-busy={isReadingReferenceImage}
|
|
disabled={isBusy || isReadingReferenceImage}
|
|
onClick={openReferenceImagePicker}
|
|
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[var(--platform-text-base)] hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
<ImagePlus
|
|
className={`h-4 w-4 ${isReadingReferenceImage ? 'animate-pulse' : ''}`}
|
|
/>
|
|
</button>
|
|
) : null}
|
|
<textarea
|
|
value={draftText}
|
|
disabled={
|
|
isBusy || isParsingDocumentInput || isReadingReferenceImage
|
|
}
|
|
rows={2}
|
|
onChange={(event) => {
|
|
setDraftText(event.target.value);
|
|
setDocumentInputError(null);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
submit();
|
|
}
|
|
}}
|
|
placeholder={composerPlaceholder}
|
|
className="min-h-[3rem] flex-1 resize-none bg-transparent px-2 py-2 text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label="发送"
|
|
disabled={
|
|
isBusy ||
|
|
isParsingDocumentInput ||
|
|
isReadingReferenceImage ||
|
|
!draftText.trim()
|
|
}
|
|
onClick={submit}
|
|
className={`inline-flex h-11 w-11 items-center justify-center rounded-full text-white disabled:cursor-not-allowed disabled:opacity-40 ${theme.userBubbleClass}`}
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default CreationAgentWorkspace;
|