9bb4942ae7
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/95 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
217 lines
8.0 KiB
TypeScript
217 lines
8.0 KiB
TypeScript
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';
|
|
|
|
import type { RightClickMenuHandler } from './common.ts';
|
|
|
|
function ToolCallView({
|
|
toolCall,
|
|
onJobCompleted,
|
|
onRightClickMenu,
|
|
}: {
|
|
toolCall: EditorAgentToolCall;
|
|
onJobCompleted?: () => void;
|
|
onRightClickMenu?: RightClickMenuHandler;
|
|
}) {
|
|
const videos = toolCall.videos ?? [];
|
|
const audios = toolCall.audios ?? [];
|
|
const jobId = toolCall.externalJobId?.trim() || null;
|
|
const initialDisplayStatus =
|
|
toolCall.status === 'completed'
|
|
? 'completed'
|
|
: toolCall.status === 'failed'
|
|
? 'failed'
|
|
: toolCall.status === 'cancelled'
|
|
? 'cancelled'
|
|
: 'pending';
|
|
const initialDisplayError = toolCall.error ?? null;
|
|
const shouldPoll = toolCall.status === 'not_completed' && Boolean(jobId);
|
|
const [displayStatus, setDisplayStatus] = useState(initialDisplayStatus);
|
|
const [displayError, setDisplayError] =
|
|
useState<string | null>(initialDisplayError);
|
|
const terminalNotifiedRef = useRef(false);
|
|
const onJobCompletedRef = useRef(onJobCompleted);
|
|
useEffect(() => {
|
|
onJobCompletedRef.current = onJobCompleted;
|
|
}, [onJobCompleted]);
|
|
useEffect(() => {
|
|
setDisplayStatus(initialDisplayStatus);
|
|
setDisplayError(initialDisplayError);
|
|
terminalNotifiedRef.current = false;
|
|
if (!shouldPoll || !jobId) return;
|
|
let disposed = false;
|
|
let timeoutId: ReturnType<typeof setTimeout> | 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 (!terminalNotifiedRef.current) {
|
|
terminalNotifiedRef.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);
|
|
};
|
|
// 轮询只允许新的 job/status source 重置;其余值通过当前 source 对应的闭包读取。
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [initialDisplayError, initialDisplayStatus, jobId, shouldPoll]);
|
|
const isCancelled = displayStatus === 'cancelled';
|
|
const isCompleted = displayStatus === 'completed';
|
|
const isFailed = displayStatus === 'failed';
|
|
const isExecuting = displayStatus === 'pending' && Boolean(jobId);
|
|
const statusLabel = isCompleted
|
|
? '已完成'
|
|
: isFailed
|
|
? '失败'
|
|
: isCancelled
|
|
? '已取消'
|
|
: isExecuting
|
|
? '执行中'
|
|
: '待确认';
|
|
return (
|
|
<div className="rounded-lg border border-slate-200 bg-white/80 p-2 text-xs text-slate-600">
|
|
<div className="flex items-center gap-2">
|
|
{isExecuting ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
|
) : isCompleted ? (
|
|
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
|
) : isCancelled || isFailed ? (
|
|
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
|
) : (
|
|
<ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
|
)}
|
|
<span>{editorAgentToolLabel(toolCall.toolName)}</span>
|
|
<span className="ml-auto text-slate-400">{statusLabel}</span>
|
|
</div>
|
|
{displayError ? (
|
|
<div className="mt-1 text-red-600">{displayError}</div>
|
|
) : null}
|
|
{toolCall.images.length ? (
|
|
<div className="mt-2 grid grid-cols-3 gap-2">
|
|
{toolCall.images.map((image, index) => (
|
|
<div
|
|
key={`${toolCall.toolName}-${image.resourceId ?? index}`}
|
|
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
|
|
onContextMenu={
|
|
onRightClickMenu
|
|
? (event) =>
|
|
onRightClickMenu(event, {
|
|
kind: 'generated_media',
|
|
mediaType: 'image',
|
|
mediaSrc: image.imageSrc,
|
|
objectKey: image.objectKey,
|
|
suggestedFileName: `Agent生成图片-${index + 1}`,
|
|
})
|
|
: undefined
|
|
}
|
|
>
|
|
<ResolvedAssetImage
|
|
src={image.thumbnailSrc ?? image.imageSrc}
|
|
objectKey={image.objectKey}
|
|
refreshKey={image.resourceId ?? toolCall.toolName}
|
|
alt=""
|
|
className="h-20 w-full object-cover"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{videos.length ? (
|
|
<div className="mt-2 grid grid-cols-1 gap-2">
|
|
{videos.map((video, index) => (
|
|
<div
|
|
key={`${toolCall.toolName}-${video.resourceId ?? video.objectKey ?? index}`}
|
|
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
|
|
onContextMenu={
|
|
onRightClickMenu
|
|
? (event) =>
|
|
onRightClickMenu(event, {
|
|
kind: 'generated_media',
|
|
mediaType: 'video',
|
|
mediaSrc: video.videoSrc,
|
|
objectKey: video.objectKey,
|
|
suggestedFileName: `Agent生成视频-${index + 1}`,
|
|
})
|
|
: undefined
|
|
}
|
|
>
|
|
<ResolvedAssetVideo
|
|
src={video.videoSrc}
|
|
objectKey={video.objectKey}
|
|
refreshKey={
|
|
video.resourceId ?? video.objectKey ?? toolCall.toolName
|
|
}
|
|
poster={video.thumbnailSrc ?? undefined}
|
|
controls
|
|
playsInline
|
|
preload="metadata"
|
|
className="max-h-56 w-full bg-black object-contain"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{audios.length ? (
|
|
<div className="mt-2 space-y-2">
|
|
{audios.map((audio, index) => (
|
|
<div
|
|
key={`${toolCall.toolName}-${audio.resourceId ?? audio.objectKey ?? index}`}
|
|
className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 p-2"
|
|
onContextMenu={
|
|
onRightClickMenu
|
|
? (event) =>
|
|
onRightClickMenu(event, {
|
|
kind: 'generated_media',
|
|
mediaType: 'audio',
|
|
mediaSrc: audio.audioSrc,
|
|
objectKey: audio.objectKey,
|
|
suggestedFileName: `Agent生成音频-${index + 1}`,
|
|
})
|
|
: undefined
|
|
}
|
|
>
|
|
<Volume2
|
|
className="h-4 w-4 shrink-0 text-slate-500"
|
|
aria-hidden="true"
|
|
/>
|
|
<ResolvedAssetAudio
|
|
src={audio.audioSrc}
|
|
objectKey={audio.objectKey}
|
|
refreshKey={
|
|
audio.resourceId ?? audio.objectKey ?? toolCall.toolName
|
|
}
|
|
controls
|
|
preload="metadata"
|
|
className="min-w-0 flex-1"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ToolCallView;
|