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

193 lines
6.0 KiB
TypeScript

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,
type EditorAgentContextAsset,
} 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';
import { MessageBubbleRightClickMenu } from './MessageBubbleRightClickMenu.tsx';
import { useRightClickMenu } from './useRightClickMenu.ts';
function messageRoleLabel(role: EditorAgentMessage['role']) {
if (role === 'user') {
return '你';
}
return 'Agent';
}
export function ThinkingBubble({
showPatienceNotice = false,
}: {
showPatienceNotice?: boolean;
}) {
return (
<article
className="flex justify-start"
aria-label={showPatienceNotice ? 'Agent仍在处理中' : '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>
{showPatienceNotice ? (
<span className="text-slate-600">仍在处理中,请耐心等待</span>
) : null}
</div>
</div>
</article>
);
}
type MessageBubbleProps = {
message: EditorAgentMessage;
busyAction: 'confirm' | 'cancel' | null;
onConfirmToolCall: (messageId: number) => Promise<void>;
onCancelToolCall: (messageId: number) => Promise<void>;
onJobCompleted?: () => void;
// TODO prop drilling
onReferenceImage?: (asset: EditorAgentContextAsset) => boolean;
};
export function MessageBubble({
message,
busyAction,
onConfirmToolCall,
onCancelToolCall,
onJobCompleted,
onReferenceImage,
}: MessageBubbleProps) {
const {
rightClickMenu,
openRightClickMenu,
closeRightClickMenu,
runRightClickAction,
} = useRightClickMenu({ onReferenceImage });
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;
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const isSystemError = systemErrorText !== null;
const visibleText = systemErrorText ?? (!isSystem ? message.text : '');
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}
/>
);
}
return (
<>
<article
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
aria-label={
isSystemError
? 'Agent错误'
: isSystem
? 'Agent操作'
: `${messageRoleLabel(message.role)}消息`
}
onContextMenu={
visibleText.trim()
? (event) =>
openRightClickMenu(event, { kind: 'text', text: visibleText })
: undefined
}
>
<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}
onRightClickMenu={(event, asset) =>
openRightClickMenu(event, { kind: 'asset', asset })
}
/>
))}
</div>
) : null}
{message.toolCall ? (
<ToolCallView
toolCall={message.toolCall}
onJobCompleted={onJobCompleted}
onRightClickMenu={(event, asset) =>
openRightClickMenu(event, { kind: 'asset', asset })
}
/>
) : null}
</div>
</article>
{rightClickMenu ? (
<MessageBubbleRightClickMenu
x={rightClickMenu.x}
y={rightClickMenu.y}
target={rightClickMenu.target}
pendingAction={rightClickMenu.pendingAction}
resultAction={rightClickMenu.resultAction}
result={rightClickMenu.result}
onAction={(action) => void runRightClickAction(action)}
onClose={closeRightClickMenu}
/>
) : null}
</>
);
}