合并最新master分支
合入 origin/master 最新画布、外部接口与后端模块化改动。 按新队列语义解决画布测试和原生壳检查冲突。 把编辑器 Agent 迁移到模块化实现并适配 LlmRunRequest。 删除已废弃的旧编辑器 Agent 单文件与个人 Claude 本地配置。
This commit is contained in:
+1
-1
@@ -21,8 +21,8 @@ import {
|
||||
isAppHistoryState,
|
||||
normalizeAppPath,
|
||||
pushAppHistoryPath,
|
||||
replaceAppHistoryPath,
|
||||
readPublicWorkCodeFromLocationSearch,
|
||||
replaceAppHistoryPath,
|
||||
resolveInitialSelectionStageFromPath,
|
||||
resolvePathForSelectionStage,
|
||||
shouldRedirectEditorCanvasWithoutProject,
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
import type { PuzzleWorkSummary } from '../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { PuzzleRuntimeShell } from './components/puzzle-runtime/PuzzleRuntimeShell';
|
||||
import {
|
||||
applyLocalPuzzleFreezeTime,
|
||||
advanceLocalPuzzleLevel,
|
||||
applyLocalPuzzleFreezeTime,
|
||||
dragLocalPuzzlePiece,
|
||||
setLocalPuzzlePaused,
|
||||
startLocalPuzzleRun,
|
||||
|
||||
@@ -77,8 +77,8 @@ import {
|
||||
PlayerLevelProgress,
|
||||
StatusRow,
|
||||
} from './CharacterInfoShared';
|
||||
import { PlatformEmptyState } from './common/PlatformEmptyState';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
import { PlatformEmptyState } from './common/PlatformEmptyState';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
NarrativeQaReport,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformActionButton } from './common/PlatformActionButton';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
import {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeSt
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformIconBadge } from '../common/PlatformIconBadge';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
import {
|
||||
type PlatformActionButtonSize,
|
||||
type PlatformActionButtonShape,
|
||||
type PlatformActionButtonSize,
|
||||
type PlatformActionButtonSurface,
|
||||
type PlatformActionButtonTone,
|
||||
} from './platformActionButtonModel';
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
import {
|
||||
getPlatformPillBadgeClassName,
|
||||
type PlatformPillBadgeSize,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
import type { PlatformActionButtonSurface } from './platformActionButtonModel';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
HTMLAttributes,
|
||||
@@ -7,6 +6,7 @@ import type {
|
||||
ReactNode,
|
||||
Ref,
|
||||
} from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
type PlatformIconButtonBaseProps = {
|
||||
label: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from 'react';
|
||||
|
||||
type PlatformInlineOptionButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
PlatformSegmentedTabs,
|
||||
type PlatformSegmentedTabItem,
|
||||
PlatformSegmentedTabs,
|
||||
} from './PlatformSegmentedTabs';
|
||||
|
||||
type PlatformSegmentedTabPresetProps<TId extends string> = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { Waves } from 'lucide-react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Waves } from 'lucide-react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformStatusDialog } from './PlatformStatusDialog';
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
import { PlatformIconBadge } from './PlatformIconBadge';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
import type {
|
||||
PlatformActionButtonSize,
|
||||
PlatformActionButtonSurface,
|
||||
PlatformActionButtonTone,
|
||||
} from './platformActionButtonModel';
|
||||
import { PlatformIconBadge } from './PlatformIconBadge';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
export type PlatformStatusDialogStatus =
|
||||
| 'success'
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
import { PlatformStatusMessage } from './PlatformStatusMessage';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
import {
|
||||
clampNumber,
|
||||
clampSquareImageCropRect,
|
||||
getSquareCropSizeBounds,
|
||||
type SquareImageCropRect,
|
||||
} from './squareImageCropModel';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
export type SquareImageCropModalLabels = {
|
||||
title: string;
|
||||
|
||||
@@ -426,6 +426,7 @@ function normalizeCreationAgentExportFileNameSegment(rawValue: string) {
|
||||
.trim()
|
||||
.split('')
|
||||
.map((character) =>
|
||||
// eslint-disable-next-line no-control-regex -- 文件名必须拒绝 C0 控制字符。
|
||||
/[\u0000-\u001f<>:"/\\|?*]/u.test(character) ? '-' : character,
|
||||
)
|
||||
.join('')
|
||||
|
||||
@@ -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;
|
||||
+832
File diff suppressed because it is too large
Load Diff
+92
-243
@@ -1,41 +1,45 @@
|
||||
import {
|
||||
Bot,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Send,
|
||||
Square,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
type FormEvent,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
EDITOR_AGENT_MAX_ATTACHMENTS,
|
||||
type EditorAgentAttachmentRef,
|
||||
type EditorAgentGenerationResultEvent,
|
||||
type EditorAgentGenerationRecord,
|
||||
type EditorAgentMessage,
|
||||
type EditorAgentStage,
|
||||
} from '../../../packages/shared/src/contracts/editorAgent';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import { createEditorProjectResource } from '../../services/image-editor/editorProjectClient';
|
||||
import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes';
|
||||
import { probeImageFileDimensions } from './ImageCanvasFileModel';
|
||||
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
|
||||
} 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,
|
||||
@@ -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) {
|
||||
@@ -609,7 +436,9 @@ export function EditorAgentConversationPanelView({
|
||||
return [...fileItems];
|
||||
}
|
||||
|
||||
const handleInputPaste = (event: ReactClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const handleInputPaste = (
|
||||
event: ReactClipboardEvent<HTMLTextAreaElement>,
|
||||
) => {
|
||||
const imageFiles = extractClipboardImageFiles(event.clipboardData);
|
||||
if (!imageFiles.length) {
|
||||
return;
|
||||
@@ -625,14 +454,20 @@ export function EditorAgentConversationPanelView({
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingAttachmentSlots =
|
||||
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(
|
||||
imageFiles.map((file) => createPastedAgentImageAttachment(file)),
|
||||
uploadFiles.map((file) => createPastedAgentImageAttachment(file)),
|
||||
)
|
||||
.then((pastedAttachments) => {
|
||||
appendAttachments(pastedAttachments);
|
||||
if (appendAttachments(pastedAttachments) && hasOverflow) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAttachmentError('图片粘贴失败,请重试');
|
||||
@@ -681,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;
|
||||
@@ -707,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" />
|
||||
@@ -717,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)}
|
||||
>
|
||||
@@ -732,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
|
||||
@@ -754,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">
|
||||
暂无消息
|
||||
@@ -818,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,154 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { EditorAgentToolCall } from '@/packages/shared/src/contracts';
|
||||
|
||||
import ToolCallView from './ToolCallView.tsx';
|
||||
|
||||
const getExternalGenerationJobStatusMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/src/services/external-generation', () => ({
|
||||
getExternalGenerationJobStatus: getExternalGenerationJobStatusMock,
|
||||
}));
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createToolCall(
|
||||
overrides: Partial<EditorAgentToolCall> = {},
|
||||
): EditorAgentToolCall {
|
||||
return {
|
||||
toolName: 'generate-image',
|
||||
status: 'not_completed',
|
||||
args: {},
|
||||
displayArgs: {
|
||||
stringArgs: [],
|
||||
imageArgs: [],
|
||||
extras: { priceMudPoints: 1 },
|
||||
},
|
||||
externalJobId: 'job-a',
|
||||
images: [],
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createCompletedJobResponse(jobId: string) {
|
||||
return {
|
||||
job: {
|
||||
operationId: jobId,
|
||||
status: 'completed' as const,
|
||||
phaseLabel: '已完成',
|
||||
phaseDetail: '生成完成',
|
||||
progress: 100,
|
||||
error: null,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ToolCallView', () => {
|
||||
beforeEach(() => {
|
||||
getExternalGenerationJobStatusMock.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['failed', '失败', '生成失败'],
|
||||
['cancelled', '已取消', null],
|
||||
] as const)(
|
||||
'keeps the server %s state when the pending response arrives late',
|
||||
async (status, statusLabel, error) => {
|
||||
const pendingResponse = createDeferred<
|
||||
ReturnType<typeof createCompletedJobResponse>
|
||||
>();
|
||||
getExternalGenerationJobStatusMock.mockReturnValueOnce(
|
||||
pendingResponse.promise,
|
||||
);
|
||||
const onJobCompleted = vi.fn();
|
||||
const { rerender } = render(
|
||||
<ToolCallView
|
||||
toolCall={createToolCall()}
|
||||
onJobCompleted={onJobCompleted}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getExternalGenerationJobStatusMock).toHaveBeenCalledWith(
|
||||
'job-a',
|
||||
);
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ToolCallView
|
||||
toolCall={createToolCall({ status, error })}
|
||||
onJobCompleted={onJobCompleted}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText(statusLabel)).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
pendingResponse.resolve(createCompletedJobResponse('job-a'));
|
||||
await pendingResponse.promise;
|
||||
});
|
||||
|
||||
expect(screen.getByText(statusLabel)).toBeTruthy();
|
||||
expect(screen.queryByText('已完成')).toBeNull();
|
||||
expect(onJobCompleted).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('ignores job A after switching to job B and completes job B once', async () => {
|
||||
const jobAResponse = createDeferred<
|
||||
ReturnType<typeof createCompletedJobResponse>
|
||||
>();
|
||||
const jobBResponse = createDeferred<
|
||||
ReturnType<typeof createCompletedJobResponse>
|
||||
>();
|
||||
getExternalGenerationJobStatusMock.mockImplementation((jobId: string) =>
|
||||
jobId === 'job-a' ? jobAResponse.promise : jobBResponse.promise,
|
||||
);
|
||||
const onJobCompleted = vi.fn();
|
||||
const { rerender } = render(
|
||||
<ToolCallView
|
||||
toolCall={createToolCall()}
|
||||
onJobCompleted={onJobCompleted}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getExternalGenerationJobStatusMock).toHaveBeenCalledWith('job-a');
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ToolCallView
|
||||
toolCall={createToolCall({ externalJobId: 'job-b' })}
|
||||
onJobCompleted={onJobCompleted}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getExternalGenerationJobStatusMock).toHaveBeenCalledWith('job-b');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jobAResponse.resolve(createCompletedJobResponse('job-a'));
|
||||
await jobAResponse.promise;
|
||||
});
|
||||
expect(screen.getByText('执行中')).toBeTruthy();
|
||||
expect(onJobCompleted).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
jobBResponse.resolve(createCompletedJobResponse('job-b'));
|
||||
await jobBResponse.promise;
|
||||
});
|
||||
|
||||
expect(await screen.findByText('已完成')).toBeTruthy();
|
||||
expect(onJobCompleted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
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 initialDisplayStatus =
|
||||
toolCall.status === 'completed'
|
||||
? 'completed'
|
||||
: toolCall.status === 'failed'
|
||||
? 'failed'
|
||||
: toolCall.status === 'cancelled'
|
||||
? 'cancelled'
|
||||
: 'pending';
|
||||
const initialDisplayError = toolCall.error ?? null;
|
||||
const shouldPoll = toolCall.status === 'not_completed' && Boolean(jobId);
|
||||
const [displayStatus, setDisplayStatus] = useState(initialDisplayStatus);
|
||||
const [displayError, setDisplayError] =
|
||||
useState<string | null>(initialDisplayError);
|
||||
const terminalNotifiedRef = useRef(false);
|
||||
const onJobCompletedRef = useRef(onJobCompleted);
|
||||
useEffect(() => {
|
||||
onJobCompletedRef.current = onJobCompleted;
|
||||
}, [onJobCompleted]);
|
||||
useEffect(() => {
|
||||
setDisplayStatus(initialDisplayStatus);
|
||||
setDisplayError(initialDisplayError);
|
||||
terminalNotifiedRef.current = false;
|
||||
if (!shouldPoll || !jobId) return;
|
||||
let disposed = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await getExternalGenerationJobStatus(jobId);
|
||||
if (disposed) return;
|
||||
if (
|
||||
response.job.status === 'completed' ||
|
||||
response.job.status === 'failed'
|
||||
) {
|
||||
setDisplayStatus(response.job.status);
|
||||
setDisplayError(response.job.error ?? null);
|
||||
if (!terminalNotifiedRef.current) {
|
||||
terminalNotifiedRef.current = true;
|
||||
onJobCompletedRef.current?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
timeoutId = setTimeout(poll, 1500);
|
||||
} catch {
|
||||
if (!disposed) timeoutId = setTimeout(poll, 3000);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [initialDisplayError, initialDisplayStatus, jobId, shouldPoll]);
|
||||
const isCancelled = displayStatus === 'cancelled';
|
||||
const isCompleted = displayStatus === 'completed';
|
||||
const isFailed = displayStatus === 'failed';
|
||||
const isExecuting = displayStatus === 'pending' && Boolean(jobId);
|
||||
const statusLabel = isCompleted
|
||||
? '已完成'
|
||||
: isFailed
|
||||
? '失败'
|
||||
: isCancelled
|
||||
? '已取消'
|
||||
: isExecuting
|
||||
? '执行中'
|
||||
: '待确认';
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white/80 p-2 text-xs text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
{isExecuting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
) : isCompleted ? (
|
||||
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : isCancelled || isFailed ? (
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
<span>{editorAgentToolLabel(toolCall.toolName)}</span>
|
||||
<span className="ml-auto text-slate-400">{statusLabel}</span>
|
||||
</div>
|
||||
{displayError ? (
|
||||
<div className="mt-1 text-red-600">{displayError}</div>
|
||||
) : null}
|
||||
{toolCall.images.length ? (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
{toolCall.images.map((image, index) => (
|
||||
<div
|
||||
key={`${toolCall.toolName}-${image.resourceId ?? index}`}
|
||||
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
|
||||
>
|
||||
<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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user