diff --git a/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx b/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx new file mode 100644 index 000000000..81b327f57 --- /dev/null +++ b/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx @@ -0,0 +1,43 @@ +import { Image as ImageIcon, X } from 'lucide-react'; + +import type { EditorAgentAttachmentRef } from '@/packages/shared/src/contracts'; +import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx'; + +function AttachmentChip({ + attachment, + onRemove, +}: { + attachment: EditorAgentAttachmentRef; + onRemove?: () => void; +}) { + const label = attachment.label?.trim() || attachment.referenceId; + return ( + + + ); +} + +export default AttachmentChip; diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index b1ddf66bc..31335c96a 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -27,7 +27,6 @@ import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDan import { UnifiedModal } from '@/src/components/common/UnifiedModal.tsx'; import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts'; import { - AttachmentChip, MessageBubble, ThinkingBubble, } from '@/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx'; @@ -45,6 +44,7 @@ import { type EditorAgentConversationClient, useEditorAgentConversation, } from './useEditorAgentConversation'; +import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx'; type AttachmentPickerTab = 'canvas' | 'library'; diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx index 31f2b8b04..20d2f4157 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx @@ -1,17 +1,8 @@ -import { Check, Image as ImageIcon, Loader2, Volume2, X } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; -import type { - EditorAgentAttachmentRef, - EditorAgentMessage, - EditorAgentToolCall, -} from '@/packages/shared/src/contracts'; -import { getExternalGenerationJobStatus } from '@/src/services/external-generation'; +import type { EditorAgentMessage } from '@/packages/shared/src/contracts'; +import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx'; import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts'; import { PendingToolCall } from '@/src/components/image-editor/EditorAgentConversation/PendingToolCall.tsx'; -import { editorAgentToolLabel } from '@/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts'; -import { ResolvedAssetAudio } from '@/src/components/ResolvedAssetAudio.tsx'; -import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx'; -import { ResolvedAssetVideo } from '@/src/components/ResolvedAssetVideo.tsx'; +import ToolCallView from '@/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx'; function messageRoleLabel(role: EditorAgentMessage['role']) { if (role === 'user') { @@ -20,211 +11,6 @@ function messageRoleLabel(role: EditorAgentMessage['role']) { return 'Agent'; } -export function AttachmentChip({ - attachment, - onRemove, -}: { - attachment: EditorAgentAttachmentRef; - onRemove?: () => void; -}) { - const label = attachment.label?.trim() || attachment.referenceId; - return ( - - - ); -} - -function ToolCallView({ - toolCall, - onJobCompleted, -}: { - toolCall: EditorAgentToolCall; - onJobCompleted?: () => void; -}) { - const videos = toolCall.videos ?? []; - const audios = toolCall.audios ?? []; - const jobId = toolCall.externalJobId?.trim() || null; - const displaySourceKey = jobId ?? toolCall.status; - const initialDisplayStatus = - toolCall.status === 'completed' - ? 'completed' - : toolCall.status === 'failed' - ? 'failed' - : toolCall.status === 'cancelled' - ? 'cancelled' - : 'pending'; - const [displayStatus, setDisplayStatus] = useState(initialDisplayStatus); - const [displayError, setDisplayError] = useState( - toolCall.error ?? null, - ); - const completionNotifiedRef = useRef(false); - const onJobCompletedRef = useRef(onJobCompleted); - useEffect(() => { - onJobCompletedRef.current = onJobCompleted; - }, [onJobCompleted]); - useEffect(() => { - if (toolCall.status !== 'not_completed' || !jobId) { - completionNotifiedRef.current = false; - return; - } - // Only a different job may reinitialize the local display state. - setDisplayStatus(initialDisplayStatus); - setDisplayError(toolCall.error ?? null); - completionNotifiedRef.current = false; - let disposed = false; - let timeoutId: ReturnType | undefined; - const poll = async () => { - try { - const response = await getExternalGenerationJobStatus(jobId); - if (disposed) return; - if ( - response.job.status === 'completed' || - response.job.status === 'failed' - ) { - setDisplayStatus(response.job.status); - setDisplayError(response.job.error ?? null); - if ( - response.job.status === 'completed' && - !completionNotifiedRef.current - ) { - completionNotifiedRef.current = true; - onJobCompletedRef.current?.(); - } - return; - } - timeoutId = setTimeout(poll, 1500); - } catch { - if (!disposed) timeoutId = setTimeout(poll, 3000); - } - }; - void poll(); - return () => { - disposed = true; - if (timeoutId) clearTimeout(timeoutId); - }; - }, [displaySourceKey]); - const isCancelled = displayStatus === 'cancelled'; - const isCompleted = displayStatus === 'completed'; - const isFailed = displayStatus === 'failed'; - const isExecuting = Boolean(jobId) && displayStatus === 'pending'; - const statusLabel = isCompleted - ? '已完成' - : isFailed - ? '失败' - : isCancelled - ? '已取消' - : '待确认'; - return ( -
-
- {isExecuting ? ( -
- {displayError ? ( -
{displayError}
- ) : null} - {toolCall.images.length ? ( -
- {toolCall.images.map((image, index) => ( -
- -
- ))} -
- ) : null} - {videos.length ? ( -
- {videos.map((video, index) => ( -
- -
- ))} -
- ) : null} - {audios.length ? ( -
- {audios.map((audio, index) => ( -
-
- ))} -
- ) : null} -
- ); -} - export function ThinkingBubble() { return (
diff --git a/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx b/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx new file mode 100644 index 000000000..21026f734 --- /dev/null +++ b/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx @@ -0,0 +1,179 @@ +import { Check, Image as ImageIcon, Loader2, Volume2, X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import type { EditorAgentToolCall } from '@/packages/shared/src/contracts'; +import { editorAgentToolLabel } from '@/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts'; +import { ResolvedAssetAudio } from '@/src/components/ResolvedAssetAudio.tsx'; +import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx'; +import { ResolvedAssetVideo } from '@/src/components/ResolvedAssetVideo.tsx'; +import { getExternalGenerationJobStatus } from '@/src/services/external-generation'; + +function ToolCallView({ + toolCall, + onJobCompleted, +}: { + toolCall: EditorAgentToolCall; + onJobCompleted?: () => void; +}) { + const videos = toolCall.videos ?? []; + const audios = toolCall.audios ?? []; + const jobId = toolCall.externalJobId?.trim() || null; + const displaySourceKey = jobId ?? toolCall.status; + const initialDisplayStatus = + toolCall.status === 'completed' + ? 'completed' + : toolCall.status === 'failed' + ? 'failed' + : toolCall.status === 'cancelled' + ? 'cancelled' + : 'pending'; + const [displayStatus, setDisplayStatus] = useState(initialDisplayStatus); + const [displayError, setDisplayError] = useState( + toolCall.error ?? null, + ); + const completionNotifiedRef = useRef(false); + const onJobCompletedRef = useRef(onJobCompleted); + useEffect(() => { + onJobCompletedRef.current = onJobCompleted; + }, [onJobCompleted]); + useEffect(() => { + if (toolCall.status !== 'not_completed' || !jobId) { + completionNotifiedRef.current = false; + return; + } + // Only a different job may reinitialize the local display state. + setDisplayStatus(initialDisplayStatus); + setDisplayError(toolCall.error ?? null); + completionNotifiedRef.current = false; + let disposed = false; + let timeoutId: ReturnType | undefined; + const poll = async () => { + try { + const response = await getExternalGenerationJobStatus(jobId); + if (disposed) return; + if ( + response.job.status === 'completed' || + response.job.status === 'failed' + ) { + setDisplayStatus(response.job.status); + setDisplayError(response.job.error ?? null); + if ( + response.job.status === 'completed' && + !completionNotifiedRef.current + ) { + completionNotifiedRef.current = true; + onJobCompletedRef.current?.(); + } + return; + } + timeoutId = setTimeout(poll, 1500); + } catch { + if (!disposed) timeoutId = setTimeout(poll, 3000); + } + }; + void poll(); + return () => { + disposed = true; + if (timeoutId) clearTimeout(timeoutId); + }; + }, [displaySourceKey]); + const isCancelled = displayStatus === 'cancelled'; + const isCompleted = displayStatus === 'completed'; + const isFailed = displayStatus === 'failed'; + const isExecuting = Boolean(jobId) && displayStatus === 'pending'; + const statusLabel = isCompleted + ? '已完成' + : isFailed + ? '失败' + : isCancelled + ? '已取消' + : '待确认'; + return ( +
+
+ {isExecuting ? ( +
+ {displayError ? ( +
{displayError}
+ ) : null} + {toolCall.images.length ? ( +
+ {toolCall.images.map((image, index) => ( +
+ +
+ ))} +
+ ) : null} + {videos.length ? ( +
+ {videos.map((video, index) => ( +
+ +
+ ))} +
+ ) : null} + {audios.length ? ( +
+ {audios.map((audio, index) => ( +
+
+ ))} +
+ ) : null} +
+ ); +} + +export default ToolCallView;