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:
@@ -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;
|
||||
+385
-154
File diff suppressed because it is too large
Load Diff
+81
-239
@@ -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 '生成图片';
|
||||
}
|
||||
+860
File diff suppressed because it is too large
Load Diff
+209
-323
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user