Files
Genarrative/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx
T
k88936 9bb4942ae7
Project CI / Repository checks (push) Successful in 1m21s
Project CI / Frontend tests (push) Successful in 31s
Project CI / Native shell tests (push) Successful in 2m25s
Project CI / Backend tests (push) Successful in 3m7s
Editor agent 右键菜单支持复制, 引用,下载 (#95)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/95
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-22 18:00:27 +08:00

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;