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; 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(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 (
{visibleOperation.phaseLabel}
{progress}%
{visibleOperation.phaseDetail ? (
{visibleOperation.phaseDetail}
) : null} {visibleOperation.error ? (
{visibleOperation.error}
) : null}
); } 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 (
{isStreaming ? ( message.text ? (
{message.text}
) : (
) ) : (
{message.text}
)} {visibleRecommendedReplies.length > 0 ? (
{visibleRecommendedReplies.map((reply, replyIndex) => ( ))}
) : null}
); } 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( null, ); const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false); const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false); // 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。 const messageListRef = useRef(null); const documentInputRef = useRef(null); const referenceImageInputRef = useRef(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 (
{error || loadingText}
); } 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, ) => { 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, ) => { 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 (
{canShowPrimaryAction ? ( ) : null}
{hasHeroCopy ? (
{session.title ? (
{session.title}
) : null} {session.assistantSummary ? (
{session.assistantSummary}
) : null}
) : null}
创作进度 {progress}%
{resolveCreationAgentProgressHint(progress, progressCopy)}
{visibleQuickActions.length > 0 ? (
{visibleQuickActions.map((action) => ( ))}
) : null}
{displayedMessages.length === 0 ? (
暂无消息
) : ( displayedMessages.map((message, index) => ( )) )}
{referenceImagePreviewSrc ? (
参考图
{referenceImageLabel || '已选择参考图'}
{onClearReferenceImage ? ( ) : null}
) : null} {documentInputError || referenceImageError || error ? (
{documentInputError || referenceImageError || error}
) : null}
{onReferenceImageChange ? ( ) : null} {onReferenceImageChange ? ( ) : null}