合并最新master分支

合入 origin/master 最新画布、外部接口与后端模块化改动。

按新队列语义解决画布测试和原生壳检查冲突。

把编辑器 Agent 迁移到模块化实现并适配 LlmRunRequest。

删除已废弃的旧编辑器 Agent 单文件与个人 Claude 本地配置。
This commit is contained in:
AIGameCreator App
2026-07-20 16:08:24 +08:00
443 changed files with 22979 additions and 9298 deletions
+1 -1
View File
@@ -21,8 +21,8 @@ import {
isAppHistoryState,
normalizeAppPath,
pushAppHistoryPath,
replaceAppHistoryPath,
readPublicWorkCodeFromLocationSearch,
replaceAppHistoryPath,
resolveInitialSelectionStageFromPath,
resolvePathForSelectionStage,
shouldRedirectEditorCanvasWithoutProject,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 {
+33
View File
@@ -0,0 +1,33 @@
import type { AudioHTMLAttributes } from 'react';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
type ResolvedAssetAudioProps = Omit<
AudioHTMLAttributes<HTMLAudioElement>,
'src'
> & {
src?: string | null;
objectKey?: string | null;
fallbackSrc?: string | null;
refreshKey?: string | number | null;
};
export function ResolvedAssetAudio({
src,
objectKey,
fallbackSrc,
refreshKey,
...rest
}: ResolvedAssetAudioProps) {
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
objectKey,
refreshKey,
});
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
if (!finalSrc) {
return null;
}
return <audio {...rest} src={finalSrc} />;
}
@@ -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';
+2 -2
View File
@@ -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 -1
View File
@@ -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;
@@ -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