Editor agent refactored (#76)

重构了editor agent.
使得它能进行多轮工具调用, 把工具调用的结果嵌入到上下文里。
对于上下文的图片, 使用哈希后的id用来引用,不暴露细节信息to llm。
当前实现, 状态直接维护在json doc里, 需要对整个doc加锁,任务目前只能串行。
把SSE改成一个简单请求( 因为生图工具耗时需要二次确认, 不需要再实时展示给用户进度)。
增加确认/取消生图操作。
把tool的参数和错误情况做了反馈。

TODO:  工具调用的规范/prompt还可以改进。

改用external job来做素材生成, 针对来自editor agent 的生成会把生成资产的引用加入到结果json里,
客户端轮询 external job 确定生成状态, 发现结束了或者失败了就 重新get 会话,后端重新提供会话的时候把 结果插入回会话历史里,用来让 ai 引用 以及显示

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/76
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #76.
This commit is contained in:
2026-07-17 21:03:15 +08:00
committed by 段舒康
parent 387a1c26e3
commit f5368c825f
82 changed files with 8809 additions and 4934 deletions
+33
View File
@@ -0,0 +1,33 @@
import type { AudioHTMLAttributes } from 'react';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
type ResolvedAssetAudioProps = Omit<
AudioHTMLAttributes<HTMLAudioElement>,
'src'
> & {
src?: string | null;
objectKey?: string | null;
fallbackSrc?: string | null;
refreshKey?: string | number | null;
};
export function ResolvedAssetAudio({
src,
objectKey,
fallbackSrc,
refreshKey,
...rest
}: ResolvedAssetAudioProps) {
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
objectKey,
refreshKey,
});
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
if (!finalSrc) {
return null;
}
return <audio {...rest} src={finalSrc} />;
}
@@ -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 (
<span className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm">
<ImageIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{label}</span>
{onRemove ? (
<button
type="button"
className="-mr-1 inline-flex h-5 w-5 items-center justify-center rounded-full text-slate-400 hover:bg-slate-100 hover:text-slate-700"
aria-label={`移除附件 ${label}`}
onClick={onRemove}
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
) : null}
{attachment.thumbnailSrc || attachment.imageSrc ? (
<span className="pointer-events-none absolute bottom-[calc(100%+0.4rem)] left-0 hidden rounded-2xl border border-white bg-white p-1 shadow-xl group-hover:block">
<ResolvedAssetImage
src={attachment.thumbnailSrc ?? attachment.imageSrc}
objectKey={attachment.objectKey}
refreshKey={attachment.referenceId}
alt=""
className="h-24 w-24 rounded-xl object-cover"
/>
</span>
) : null}
</span>
);
}
export default AttachmentChip;
@@ -1,12 +1,10 @@
import {
Bot,
Image as ImageIcon,
Loader2,
MessageCircle,
Paperclip,
Plus,
Send,
Square,
Trash2,
X,
} from 'lucide-react';
@@ -22,24 +20,30 @@ import {
import {
EDITOR_AGENT_MAX_ATTACHMENTS,
type EditorAgentAttachmentRef,
type EditorAgentGenerationRecord,
type EditorAgentGenerationResultEvent,
type EditorAgentMessage,
type EditorAgentStage,
} from '../../../packages/shared/src/contracts/editorAgent';
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
import { createEditorProjectResource } from '../../services/image-editor/editorProjectClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { UnifiedModal } from '../common/UnifiedModal';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes';
import { probeImageFileDimensions } from './ImageCanvasFileModel';
} from '@/packages/shared/src/contracts';
import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx';
import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDangerConfirmDialog.tsx';
import { UnifiedModal } from '@/src/components/common/UnifiedModal.tsx';
import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx';
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
import {
MessageBubble,
ThinkingBubble,
} from '@/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx';
import type {
CanvasLayer,
EditorAsset,
} from '@/src/components/image-editor/ImageCanvasEditorTypes.ts';
import { probeImageFileDimensions } from '@/src/components/image-editor/ImageCanvasFileModel.ts';
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
import { uploadEditorMediaAssetFile } from '@/src/services/image-editor/editorMediaAssetUploadClient.ts';
import { createEditorProjectResource } from '@/src/services/image-editor/editorProjectClient.ts';
import { useImageCanvasContextStore } from '../useImageCanvasContextStore.ts';
import {
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation';
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
type AttachmentPickerTab = 'canvas' | 'library';
@@ -54,7 +58,9 @@ type EditorAgentConversationPanelViewProps = {
onToggleOpen: () => void;
layers?: CanvasLayer[];
assets?: EditorAsset[];
onGenerationResult?: (event: EditorAgentGenerationResultEvent) => void;
onCanvasRefreshRequested?: () => void;
// TODO refactor: move the task list update seperate
onConfirmSent?: () => void;
client?: EditorAgentConversationClient;
};
@@ -62,10 +68,6 @@ function stopAgentPanelWheel(event: ReactWheelEvent<HTMLElement>) {
event.stopPropagation();
}
function attachmentKey(attachment: EditorAgentAttachmentRef) {
return `${attachment.source}:${attachment.referenceId}`;
}
function isImageLayer(layer: CanvasLayer) {
return (
(layer.mediaType ?? 'image') === 'image' &&
@@ -122,183 +124,6 @@ function createLibraryAttachmentOptions(
});
}
function stageLabel(stage: EditorAgentStage) {
if (stage === 'thinking') {
return '思考中';
}
if (stage === 'responding') {
return '回复中';
}
if (stage === 'generating') {
return '生成中';
}
if (stage === 'completed') {
return '完成';
}
if (stage === 'failed') {
return '失败';
}
return '';
}
function toolLabel(toolName: EditorAgentGenerationRecord['toolName']) {
if (toolName === 'edit_image') {
return '修改图片';
}
if (toolName === 'generate_character') {
return '生成角色';
}
if (toolName === 'generate_icon_spritesheet') {
return '生成图标';
}
if (toolName === 'generate_ui_design') {
return '生成 UI';
}
return '生成图片';
}
function messageRoleLabel(role: EditorAgentMessage['role']) {
return role === 'user' ? '你' : 'Agent';
}
function AttachmentChip({
attachment,
onRemove,
}: {
attachment: EditorAgentAttachmentRef;
onRemove?: () => void;
}) {
const label = attachment.label?.trim() || attachment.referenceId;
return (
<span className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm">
<ImageIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{label}</span>
{onRemove ? (
<button
type="button"
className="-mr-1 inline-flex h-5 w-5 items-center justify-center rounded-full text-slate-400 hover:bg-slate-100 hover:text-slate-700"
aria-label={`移除附件 ${label}`}
onClick={onRemove}
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
) : null}
{attachment.thumbnailSrc || attachment.imageSrc ? (
<span className="pointer-events-none absolute bottom-[calc(100%+0.4rem)] left-0 hidden rounded-2xl border border-white bg-white p-1 shadow-xl group-hover:block">
<ResolvedAssetImage
src={attachment.thumbnailSrc ?? attachment.imageSrc}
objectKey={attachment.objectKey}
refreshKey={attachment.referenceId}
alt=""
className="h-24 w-24 rounded-xl object-cover"
/>
</span>
) : null}
</span>
);
}
function GenerationRecordsView({
generations,
}: {
generations: EditorAgentGenerationRecord[];
}) {
if (!generations.length) {
return null;
}
return (
<div className="mt-2 space-y-2">
{generations.map((generation) => (
<div
key={generation.toolCallId}
className="rounded-2xl border border-slate-200 bg-white/80 p-2 text-xs text-slate-600"
>
{generation.status === 'generating' || generation.error ? (
<div className="flex items-center gap-2">
{generation.status === 'generating' ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : (
<ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />
)}
<span>{toolLabel(generation.toolName)}</span>
{generation.model ? <span>{generation.model}</span> : null}
</div>
) : null}
{generation.error ? (
<div className="mt-1 text-red-600">{generation.error}</div>
) : null}
{generation.images.length ? (
<div className="mt-2 grid grid-cols-3 gap-2">
{generation.images.map((image, index) => (
<div
key={`${generation.toolCallId}-${image.resourceId ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
>
<ResolvedAssetImage
src={image.thumbnailSrc ?? image.imageSrc}
objectKey={image.objectKey}
refreshKey={
image.resourceId ??
generation.taskId ??
generation.toolCallId
}
alt=""
className="h-20 w-full object-cover"
/>
</div>
))}
</div>
) : null}
</div>
))}
</div>
);
}
function MessageBubble({
message,
}: {
message: EditorAgentMessage;
}) {
const isUser = message.role === 'user';
return (
<article
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
aria-label={`${messageRoleLabel(message.role)}消息`}
>
<div
className={`max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
isUser
? 'bg-slate-900 text-white'
: message.kind === 'error'
? 'border border-red-200 bg-red-50 text-red-700'
: 'border border-slate-200 bg-white text-slate-700'
}`}
>
<div className="whitespace-pre-wrap break-words">
{message.text || (message.status === 'streaming' ? '...' : '')}
</div>
{message.attachments.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.attachments.map((attachment) => (
<AttachmentChip
key={attachmentKey(attachment)}
attachment={attachment}
/>
))}
</div>
) : null}
<GenerationRecordsView
generations={message.generations}
/>
</div>
</article>
);
}
function AttachmentPickerModal({
open,
tab,
@@ -412,7 +237,8 @@ export function EditorAgentConversationPanelView({
onToggleOpen,
layers = [],
assets = [],
onGenerationResult,
onCanvasRefreshRequested,
onConfirmSent,
client,
}: EditorAgentConversationPanelViewProps) {
const [hasConversationMounted, setHasConversationMounted] = useState(open);
@@ -421,30 +247,32 @@ export function EditorAgentConversationPanelView({
setHasConversationMounted(true);
}
}, [open]);
const projectId = useImageCanvasContextStore(
(state) => state.projectId,
);
const projectId = useImageCanvasContextStore((state) => state.projectId);
const effectiveProjectId = hasConversationMounted ? projectId : null;
const {
conversations,
activeConversationId,
messages,
stage,
isLoadingConversations,
isLoadingMessages,
isCreatingConversation,
isDeletingConversation,
isStreaming,
isWaiting,
toolCallAction,
isToolCallActionPending,
errorMessage,
createConversation,
selectConversation,
refreshActiveConversation,
sendMessage,
stopCurrentTurn,
confirmToolCall,
cancelToolCall,
deleteActiveConversation,
} = useEditorAgentConversation({
projectId: effectiveProjectId,
client,
onGenerationResult,
onCanvasRefreshRequested,
onConfirmSent,
});
const [draftText, setDraftText] = useState('');
const [attachments, setAttachments] = useState<EditorAgentAttachmentRef[]>(
@@ -476,8 +304,8 @@ export function EditorAgentConversationPanelView({
return optionMap;
}, [canvasAttachmentOptions, libraryAttachmentOptions]);
const currentStageLabel = stageLabel(stage);
const hasProject = Boolean(projectId?.trim());
const isConversationBusy = isWaiting || isToolCallActionPending;
const openAttachmentPicker = () => {
setAttachmentError(null);
@@ -513,8 +341,7 @@ export function EditorAgentConversationPanelView({
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isStreaming) {
stopCurrentTurn();
if (isWaiting) {
return;
}
if (isPastingAttachment) {
@@ -631,6 +458,7 @@ export function EditorAgentConversationPanelView({
EDITOR_AGENT_MAX_ATTACHMENTS - attachments.length;
const uploadFiles = imageFiles.slice(0, remainingAttachmentSlots);
const hasOverflow = uploadFiles.length < imageFiles.length;
// TODO: deduplicate those existing assets
setIsPastingAttachment(true);
setAttachmentError('图片上传中');
void Promise.all(
@@ -688,7 +516,9 @@ export function EditorAgentConversationPanelView({
aria-label="当前对话"
value={activeConversationId ?? ''}
disabled={
!conversations.length || isLoadingConversations || isStreaming
!conversations.length ||
isLoadingConversations ||
isConversationBusy
}
onChange={(event) => {
const nextConversationId = event.currentTarget.value;
@@ -714,7 +544,9 @@ export function EditorAgentConversationPanelView({
type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-white disabled:opacity-45"
aria-label="新建对话"
disabled={!hasProject || isCreatingConversation || isStreaming}
disabled={
!hasProject || isCreatingConversation || isConversationBusy
}
onClick={() => void createConversation()}
>
<Plus className="h-4 w-4" aria-hidden="true" />
@@ -724,7 +556,9 @@ export function EditorAgentConversationPanelView({
className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-white text-slate-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-45"
aria-label="删除当前对话"
disabled={
!activeConversationId || isDeletingConversation || isStreaming
!activeConversationId ||
isDeletingConversation ||
isConversationBusy
}
onClick={() => setDeleteConfirmOpen(true)}
>
@@ -739,15 +573,16 @@ export function EditorAgentConversationPanelView({
<X className="h-4 w-4" aria-hidden="true" />
</button>
</header>
{currentStageLabel ? (
{isConversationBusy ? (
<div className="flex items-center gap-2 border-b border-slate-200 bg-white/60 px-4 py-2 text-xs text-slate-500">
{isStreaming ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : null}
<span>{currentStageLabel}</span>
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
<span>
{toolCallAction?.action === 'confirm'
? '执行中'
: toolCallAction?.action === 'cancel'
? '取消中'
: '思考中'}
</span>
</div>
) : null}
<div
@@ -761,12 +596,26 @@ export function EditorAgentConversationPanelView({
加载中
</div>
) : messages.length ? (
messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
/>
))
<>
{messages.map((message, messageIndex) => (
<MessageBubble
key={`${message.createdAt}-${messageIndex}`}
message={message}
busyAction={
toolCallAction?.messageId === message.id
? toolCallAction.action
: null
}
onConfirmToolCall={confirmToolCall}
onCancelToolCall={cancelToolCall}
onJobCompleted={() => {
void refreshActiveConversation();
onCanvasRefreshRequested?.();
}}
/>
))}
{isWaiting ? <ThinkingBubble /> : null}
</>
) : (
<div className="rounded-3xl border border-dashed border-slate-200 bg-white/70 px-4 py-8 text-center text-sm text-slate-400">
暂无消息
@@ -825,21 +674,14 @@ export function EditorAgentConversationPanelView({
type="submit"
className="inline-flex h-10 min-w-16 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
disabled={
!isStreaming &&
((!draftText.trim() && !attachments.length) || !hasProject)
isWaiting ||
isToolCallActionPending ||
(!draftText.trim() && !attachments.length) ||
!hasProject
}
>
{isStreaming ? (
<>
<Square className="h-3.5 w-3.5" aria-hidden="true" />
停止
</>
) : (
<>
<Send className="h-3.5 w-3.5" aria-hidden="true" />
发送
</>
)}
<Send className="h-3.5 w-3.5" aria-hidden="true" />
发送
</button>
</div>
</form>
@@ -0,0 +1,53 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { EditorAgentMessage } from '@/packages/shared/src/contracts';
import { MessageBubble } from './MessageBubble.tsx';
function renderMessage(message: EditorAgentMessage) {
return render(
<MessageBubble
message={message}
busyAction={null}
onConfirmToolCall={vi.fn()}
onCancelToolCall={vi.fn()}
/>,
);
}
describe('MessageBubble', () => {
it('shows prefixed system errors as red Agent errors without the wire prefix', () => {
renderMessage({
id: 2,
role: 'system',
text: 'ERROR planning failed',
attachments: [],
toolCall: null,
createdAt: '2026-07-16T00:00:00Z',
});
const error = screen.getByLabelText('Agent错误');
expect(error.textContent).toContain('planning failed');
expect(error.textContent).not.toContain('ERROR');
expect(error.firstElementChild?.classList.contains('bg-red-50')).toBe(true);
expect(error.firstElementChild?.classList.contains('text-red-700')).toBe(
true,
);
});
it('continues to hide internal system messages without the error prefix', () => {
const { container } = renderMessage({
id: 3,
role: 'system',
text: 'internal attachment bookkeeping',
attachments: [],
toolCall: null,
createdAt: '2026-07-16T00:00:00Z',
});
expect(container.childElementCount).toBe(0);
});
});
@@ -0,0 +1,136 @@
import {
EDITOR_AGENT_ERROR_MESSAGE_PREFIX,
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 ToolCallView from '@/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx';
function messageRoleLabel(role: EditorAgentMessage['role']) {
if (role === 'user') {
return '你';
}
return 'Agent';
}
export function ThinkingBubble() {
return (
<article className="flex justify-start" aria-label="Agent思考中">
<div className="max-w-[86%] rounded-3xl border border-slate-200 bg-white px-3.5 py-3 text-sm leading-6 shadow-sm">
<div className="flex items-center gap-1.5">
<span className="flex gap-0.5">
<span
className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"
style={{ animationDelay: '0ms' }}
/>
<span
className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"
style={{ animationDelay: '150ms' }}
/>
<span
className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"
style={{ animationDelay: '300ms' }}
/>
</span>
</div>
</div>
</article>
);
}
type MessageBubbleProps = {
message: EditorAgentMessage;
busyAction: 'confirm' | 'cancel' | null;
onConfirmToolCall: (messageId: number) => Promise<void>;
onCancelToolCall: (messageId: number) => Promise<void>;
onJobCompleted?: () => void;
};
export function MessageBubble({
message,
busyAction,
onConfirmToolCall,
onCancelToolCall,
onJobCompleted,
}: MessageBubbleProps) {
const systemErrorText =
message.role === 'system' &&
!message.toolCall &&
message.text.startsWith(EDITOR_AGENT_ERROR_MESSAGE_PREFIX)
? message.text.slice(EDITOR_AGENT_ERROR_MESSAGE_PREFIX.length)
: null;
if (message.role === 'system' && !message.toolCall && systemErrorText === null) {
return null;
}
if (
message.role === 'system' &&
message.toolCall &&
message.toolCall.status === 'not_completed' &&
!message.toolCall.externalJobId
) {
return (
<PendingToolCall
messageId={message.id}
toolCall={message.toolCall}
busyAction={busyAction}
onConfirm={onConfirmToolCall}
onCancel={onCancelToolCall}
/>
);
}
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const isSystemError = systemErrorText !== null;
return (
<article
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
aria-label={
isSystemError
? 'Agent错误'
: isSystem
? 'Agent操作'
: `${messageRoleLabel(message.role)}消息`
}
>
<div
className={
isSystem && !isSystemError
? 'max-w-[86%]'
: `max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
isUser
? 'bg-slate-900 text-white'
: isSystemError || message.toolCall?.status === 'failed'
? 'border border-red-200 bg-red-50 text-red-700'
: 'border border-slate-200 bg-white text-slate-700'
}`
}
>
{(!isSystem || isSystemError) && (systemErrorText ?? message.text) ? (
<div className="whitespace-pre-wrap break-words">
{systemErrorText ?? message.text}
</div>
) : null}
{!isSystem && message.attachments.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.attachments.map((attachment) => (
<AttachmentChip
key={attachmentKey(attachment)}
attachment={attachment}
/>
))}
</div>
) : null}
{message.toolCall ? (
<ToolCallView
toolCall={message.toolCall}
onJobCompleted={onJobCompleted}
/>
) : null}
</div>
</article>
);
}
@@ -0,0 +1,155 @@
import { Check, Coins, Loader2, Pencil, X } from 'lucide-react';
import type { EditorAgentToolCall } from '@/packages/shared/src/contracts';
import { editorAgentToolLabel } from '@/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts';
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
type PendingToolCallAction = 'confirm' | 'cancel' | null;
type PendingToolCallProps = {
messageId: number;
toolCall: EditorAgentToolCall;
busyAction: PendingToolCallAction;
onConfirm: (messageId: number) => Promise<void>;
onCancel: (messageId: number) => Promise<void>;
};
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
export function PendingToolCall({
messageId,
toolCall,
busyAction,
onConfirm,
onCancel,
}: PendingToolCallProps) {
const displayArgs = toolCall.displayArgs;
const label = editorAgentToolLabel(toolCall.toolName);
const isBusy = busyAction !== null;
const statusLabel =
busyAction === 'confirm'
? '执行中'
: busyAction === 'cancel'
? '取消中'
: '待确认';
const handleCancel = () => {
void onCancel(messageId).catch(() => undefined);
};
const handleConfirm = () => {
void onConfirm(messageId).catch(() => undefined);
};
return (
<article className="flex justify-start" aria-label={`待确认的${label}操作`}>
<div className="w-full max-w-[86%] rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-700 shadow-sm">
<div className="flex items-center gap-2 font-medium text-slate-900">
<Pencil className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>{label}</span>
<span className="ml-auto text-xs font-normal text-amber-700">
{statusLabel}
</span>
</div>
<div className="mt-3 space-y-3 empty:hidden">
{displayArgs.stringArgs.map((argument, index) => (
<div
key={`${argument.name}-${index}`}
className="rounded-lg bg-slate-50 px-2.5 py-2"
>
<div className="text-xs font-medium text-slate-500">
{argument.label}
</div>
<div className="mt-1 whitespace-pre-wrap break-words text-sm leading-5 text-slate-800">
{argument.value}
</div>
</div>
))}
{displayArgs.imageArgs.map((argument, argumentIndex) => (
<div key={`${argument.name}-${argumentIndex}`}>
<div className="flex items-center gap-2 text-xs font-medium text-slate-500">
<span>{argument.label}</span>
<span className="font-normal text-slate-400">
{argument.refs.length} 张
</span>
</div>
{argument.refs.length ? (
<div className="mt-1.5 flex flex-wrap gap-2">
{argument.refs.map((image, imageIndex) => {
const imageLabel =
readString(image.label) ?? `图片 ${imageIndex + 1}`;
return (
<figure
key={`${image.imageId}-${imageIndex}`}
className="w-20 min-w-0"
>
<div className="overflow-hidden rounded-lg border border-slate-200 bg-slate-100">
<ResolvedAssetImage
src={image.thumbnailSrc ?? image.imageSrc}
objectKey={image.objectKey}
refreshKey={image.imageId}
alt={`${argument.label}:${imageLabel}`}
className="h-20 w-20 object-contain"
/>
</div>
{image.label ? (
<figcaption className="mt-1 truncate text-center text-[11px] text-slate-500">
{image.label}
</figcaption>
) : null}
</figure>
);
})}
</div>
) : null}
</div>
))}
</div>
<div className="mt-3 flex items-center gap-1.5 rounded-lg border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs font-medium text-amber-800">
<Coins className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>预计消耗 {displayArgs.extras.priceMudPoints}泥点</span>
</div>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isBusy}
onClick={handleCancel}
>
{busyAction === 'cancel' ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : (
<X className="h-3.5 w-3.5" aria-hidden="true" />
)}
{busyAction === 'cancel' ? '取消中' : '取消'}
</button>
<button
type="button"
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-slate-900 px-3 text-sm font-medium text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isBusy}
onClick={handleConfirm}
>
{busyAction === 'confirm' ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
)}
{busyAction === 'confirm' ? '执行中' : '确认'}
</button>
</div>
</div>
</article>
);
}
@@ -0,0 +1,178 @@
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<string | null>(
toolCall.error ?? null,
);
const terminalNotifiedRef = useRef(false);
const onJobCompletedRef = useRef(onJobCompleted);
useEffect(() => {
onJobCompletedRef.current = onJobCompleted;
}, [onJobCompleted]);
useEffect(() => {
if (toolCall.status !== 'not_completed' || !jobId) {
terminalNotifiedRef.current = false;
return;
}
// Only a different job may reinitialize the local display state.
setDisplayStatus(initialDisplayStatus);
setDisplayError(toolCall.error ?? null);
terminalNotifiedRef.current = false;
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);
};
}, [displaySourceKey]);
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"
>
<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"
>
<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"
>
<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;
@@ -0,0 +1,5 @@
import type { EditorAgentAttachmentRef } from '@/packages/shared/src/contracts';
export function attachmentKey(attachment: EditorAgentAttachmentRef) {
return `${attachment.source}:${attachment.referenceId}`;
}
@@ -0,0 +1,24 @@
export function editorAgentToolLabel(toolName: string) {
if (toolName === 'edit-image') {
return '修改图片';
}
if (toolName === 'generate-character') {
return '生成角色';
}
if (toolName === 'generate-icon-spritesheet') {
return '生成图标';
}
if (toolName === 'generate-ui-design') {
return '生成 UI';
}
if (toolName === 'generate-video') {
return '生成视频';
}
if (toolName === 'generate-sound-effect') {
return '生成音效';
}
if (toolName === 'generate-background-music') {
return '生成背景音乐';
}
return '生成图片';
}
@@ -12,11 +12,11 @@ import userEvent from '@testing-library/user-event';
import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation';
import {
ApiClientError,
AuthUiContext,
createAuthValue,
defaultEditorAssetLibraryAssets,
defaultEditorProjectLayers,
defaultEditorProjectResources,
dispatchPointerEvent,
@@ -25,7 +25,6 @@ import {
readZipText,
setupImageCanvasEditorViewTestLifecycle,
} from './ImageCanvasEditorView.test-utils';
import type { EditorAgentConversationClient } from './useEditorAgentConversation';
type EditorAgentListConversations =
EditorAgentConversationClient['listConversations'];
@@ -35,7 +34,7 @@ type EditorAgentGetConversation =
EditorAgentConversationClient['getConversation'];
type EditorAgentDeleteConversation =
EditorAgentConversationClient['deleteConversation'];
type EditorAgentStreamMessage = EditorAgentConversationClient['streamMessage'];
type EditorAgentStreamMessage = EditorAgentConversationClient['sendMessage'];
type EditorAgentConversationSummary = Awaited<
ReturnType<EditorAgentListConversations>
>[number];
@@ -87,12 +86,23 @@ const deleteEditorAgentConversationMock = vi.hoisted(() =>
updatedAt: '2026-07-03T00:00:00.000Z',
})),
);
const streamEditorAgentMessageMock = vi.hoisted(() =>
const sendEditorAgentMessageMock = vi.hoisted(() =>
vi.fn<
Parameters<EditorAgentStreamMessage>,
ReturnType<EditorAgentStreamMessage>
>(async () => undefined),
>(async () => ({
conversation: {
conversationId: 'editor-agent-conv-test',
projectId: 'editor-project-default',
title: '画布 Agent',
updatedAt: '2026-07-03T00:00:00.000Z',
},
deltaMessages: [],
errorMessage: null,
})),
);
const confirmEditorAgentToolCallMock = vi.hoisted(() => vi.fn());
const cancelEditorAgentToolCallMock = vi.hoisted(() => vi.fn());
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
@@ -159,11 +169,13 @@ vi.mock('../../services/frontendRuntimeConfigService', () => ({
}));
vi.mock('../../services/image-editor/editorAgentClient', () => ({
cancelEditorAgentToolCall: cancelEditorAgentToolCallMock,
confirmEditorAgentToolCall: confirmEditorAgentToolCallMock,
createEditorAgentConversation: createEditorAgentConversationMock,
deleteEditorAgentConversation: deleteEditorAgentConversationMock,
getEditorAgentConversation: getEditorAgentConversationMock,
listEditorAgentConversations: listEditorAgentConversationsMock,
streamEditorAgentMessage: streamEditorAgentMessageMock,
sendEditorAgentMessage: sendEditorAgentMessageMock,
}));
function createEditorAgentConversationSummary(
@@ -173,45 +185,66 @@ function createEditorAgentConversationSummary(
conversationId: 'editor-agent-conv-test',
projectId: 'editor-project-default',
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
...overrides,
};
}
function createEditImageToolCallDisplayArgs(prompt: string) {
return {
stringArgs: [{ name: 'prompt', label: '修改要求', value: prompt }],
imageArgs: [
{
name: 'object_image_id',
label: '目标图片',
refs: [
{
imageId: 'source-image',
imageSrc: '/source-image.png',
label: '源图片',
},
],
},
],
extras: { priceMudPoints: 3 },
};
}
function createEditorAgentDetailWithGeneration(
resourceId: string,
): EditorAgentConversationDetail {
const summary = createEditorAgentConversationSummary();
const conversationSummary = createEditorAgentConversationSummary();
return {
...summary,
...conversationSummary,
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
messages: [
{
id: 'agent-message-result',
role: 'assistant',
kind: 'chat',
text: '生成完成',
id: 0,
role: 'system',
text: 'internal completed tool output',
attachments: [],
generations: [
{
toolCallId: 'agent-tool-call-1',
toolName: 'generate_image',
taskId: 'task-agent-1',
status: 'completed',
model: 'gpt-image-2',
images: [
{
resourceId,
imageSrc: '/agent-result.png',
thumbnailSrc: '/agent-result-thumb.png',
width: 512,
height: 512,
},
],
error: null,
toolCall: {
toolName: 'generate_image',
status: 'completed',
externalJobId: 'task-generation-1',
args: {},
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 0 },
},
],
status: 'completed',
images: [
{
resourceId,
imageSrc: '/agent-result.png',
thumbnailSrc: '/agent-result-thumb.png',
width: 512,
height: 512,
},
],
error: null,
},
createdAt: '2026-07-03T00:00:01.000Z',
},
],
@@ -276,16 +309,26 @@ describe('ImageCanvasEditorView', () => {
...createEditorAgentConversationSummary({
conversationId: 'editor-agent-conv-created',
}),
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
messages: [],
});
getEditorAgentConversationMock.mockResolvedValue({
...createEditorAgentConversationSummary(),
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
messages: [],
});
deleteEditorAgentConversationMock.mockResolvedValue(
createEditorAgentConversationSummary(),
);
streamEditorAgentMessageMock.mockResolvedValue(undefined);
sendEditorAgentMessageMock.mockResolvedValue({
conversation: createEditorAgentConversationSummary(),
deltaMessages: [],
errorMessage: null,
});
confirmEditorAgentToolCallMock.mockReset();
cancelEditorAgentToolCallMock.mockReset();
getPlatformProfileDashboardMock.mockResolvedValue({
walletBalance: 1234,
totalPlayTimeMs: 0,
@@ -339,7 +382,9 @@ describe('ImageCanvasEditorView', () => {
createEditorAgentConversationMock.mockReset();
getEditorAgentConversationMock.mockReset();
deleteEditorAgentConversationMock.mockReset();
streamEditorAgentMessageMock.mockReset();
sendEditorAgentMessageMock.mockReset();
confirmEditorAgentToolCallMock.mockReset();
cancelEditorAgentToolCallMock.mockReset();
getPlatformProfileDashboardMock.mockReset();
getRpgProfileRechargeCenterMock.mockReset();
getRpgProfileWalletLedgerMock.mockReset();
@@ -2113,119 +2158,63 @@ describe('ImageCanvasEditorView', () => {
expect(loadEditorProjectMock).not.toHaveBeenCalled();
});
it('refreshes canvas and asset library when Agent generation finishes', async () => {
it('confirms Agent tool calls and refreshes the conversation for the job id', async () => {
enableEditorAgentSidebarForTest();
let showGeneratedAsset = false;
loadEditorAssetLibraryMock.mockImplementation(async () => ({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: showGeneratedAsset
? [
...defaultEditorAssetLibraryAssets,
{
assetId: 'asset-agent-stream',
folderId: 'project',
label: 'Agent结果素材',
imageSrc: '/agent-stream.png',
width: 512,
height: 512,
sourceType: 'generated',
assetKind: 'editor_agent_generated_image',
},
]
: defaultEditorAssetLibraryAssets,
}));
listEditorAgentConversationsMock.mockResolvedValueOnce([
createEditorAgentConversationSummary(),
]);
getEditorAgentConversationMock.mockResolvedValueOnce({
const pendingConversation: EditorAgentConversationDetail = {
...createEditorAgentConversationSummary(),
messages: [],
});
streamEditorAgentMessageMock.mockImplementation(
async (conversationId, _payload, options) => {
options.onEvent?.({
event: 'message_delta',
data: {
conversationId,
messageId: 'assistant-agent-stream',
role: 'assistant',
kind: 'chat',
textDelta: '我来生成图片。',
},
});
showGeneratedAsset = true;
options.onEvent?.({
event: 'generation_result',
data: {
conversationId,
messageId: 'assistant-agent-stream',
toolCallId: 'tool-call-agent-stream',
toolName: 'generate_image',
model: 'gpt-image-2',
images: [
{
resourceId: 'resource-agent-stream',
imageSrc: '/agent-stream.png',
thumbnailSrc: null,
width: 512,
height: 512,
},
],
},
});
options.onEvent?.({
event: 'done',
data: { conversationId, title: null },
});
},
);
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '默认项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
...defaultEditorProjectLayers,
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
messages: [
{
layerId: 'layer-agent-stream',
resourceId: 'resource-agent-stream',
title: 'Agent结果图',
src: '/agent-stream.png',
x: 1500,
y: 300,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 3,
sourceType: 'generated',
id: 3,
role: 'system',
text: 'internal pending tool prompt',
attachments: [],
toolCall: {
toolName: 'edit-image',
status: 'not_completed',
args: {
object_image_id: 'source-image',
prompt: '把图片换成像素风',
},
displayArgs:
createEditImageToolCallDisplayArgs('把图片换成像素风'),
images: [],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
},
],
resources: [
...defaultEditorProjectResources,
};
const confirmedConversation: EditorAgentConversationDetail = {
...pendingConversation,
messages: [
{
resourceId: 'resource-agent-stream',
projectId: 'editor-project-default',
imageSrc: '/agent-stream.png',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'Agent生成结果',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-agent-stream',
assetKind: 'editor_agent_generated_image',
id: 3,
role: 'system',
text: 'internal confirmed tool output',
attachments: [],
toolCall: {
toolName: 'edit-image',
status: 'not_completed',
externalJobId: 'task-edit-canvas-1',
args: {},
displayArgs:
createEditImageToolCallDisplayArgs('把图片换成像素风'),
images: [],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
},
],
updatedAt: '2026-07-03T00:02:00.000Z',
});
};
getEditorAgentConversationMock
.mockResolvedValueOnce(pendingConversation)
.mockResolvedValue(confirmedConversation);
confirmEditorAgentToolCallMock.mockResolvedValue(undefined);
render(<ImageCanvasEditorView />);
@@ -2235,20 +2224,21 @@ describe('ImageCanvasEditorView', () => {
'editor-agent-conv-test',
);
});
fireEvent.change(await screen.findByLabelText('发送给画布 Agent'), {
target: { value: '生成一张图' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
const getConversationCallsBeforeConfirm =
getEditorAgentConversationMock.mock.calls.length;
fireEvent.click(await screen.findByRole('button', { name: '确认' }));
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith(
'editor-project-default',
expect(confirmEditorAgentToolCallMock).toHaveBeenCalledWith(
'editor-agent-conv-test',
3,
);
});
expect(await screen.findByAltText('画布图片:Agent结果图')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(await screen.findByText('Agent结果素材')).toBeTruthy();
expect(getEditorAgentConversationMock).toHaveBeenCalledTimes(
getConversationCallsBeforeConfirm + 1,
);
expect(await screen.findByText('执行中')).toBeTruthy();
expect(screen.queryByAltText('画布图片:Agent结果图')).toBeNull();
});
it('resets the canvas view without forwarding the click event to fit layers', () => {
@@ -9,7 +9,6 @@ import {
useState,
} from 'react';
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
import {
@@ -1098,20 +1097,14 @@ export function ImageCanvasEditorView({
},
[applyProjectSnapshot, refreshAssetLibrary],
);
const handleEditorAgentGenerationResult = useCallback(
(event: EditorAgentGenerationResultEvent) => {
const hasGeneratedResource = event.images.some((image) =>
image.resourceId?.trim(),
);
if (!projectId || !hasGeneratedResource) {
return;
}
void loadEditorProject(projectId)
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
},
[applyGeneratedProjectSnapshot, projectId],
);
const handleEditorAgentCanvasRefreshRequested = useCallback(() => {
if (!projectId) {
return;
}
void loadEditorProject(projectId)
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
}, [applyGeneratedProjectSnapshot, projectId]);
const persistUpdatedLayerResource = useCallback(
(layer: CanvasLayer) => {
if (!projectId) {
@@ -1243,6 +1236,13 @@ export function ImageCanvasEditorView({
applyProjectSnapshot: applyGeneratedProjectSnapshot,
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
});
const handleEditorAgentConfirmSent = useCallback(() => {
generationSurface.refreshTaskList();
}, [generationSurface]);
const handleEditorAgentJobCompleted = useCallback(() => {
generationSurface.refreshTaskList();
handleEditorAgentCanvasRefreshRequested();
}, [generationSurface, handleEditorAgentCanvasRefreshRequested]);
const showGenerationWarning = generationSurface.showGenerationWarning;
const handleExternalGenerationTasksCompleted = useCallback(
(tasks: ExternalGenerationTaskRecord[]) => {
@@ -2152,7 +2152,8 @@ export function ImageCanvasEditorView({
onActivateGenerationDialog: activateCanvasGenerationDialog,
onFocusExternalTask: focusExternalGenerationTask,
onExternalTasksCompleted: handleExternalGenerationTasksCompleted,
onEditorAgentGenerationResult: handleEditorAgentGenerationResult,
onEditorAgentCanvasRefreshRequested: handleEditorAgentJobCompleted,
onEditorAgentConfirmSent: handleEditorAgentConfirmSent,
onToggleTaskSidebar: toggleTaskSidebar,
onToggleAgentConversation: toggleAgentConversation,
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
@@ -7,9 +7,9 @@ import type {
RefObject,
} from 'react';
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView';
import type { ExternalGenerationTaskRecord } from '@/packages/shared/src';
import { EditorAgentConversationPanelView } from '@/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx';
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
import type {
@@ -127,9 +127,8 @@ export type ImageCanvasStageViewProps = {
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void;
onEditorAgentGenerationResult?: (
event: EditorAgentGenerationResultEvent,
) => void;
onEditorAgentCanvasRefreshRequested?: () => void;
onEditorAgentConfirmSent?: () => void;
onToggleTaskSidebar: () => void;
onToggleAgentConversation: () => void;
onCropExpandHandlePointerDown: (
@@ -262,7 +261,8 @@ export function ImageCanvasStageView({
onActivateGenerationDialog,
onFocusExternalTask,
onExternalTasksCompleted,
onEditorAgentGenerationResult,
onEditorAgentCanvasRefreshRequested,
onEditorAgentConfirmSent,
onToggleTaskSidebar,
onToggleAgentConversation,
onCropExpandHandlePointerDown,
@@ -485,7 +485,8 @@ export function ImageCanvasStageView({
onToggleOpen={onToggleAgentConversation}
layers={layers}
assets={editorAgentAssets}
onGenerationResult={onEditorAgentGenerationResult}
onCanvasRefreshRequested={onEditorAgentCanvasRefreshRequested}
onConfirmSent={onEditorAgentConfirmSent}
/>
) : null}
@@ -1,428 +0,0 @@
/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation';
function createClient(): EditorAgentConversationClient {
return {
listConversations: vi.fn().mockResolvedValue([
{
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
},
]),
createConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '新对话',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
}),
getConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
}),
deleteConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
}),
streamMessage: vi
.fn()
.mockImplementation(async (_conversationId, _payload, options) => {
options.onEvent?.({
event: 'stage',
data: {
conversationId: 'conversation-1',
stage: 'thinking',
},
});
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-1',
role: 'assistant',
kind: 'chat',
textDelta: '我来处理',
},
});
options.onEvent?.({
event: 'tool_started',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-1',
toolCallId: 'tool-call-1',
toolName: 'generate_image',
taskId: 'task-1',
model: 'gpt-image-2',
},
});
options.onEvent?.({
event: 'generation_result',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-1',
toolCallId: 'tool-call-1',
toolName: 'generate_image',
model: null,
images: [
{
resourceId: 'resource-result-1',
imageSrc: '/result.png',
thumbnailSrc: '/result-thumb.png',
width: 1024,
height: 1024,
},
],
},
});
options.onEvent?.({
event: 'done',
data: {
conversationId: 'conversation-1',
title: '像素角色',
},
});
}),
};
}
describe('useEditorAgentConversation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('loads conversations and applies message stream events', async () => {
const client = createClient();
const onGenerationResult = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
onGenerationResult,
}),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await result.current.sendMessage('把这个角色改成像素风');
});
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '把这个角色改成像素风',
attachments: [],
}),
expect.objectContaining({
onEvent: expect.any(Function),
signal: expect.any(AbortSignal),
}),
);
expect(result.current.stage).toBe('completed');
expect(result.current.isStreaming).toBe(false);
expect(onGenerationResult).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'tool-call-1',
images: [
expect.objectContaining({
resourceId: 'resource-result-1',
}),
],
}),
);
expect(result.current.messages.map((message) => message.text)).toEqual([
'把这个角色改成像素风',
'我来处理',
]);
expect(result.current.messages[1]?.generations).toEqual([
expect.objectContaining({
toolCallId: 'tool-call-1',
taskId: 'task-1',
model: 'gpt-image-2',
status: 'completed',
images: [
expect.objectContaining({
resourceId: 'resource-result-1',
thumbnailSrc: '/result-thumb.png',
}),
],
}),
]);
expect(result.current.conversations[0]?.title).toBe('像素角色');
});
it('creates a conversation before sending when the project has no history', async () => {
const client = createClient();
vi.mocked(client.listConversations).mockResolvedValueOnce([]);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.isLoadingConversations).toBe(false);
});
await act(async () => {
await result.current.sendMessage('新建后发送');
});
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-2',
expect.objectContaining({ text: '新建后发送' }),
expect.any(Object),
);
});
it('allows sending an attachment-only message', async () => {
const client = createClient();
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await result.current.sendMessage('', [
{
source: 'canvas_resource',
referenceId: 'resource-1',
objectKey: 'generated-editor-assets/resource-1.png',
imageSrc: '/resource-1.png',
label: '参考图',
},
]);
});
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '',
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-1',
}),
],
}),
expect.any(Object),
);
});
it('keeps LLM planning failures as one failed assistant message', async () => {
const client = createClient();
vi.mocked(client.streamMessage).mockImplementation(
async (_conversationId, _payload, options) => {
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-planning-error',
role: 'assistant',
kind: 'error',
textDelta: '画布 Agent 的 LLM 未配置,无法处理这句话。',
},
});
options.onEvent?.({
event: 'stage',
data: {
conversationId: 'conversation-1',
stage: 'failed',
},
});
options.onEvent?.({
event: 'done',
data: {
conversationId: 'conversation-1',
title: null,
},
});
},
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await result.current.sendMessage('这是美术素材');
});
expect(result.current.stage).toBe('failed');
expect(result.current.errorMessage).toBe(
'画布 Agent 的 LLM 未配置,无法处理这句话。',
);
expect(
result.current.messages.filter((message) => message.kind === 'error'),
).toEqual([
expect.objectContaining({
id: 'assistant-planning-error',
text: '画布 Agent 的 LLM 未配置,无法处理这句话。',
status: 'failed',
}),
]);
});
it('shows a failed tool completion as a generation record', async () => {
const client = createClient();
vi.mocked(client.streamMessage).mockImplementation(
async (_conversationId, _payload, options) => {
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-failed',
role: 'assistant',
kind: 'chat',
textDelta: '我先尝试生成。',
},
});
options.onEvent?.({
event: 'tool_started',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-failed',
toolCallId: 'tool-call-failed',
toolName: 'generate_image',
taskId: null,
model: 'gpt-image-2',
status: 'generating',
},
});
options.onEvent?.({
event: 'tool_completed',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-failed',
toolCallId: 'tool-call-failed',
toolName: 'generate_image',
taskId: null,
model: 'gpt-image-2',
status: 'failed',
error: '泥点余额不足',
},
});
options.onEvent?.({
event: 'error',
data: {
conversationId: 'conversation-1',
code: 'INSUFFICIENT_BALANCE',
message: '泥点余额不足',
recoverable: true,
},
});
},
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await expect(
result.current.sendMessage('生成一张图'),
).resolves.toBeUndefined();
});
const failedMessage = result.current.messages.find(
(message) => message.id === 'assistant-failed',
);
expect(failedMessage?.status).toBe('failed');
expect(failedMessage?.generations).toEqual([
expect.objectContaining({
toolCallId: 'tool-call-failed',
status: 'failed',
model: 'gpt-image-2',
error: '泥点余额不足',
}),
]);
expect(result.current.errorMessage).toBe('泥点余额不足');
});
it('aborts the active stream and marks streaming messages as stopped', async () => {
const client = createClient();
let streamSignal: AbortSignal | null = null;
vi.mocked(client.streamMessage).mockImplementation(
(_conversationId, _payload, options) =>
new Promise<void>((resolve) => {
streamSignal = options.signal ?? null;
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-streaming',
role: 'assistant',
kind: 'chat',
textDelta: '处理中',
},
});
streamSignal?.addEventListener('abort', () => resolve(), {
once: true,
});
}),
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
void act(() => {
void result.current.sendMessage('请继续');
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
act(() => {
result.current.stopCurrentTurn();
});
await waitFor(() => {
expect(streamSignal?.aborted).toBe(true);
});
expect(result.current.messages.at(-1)?.status).toBe('stopped');
expect(result.current.isStreaming).toBe(false);
});
});
@@ -616,6 +616,9 @@ export function useImageCanvasGenerationWorkflow({
setIsTaskSidebarOpen(true);
setTaskListRefreshKey((key) => key + 1);
}, []);
const refreshTaskList = useCallback(() => {
setTaskListRefreshKey((key) => key + 1);
}, []);
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
@@ -2469,6 +2472,7 @@ export function useImageCanvasGenerationWorkflow({
removeSelectedLayerBackground,
splitSelectedIconSpritesheet,
taskListRefreshKey,
refreshTaskList,
isTaskSidebarOpen,
toggleTaskSidebar: () => setIsTaskSidebarOpen((open) => !open),
extractUiDesignAssets,
@@ -2536,6 +2540,7 @@ export function useImageCanvasGenerationWorkflow({
isMusicMenuOpen,
isTaskSidebarOpen,
taskListRefreshKey,
refreshTaskList,
isPublicationMenuOpen,
isPublicationReferenceMenuOpen,
isSpecMenuOpen,
+54
View File
@@ -598,6 +598,60 @@ describe('apiClient', () => {
expect(result).toEqual({ value: 42 });
});
it('reuses the exact post body and request id for an enabled unsafe transport retry', async () => {
setStoredAccessToken('editor-agent-token', { emit: false });
fetchMock
.mockRejectedValueOnce(new TypeError('network unavailable'))
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: { deltaMessages: [] },
error: null,
meta: { apiVersion: '2026-06-16' },
}),
headers: { 'Content-Type': 'application/json' },
}),
);
const body = JSON.stringify({
clientMessageId: 'client-message-1',
text: '生成一张图',
attachments: [],
});
await requestJson(
'/api/editor/agent-conversations/conversation-1/messages',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
},
'发送画布 Agent 消息失败',
{
authImpact: 'local',
retry: {
maxRetries: 1,
baseDelayMs: 1,
maxDelayMs: 1,
retryUnsafeMethods: true,
},
},
);
expect(fetchMock).toHaveBeenCalledTimes(2);
const firstInit = fetchMock.mock.calls[0]?.[1] as RequestInit;
const secondInit = fetchMock.mock.calls[1]?.[1] as RequestInit;
expect(firstInit.body).toBe(body);
expect(secondInit.body).toBe(body);
expect((firstInit.headers as Record<string, string>)['x-request-id']).toBe(
'web-11111111-2222-3333-4444-555555555555',
);
expect((secondInit.headers as Record<string, string>)['x-request-id']).toBe(
(firstInit.headers as Record<string, string>)['x-request-id'],
);
});
it('aborts requests when timeoutMs is reached', async () => {
setStoredAccessToken('timeout-token', { emit: false });
fetchMock.mockImplementation(
@@ -1,41 +1,24 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
cancelEditorAgentToolCall,
confirmEditorAgentToolCall,
createEditorAgentConversation,
deleteEditorAgentConversation,
getEditorAgentConversation,
listEditorAgentConversations,
streamEditorAgentMessage,
sendEditorAgentMessage,
} from './editorAgentClient';
const requestJsonMock = vi.hoisted(() => vi.fn());
const fetchWithApiAuthMock = vi.hoisted(() => vi.fn());
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
fetchWithApiAuth: fetchWithApiAuthMock,
}));
function createSseResponse(payload: string) {
const encoder = new TextEncoder();
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(payload));
controller.close();
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream; charset=utf-8' },
},
);
}
describe('editorAgentClient', () => {
afterEach(() => {
requestJsonMock.mockReset();
fetchWithApiAuthMock.mockReset();
});
it('uses the planned editor agent conversation CRUD routes', async () => {
@@ -117,40 +100,37 @@ describe('editorAgentClient', () => {
);
});
it('posts editor agent message streams and emits typed SSE events', async () => {
fetchWithApiAuthMock.mockResolvedValueOnce(
createSseResponse(
[
'event: stage',
'data: {"conversationId":"conversation-1","stage":"thinking"}',
'',
'event: message_delta',
'data: {"conversationId":"conversation-1","messageId":"assistant-1","role":"assistant","kind":"chat","textDelta":"我先看一下"}',
'',
'event: done',
'data: {"conversationId":"conversation-1","title":"生成角色"}',
'',
'',
].join('\n'),
),
);
const events: string[] = [];
await streamEditorAgentMessage(
'conversation-1',
{
clientMessageId: 'client-message-1',
text: '帮我把角色改成像素风',
attachments: [],
it('sends an editor agent message and returns delta messages', async () => {
const responseBody = {
conversation: {
conversationId: 'conversation-1',
projectId: 'project-1',
title: '帮我把角色改成像素风',
updatedAt: '2026-07-03T00:00:01.000Z',
},
{
onEvent: (event) => events.push(event.event),
},
);
deltaMessages: [
{
id: 1,
role: 'assistant',
text: '我来处理',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:00:00.000Z',
},
],
errorMessage: null,
};
requestJsonMock.mockResolvedValueOnce(responseBody);
expect(events).toEqual(['stage', 'message_delta', 'done']);
expect(fetchWithApiAuthMock).toHaveBeenCalledWith(
'/api/editor/agent-conversations/conversation-1/messages/stream',
const result = await sendEditorAgentMessage('conversation-1', {
clientMessageId: 'client-message-1',
text: '帮我把角色改成像素风',
attachments: [],
});
expect(result).toEqual(responseBody);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/agent-conversations/conversation-1/messages',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -160,10 +140,48 @@ describe('editorAgentClient', () => {
attachments: [],
}),
}),
'发送画布 Agent 消息失败',
expect.objectContaining({
timeoutMs: 1_200_000,
authImpact: 'local',
retry: {
maxRetries: 1,
baseDelayMs: 250,
maxDelayMs: 250,
retryUnsafeMethods: true,
},
}),
);
});
it('confirms and cancels a pending tool call by numeric message id', async () => {
requestJsonMock
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true });
await expect(
confirmEditorAgentToolCall('conversation/1', 7),
).resolves.toBeUndefined();
await expect(
cancelEditorAgentToolCall('conversation/1', 7),
).resolves.toBeUndefined();
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
'/api/editor/agent-conversations/conversation%2F1/messages/7/confirm',
{ method: 'POST' },
'确认画布 Agent 操作失败',
{
timeoutMs: 1_200_000,
authImpact: 'local',
},
);
expect(requestJsonMock).toHaveBeenNthCalledWith(
2,
'/api/editor/agent-conversations/conversation%2F1/messages/7/cancel',
{ method: 'POST' },
'取消画布 Agent 操作失败',
{ authImpact: 'local' },
);
});
});
+57 -34
View File
@@ -4,23 +4,23 @@ import type {
EditorAgentConversationListResponse,
EditorAgentConversationResponse,
EditorAgentConversationSummary,
EditorAgentSseEvent,
StreamEditorAgentMessageRequest,
EditorAgentMessageRequest,
EditorAgentMessageResponse,
} from '../../../packages/shared/src/contracts/editorAgent';
import {
appendApiErrorRequestId,
parseApiErrorMessage,
} from '../../../packages/shared/src/http';
import { fetchWithApiAuth, requestJson } from '../apiClient';
import { readEditorAgentSseEvents } from './editorAgentSse';
import { requestJson } from '../apiClient';
const EDITOR_PROJECT_AGENT_CONVERSATION_API_BASE = '/api/editor/projects';
const EDITOR_AGENT_CONVERSATION_API_BASE = '/api/editor/agent-conversations';
const EDITOR_AGENT_STREAM_TIMEOUT_MS = 1_200_000;
const EDITOR_AGENT_MESSAGE_TIMEOUT_MS = 1_200_000;
const EDITOR_AGENT_MESSAGE_RETRY = {
maxRetries: 1,
baseDelayMs: 250,
maxDelayMs: 250,
retryUnsafeMethods: true,
} as const;
export type StreamEditorAgentMessageOptions = {
export type SendEditorAgentMessageOptions = {
signal?: AbortSignal;
onEvent?: (event: EditorAgentSseEvent) => void;
};
type DeleteEditorAgentConversationResponse = {
@@ -48,6 +48,16 @@ function agentConversationPath(conversationId: string) {
)}`;
}
function agentToolCallActionPath(
conversationId: string,
messageId: number,
action: 'confirm' | 'cancel',
) {
return `${agentConversationPath(conversationId)}/messages/${encodeURIComponent(
String(messageId),
)}/${action}`;
}
export async function listEditorAgentConversations(
projectId: string,
): Promise<EditorAgentConversationSummary[]> {
@@ -97,36 +107,49 @@ export async function deleteEditorAgentConversation(
return response.conversation;
}
export async function streamEditorAgentMessage(
export async function sendEditorAgentMessage(
conversationId: string,
payload: StreamEditorAgentMessageRequest,
options: StreamEditorAgentMessageOptions = {},
) {
const response = await fetchWithApiAuth(
`${agentConversationPath(conversationId)}/messages/stream`,
payload: EditorAgentMessageRequest,
options: SendEditorAgentMessageOptions = {},
): Promise<EditorAgentMessageResponse> {
return requestJson<EditorAgentMessageResponse>(
`${agentConversationPath(conversationId)}/messages`,
{
...jsonRequest('POST', payload as unknown as Record<string, unknown>),
signal: options.signal,
},
'发送画布 Agent 消息失败',
{
timeoutMs: EDITOR_AGENT_STREAM_TIMEOUT_MS,
timeoutMs: EDITOR_AGENT_MESSAGE_TIMEOUT_MS,
authImpact: 'local',
retry: EDITOR_AGENT_MESSAGE_RETRY,
},
);
}
export async function confirmEditorAgentToolCall(
conversationId: string,
messageId: number,
): Promise<void> {
await requestJson<unknown>(
agentToolCallActionPath(conversationId, messageId, 'confirm'),
{ method: 'POST' },
'确认画布 Agent 操作失败',
{
timeoutMs: EDITOR_AGENT_MESSAGE_TIMEOUT_MS,
authImpact: 'local',
},
);
if (!response.ok) {
const responseText = await response.text();
throw new Error(
appendApiErrorRequestId(
parseApiErrorMessage(responseText, '发送画布 Agent 消息失败'),
response.headers.get('x-request-id'),
),
);
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
await readEditorAgentSseEvents(response, (event) => options.onEvent?.(event));
}
export async function cancelEditorAgentToolCall(
conversationId: string,
messageId: number,
): Promise<void> {
await requestJson<unknown>(
agentToolCallActionPath(conversationId, messageId, 'cancel'),
{ method: 'POST' },
'取消画布 Agent 操作失败',
{ authImpact: 'local' },
);
}
@@ -1,88 +0,0 @@
import { expect, test, vi } from 'vitest';
import { readEditorAgentSseEvents } from './editorAgentSse';
function createSseResponse(chunks: string[]) {
const encoder = new TextEncoder();
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}),
{
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
},
},
);
}
test('readEditorAgentSseEvents parses stage, generation result and done events', async () => {
const onEvent = vi.fn();
await readEditorAgentSseEvents(
createSseResponse([
'event: stage\r\ndata: {"conversationId":"conversation-1","stage":"generating"}\r\n\r\n',
'event: generation_result\r\ndata: {"conversationId":"conversation-1","messageId":"assistant-1","toolCallId":"tool-1","toolName":"generate_image","model":"gpt-image-2","images":[{"resourceId":"resource-1","imageSrc":"/generated/1.png","thumbnailSrc":null,"width":512,"height":512}]}\r\n\r\n',
'event: done\r\ndata: {"conversationId":"conversation-1","title":"像素角色"}\r\n\r\n',
]),
onEvent,
);
expect(onEvent).toHaveBeenNthCalledWith(1, {
event: 'stage',
data: {
conversationId: 'conversation-1',
stage: 'generating',
},
});
expect(onEvent).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
event: 'generation_result',
data: expect.objectContaining({
toolCallId: 'tool-1',
images: [
{
resourceId: 'resource-1',
imageSrc: '/generated/1.png',
thumbnailSrc: null,
width: 512,
height: 512,
},
],
}),
}),
);
expect(onEvent).toHaveBeenNthCalledWith(3, {
event: 'done',
data: {
conversationId: 'conversation-1',
title: '像素角色',
},
});
});
test('readEditorAgentSseEvents accepts fallback event name from message payload', async () => {
const onEvent = vi.fn();
await readEditorAgentSseEvents(
createSseResponse([
'data: {"event":"error","data":{"conversationId":"conversation-1","code":"INSUFFICIENT_BALANCE","message":"泥点不足","recoverable":true}}\n\n',
]),
onEvent,
);
expect(onEvent).toHaveBeenCalledWith({
event: 'error',
data: {
conversationId: 'conversation-1',
code: 'INSUFFICIENT_BALANCE',
message: '泥点不足',
recoverable: true,
},
});
});
@@ -1,70 +0,0 @@
import type { EditorAgentSseEvent } from '../../../packages/shared/src/contracts/editorAgent';
import { readSseJsonStream } from '../sseStream';
const EDITOR_AGENT_SSE_EVENT_NAMES = new Set<EditorAgentSseEvent['event']>([
'stage',
'message_delta',
'tool_started',
'tool_completed',
'generation_result',
'error',
'done',
]);
function isEditorAgentSseEventName(
value: unknown,
): value is EditorAgentSseEvent['event'] {
return (
typeof value === 'string' &&
EDITOR_AGENT_SSE_EVENT_NAMES.has(value as EditorAgentSseEvent['event'])
);
}
function readEventNameFromPayload(parsed: Record<string, unknown>) {
return isEditorAgentSseEventName(parsed.event) ? parsed.event : null;
}
function readEventDataFromPayload(parsed: Record<string, unknown>) {
const data = parsed.data;
return typeof data === 'object' && data !== null
? (data as Record<string, unknown>)
: null;
}
export function normalizeEditorAgentSseEvent(
eventName: string,
parsed: Record<string, unknown>,
): EditorAgentSseEvent | null {
const payloadEventName = readEventNameFromPayload(parsed);
if (payloadEventName) {
const payloadData = readEventDataFromPayload(parsed);
if (!payloadData) {
return null;
}
return {
event: payloadEventName,
data: payloadData,
} as unknown as EditorAgentSseEvent;
}
if (!isEditorAgentSseEventName(eventName)) {
return null;
}
return {
event: eventName,
data: parsed,
} as unknown as EditorAgentSseEvent;
}
export async function readEditorAgentSseEvents(
response: Response,
onEvent: (event: EditorAgentSseEvent) => void,
) {
await readSseJsonStream(response, ({ eventName, parsed }) => {
const event = normalizeEditorAgentSseEvent(eventName, parsed);
if (event) {
onEvent(event);
}
});
}