fix(desktop): allow removing images when editing user messages (#9979)
Co-authored-by: TeAmo <liu.junhui3@iwhalecloud.com> Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
@@ -275,10 +275,17 @@ describe('acpChatSessionController.updateMessage', () => {
|
||||
};
|
||||
|
||||
await expect(
|
||||
acpChatSessionController.updateMessage(SESSION_ID, existingMessage.id, 'Updated', 'edit', {
|
||||
getCurrentSnapshot: () => currentSnapshot,
|
||||
onFinish: vi.fn(),
|
||||
})
|
||||
acpChatSessionController.updateMessage(
|
||||
SESSION_ID,
|
||||
existingMessage.id,
|
||||
'Updated',
|
||||
'edit',
|
||||
[],
|
||||
{
|
||||
getCurrentSnapshot: () => currentSnapshot,
|
||||
onFinish: vi.fn(),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Cannot submit while prompt cancellation is pending');
|
||||
|
||||
expect(acpChatSessionActions.setChatState).not.toHaveBeenCalledWith(
|
||||
@@ -301,10 +308,17 @@ describe('acpChatSessionController.updateMessage', () => {
|
||||
};
|
||||
|
||||
await expect(
|
||||
acpChatSessionController.updateMessage(SESSION_ID, existingMessage.id, 'Updated', 'edit', {
|
||||
getCurrentSnapshot: () => currentSnapshot,
|
||||
onFinish: vi.fn(),
|
||||
})
|
||||
acpChatSessionController.updateMessage(
|
||||
SESSION_ID,
|
||||
existingMessage.id,
|
||||
'Updated',
|
||||
'edit',
|
||||
[],
|
||||
{
|
||||
getCurrentSnapshot: () => currentSnapshot,
|
||||
onFinish: vi.fn(),
|
||||
}
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(acpChatSessionActions.setChatState).not.toHaveBeenCalledWith(
|
||||
@@ -346,6 +360,7 @@ describe('acpChatSessionController.updateMessage', () => {
|
||||
existingMessage.id,
|
||||
'Updated',
|
||||
'edit',
|
||||
[],
|
||||
{
|
||||
getCurrentSnapshot: () => activeSnapshot,
|
||||
onFinish: vi.fn(),
|
||||
|
||||
@@ -10,14 +10,26 @@ describe('messageToAcpPromptContent', () => {
|
||||
created: 123,
|
||||
content: [
|
||||
{ type: 'text', text: 'Describe this' },
|
||||
{ type: 'image', data: 'abc123', mimeType: 'image/png' },
|
||||
{
|
||||
type: 'image',
|
||||
data: 'abc123',
|
||||
mimeType: 'image/png',
|
||||
_meta: { source: 'acp' },
|
||||
annotations: { priority: 0.5 },
|
||||
},
|
||||
],
|
||||
metadata: { userVisible: true, agentVisible: true },
|
||||
};
|
||||
|
||||
expect(messageToAcpPromptContent(message)).toEqual([
|
||||
{ type: 'text', text: 'Describe this' },
|
||||
{ type: 'image', data: 'abc123', mimeType: 'image/png' },
|
||||
{
|
||||
type: 'image',
|
||||
data: 'abc123',
|
||||
mimeType: 'image/png',
|
||||
_meta: { source: 'acp' },
|
||||
annotations: { priority: 0.5 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ import { ChatState } from '../types/chatState';
|
||||
import type { Session } from '../types/session';
|
||||
import { errorMessage } from '../utils/conversionUtils';
|
||||
import { showExtensionLoadResults } from '../utils/extensionErrorUtils';
|
||||
import { createUserMessage, getPendingToolConfirmationIds, type Message } from '../types/message';
|
||||
import {
|
||||
createUserMessage,
|
||||
getPendingToolConfirmationIds,
|
||||
type ImageData,
|
||||
type Message,
|
||||
} from '../types/message';
|
||||
import {
|
||||
acpChatSessionActions,
|
||||
acpChatSessionStore,
|
||||
@@ -55,7 +60,8 @@ export interface AcpChatSessionController {
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
newContent: string,
|
||||
editType: 'fork' | 'edit' | undefined,
|
||||
editType: 'fork' | 'edit',
|
||||
retainedImages: ImageData[],
|
||||
options: AcpSubmitMessageOptions
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -87,7 +93,8 @@ function assertNoPendingPromptCancellation(sessionId: string): void {
|
||||
async function forkSessionWithEditedMessage(
|
||||
sessionId: string,
|
||||
message: Message,
|
||||
editedMessage: string
|
||||
editedMessage: string,
|
||||
editedImages: ImageData[]
|
||||
): Promise<void> {
|
||||
const targetSessionId = await acpForkSession(sessionId, message.created);
|
||||
|
||||
@@ -96,6 +103,7 @@ async function forkSessionWithEditedMessage(
|
||||
newSessionId: targetSessionId,
|
||||
shouldStartAgent: true,
|
||||
editedMessage,
|
||||
editedImages,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
@@ -233,12 +241,12 @@ async function updateMessage(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
newContent: string,
|
||||
editType: 'fork' | 'edit' | undefined,
|
||||
editType: 'fork' | 'edit',
|
||||
retainedImages: ImageData[],
|
||||
options: AcpSubmitMessageOptions
|
||||
): Promise<void> {
|
||||
assertNoPendingPromptCancellation(sessionId);
|
||||
|
||||
const resolvedEditType = editType ?? 'fork';
|
||||
const currentSnapshot = options.getCurrentSnapshot();
|
||||
const storedSnapshot = acpChatSessionStore.getSnapshot(sessionId);
|
||||
const activePromptAttemptId = storedSnapshot?.activePromptAttemptId;
|
||||
@@ -249,8 +257,8 @@ async function updateMessage(
|
||||
throw new Error(`Message with id ${messageId} not found in current messages`);
|
||||
}
|
||||
|
||||
if (resolvedEditType === 'fork') {
|
||||
await forkSessionWithEditedMessage(sessionId, message, newContent);
|
||||
if (editType === 'fork') {
|
||||
await forkSessionWithEditedMessage(sessionId, message, newContent, retainedImages);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -303,13 +311,7 @@ async function updateMessage(
|
||||
await acpTruncateSessionConversation(sessionId, message.created);
|
||||
|
||||
const truncatedMessages = currentMessages.filter((m) => m.created < message.created);
|
||||
const updatedUserMessage = createUserMessage(newContent);
|
||||
|
||||
for (const content of message.content) {
|
||||
if (content.type === 'image') {
|
||||
updatedUserMessage.content.push(content);
|
||||
}
|
||||
}
|
||||
const updatedUserMessage = createUserMessage(newContent, retainedImages);
|
||||
|
||||
const messagesForUI = [...truncatedMessages, updatedUserMessage];
|
||||
acpChatSessionActions.setMessages(sessionId, messagesForUI);
|
||||
|
||||
@@ -50,6 +50,8 @@ export function messageToAcpPromptContent(message: Message): ContentBlock[] {
|
||||
type: 'image',
|
||||
data: content.data,
|
||||
mimeType: content.mimeType,
|
||||
...(content._meta ? { _meta: content._meta } : {}),
|
||||
...(content.annotations ? { annotations: content.annotations } : {}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,12 @@ import { RecipeWarningModal } from './ui/RecipeWarningModal';
|
||||
import { scanRecipe } from '../recipe';
|
||||
import type { Recipe } from '../recipe';
|
||||
import RecipeActivities from './recipes/RecipeActivities';
|
||||
import { getTextAndImageContent, type Message, type UserInput } from '../types/message';
|
||||
import {
|
||||
getTextAndImageContent,
|
||||
type ImageData,
|
||||
type Message,
|
||||
type UserInput,
|
||||
} from '../types/message';
|
||||
import { substituteParameters } from '../utils/parameterSubstitution';
|
||||
import { useAutoSubmit } from '../hooks/useAutoSubmit';
|
||||
import { Goose } from './icons';
|
||||
@@ -304,11 +309,12 @@ export default function BaseChat({
|
||||
const handleSessionForked = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{
|
||||
newSessionId: string;
|
||||
shouldStartAgent?: boolean;
|
||||
editedMessage?: string;
|
||||
shouldStartAgent: boolean;
|
||||
editedMessage: string;
|
||||
editedImages: ImageData[];
|
||||
}>;
|
||||
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
|
||||
const { newSessionId, shouldStartAgent, editedMessage } = customEvent.detail;
|
||||
const { newSessionId, shouldStartAgent, editedMessage, editedImages } = customEvent.detail;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('resumeSessionId', newSessionId);
|
||||
@@ -319,7 +325,7 @@ export default function BaseChat({
|
||||
navigate(`/pair?${params.toString()}`, {
|
||||
state: {
|
||||
disableAnimation: true,
|
||||
initialMessage: editedMessage ? { msg: editedMessage, images: [] } : undefined,
|
||||
initialMessage: { msg: editedMessage, images: editedImages },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -26,7 +26,12 @@ import {
|
||||
CreditsExhaustedNotification,
|
||||
getCreditsExhaustedNotification,
|
||||
} from './context_management/CreditsExhaustedNotification';
|
||||
import type { Message, NotificationEvent, SystemNotificationContent } from '../types/message';
|
||||
import type {
|
||||
ImageData,
|
||||
Message,
|
||||
NotificationEvent,
|
||||
SystemNotificationContent,
|
||||
} from '../types/message';
|
||||
import LoadingGoose from './LoadingGoose';
|
||||
import { ChatType } from '../types/chat';
|
||||
import { identifyConsecutiveToolCalls, isInChain } from '../utils/toolCallChaining';
|
||||
@@ -59,7 +64,12 @@ interface ProgressiveMessageListProps {
|
||||
// Custom render function for messages
|
||||
renderMessage?: (message: Message, index: number) => React.ReactNode | null;
|
||||
isStreamingMessage?: boolean; // Whether messages are currently being streamed
|
||||
onMessageUpdate?: (messageId: string, newContent: string, editType?: 'fork' | 'edit') => void;
|
||||
onMessageUpdate?: (
|
||||
messageId: string,
|
||||
newContent: string,
|
||||
editType: 'fork' | 'edit',
|
||||
retainedImages: ImageData[]
|
||||
) => void;
|
||||
onRenderingComplete?: () => void; // Callback when all messages are rendered
|
||||
submitElicitationResponse?: (
|
||||
elicitationId: string,
|
||||
@@ -270,15 +280,17 @@ export default function ProgressiveMessageList({
|
||||
const previousResolvedModel = currentResolvedModel ? getPreviousResolvedModel(index) : null;
|
||||
const showModelChangeDisclosure = Boolean(
|
||||
currentResolvedModel &&
|
||||
previousResolvedModel &&
|
||||
currentResolvedModel !== previousResolvedModel
|
||||
previousResolvedModel &&
|
||||
currentResolvedModel !== previousResolvedModel
|
||||
);
|
||||
|
||||
const messageKey = message.id ?? `msg-${index}-${message.created}`;
|
||||
|
||||
return (
|
||||
<Fragment key={messageKey}>
|
||||
{showModelChangeDisclosure && currentResolvedModel && previousResolvedModel &&
|
||||
{showModelChangeDisclosure &&
|
||||
currentResolvedModel &&
|
||||
previousResolvedModel &&
|
||||
renderModelChangeDisclosure(previousResolvedModel, currentResolvedModel)}
|
||||
<div
|
||||
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} ${isUser ? 'user' : 'assistant'} ${messageIsInChain ? 'in-chain' : ''}`}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import ImagePreview from './ImagePreview';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import { getTextAndImageContent, type Message } from '../types/message';
|
||||
import {
|
||||
getTextAndImageContent,
|
||||
imageDataFromMessage,
|
||||
type ImageData,
|
||||
type Message,
|
||||
} from '../types/message';
|
||||
import MessageCopyLink from './MessageCopyLink';
|
||||
import { formatMessageTimestamp } from '../utils/timeUtils';
|
||||
import Close from './icons/Close';
|
||||
import Edit from './icons/Edit';
|
||||
import { Button } from './ui/button';
|
||||
import { defineMessages, useIntl } from '../i18n';
|
||||
@@ -23,7 +29,8 @@ const i18n = defineMessages({
|
||||
},
|
||||
editInPlaceDescription: {
|
||||
id: 'userMessage.editInPlaceDescription',
|
||||
defaultMessage: '<b>Edit in Place</b> updates this session • <b>Fork Session</b> creates a new session',
|
||||
defaultMessage:
|
||||
'<b>Edit in Place</b> updates this session • <b>Fork Session</b> creates a new session',
|
||||
},
|
||||
cancel: {
|
||||
id: 'userMessage.cancel',
|
||||
@@ -69,11 +76,24 @@ const i18n = defineMessages({
|
||||
id: 'userMessage.editMessageTitle',
|
||||
defaultMessage: 'Edit message',
|
||||
},
|
||||
removeImageFromEdit: {
|
||||
id: 'userMessage.removeImageFromEdit',
|
||||
defaultMessage: 'Remove image from message',
|
||||
},
|
||||
editImagesHeading: {
|
||||
id: 'userMessage.editImagesHeading',
|
||||
defaultMessage: 'Attached images:',
|
||||
},
|
||||
});
|
||||
|
||||
interface UserMessageProps {
|
||||
message: Message;
|
||||
onMessageUpdate?: (messageId: string, newContent: string, editType?: 'fork' | 'edit') => void;
|
||||
onMessageUpdate?: (
|
||||
messageId: string,
|
||||
newContent: string,
|
||||
editType: 'fork' | 'edit',
|
||||
retainedImages: ImageData[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function UserMessage({ message, onMessageUpdate }: UserMessageProps) {
|
||||
@@ -87,32 +107,39 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
const { textContent, imagePaths } = getTextAndImageContent(message);
|
||||
const timestamp = formatMessageTimestamp(message.created);
|
||||
|
||||
// Effect to handle message content changes and ensure persistence
|
||||
const messageImages: ImageData[] = imageDataFromMessage(message);
|
||||
|
||||
const [removedImageIndices, setRemovedImageIndices] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// If we're not editing, update the edit content to match the current message
|
||||
if (!isEditing) {
|
||||
setEditContent(textContent);
|
||||
}
|
||||
}, [message.content, textContent, message.id, isEditing]);
|
||||
|
||||
// Initialize edit mode with current message content
|
||||
const initializeEditMode = useCallback(() => {
|
||||
setEditContent(textContent);
|
||||
setError(null);
|
||||
setRemovedImageIndices(new Set());
|
||||
window.electron.logInfo(`Entering edit mode with content: ${textContent}`);
|
||||
}, [textContent]);
|
||||
|
||||
// Handle edit button click
|
||||
const handleRemoveImage = useCallback((index: number) => {
|
||||
setRemovedImageIndices((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(index);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleEditClick = useCallback(() => {
|
||||
const newEditingState = !isEditing;
|
||||
setIsEditing(newEditingState);
|
||||
|
||||
// Initialize edit content when entering edit mode
|
||||
if (newEditingState) {
|
||||
initializeEditMode();
|
||||
window.electron.logInfo(`Edit interface shown for message: ${message.id}`);
|
||||
|
||||
// Focus the textarea after a brief delay to ensure it's rendered
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
@@ -127,43 +154,54 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
window.electron.logInfo(`Edit state toggled: ${newEditingState} for message: ${message.id}`);
|
||||
}, [isEditing, initializeEditMode, message.id]);
|
||||
|
||||
// Handle content changes in edit mode
|
||||
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newContent = e.target.value;
|
||||
setEditContent(newContent);
|
||||
setError(null); // Clear any previous errors
|
||||
setError(null);
|
||||
window.electron.logInfo(`Content changed: ${newContent}`);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(editType: 'fork' | 'edit' = 'fork') => {
|
||||
if (editContent.trim().length === 0) {
|
||||
(editType: 'fork' | 'edit') => {
|
||||
const retainedImages = messageImages.filter((_, index) => !removedImageIndices.has(index));
|
||||
|
||||
if (editContent.trim().length === 0 && retainedImages.length === 0) {
|
||||
setError(intl.formatMessage(i18n.emptyError));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
|
||||
if (editType === 'edit' && editContent.trim() === textContent.trim()) {
|
||||
if (
|
||||
editType === 'edit' &&
|
||||
editContent.trim() === textContent.trim() &&
|
||||
retainedImages.length === messageImages.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onMessageUpdate && message.id) {
|
||||
onMessageUpdate(message.id, editContent, editType);
|
||||
onMessageUpdate(message.id, editContent, editType, retainedImages);
|
||||
}
|
||||
},
|
||||
[editContent, textContent, onMessageUpdate, message.id, intl]
|
||||
[
|
||||
editContent,
|
||||
textContent,
|
||||
onMessageUpdate,
|
||||
message.id,
|
||||
intl,
|
||||
messageImages,
|
||||
removedImageIndices,
|
||||
]
|
||||
);
|
||||
|
||||
// Handle cancel action
|
||||
const handleCancel = useCallback(() => {
|
||||
window.electron.logInfo('Cancel clicked - reverting to original content');
|
||||
setIsEditing(false);
|
||||
setEditContent(textContent); // Reset to original content
|
||||
setEditContent(textContent);
|
||||
setError(null);
|
||||
}, [textContent]);
|
||||
|
||||
// Handle keyboard events for accessibility
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
window.electron.logInfo(
|
||||
@@ -176,13 +214,12 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
window.electron.logInfo('Cmd+Enter detected, calling handleSave');
|
||||
handleSave();
|
||||
handleSave('fork');
|
||||
}
|
||||
},
|
||||
[handleCancel, handleSave]
|
||||
);
|
||||
|
||||
// Auto-resize textarea based on content
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && isEditing) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
@@ -194,7 +231,6 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
<div className="w-full mt-[16px] opacity-0 animate-[appear_150ms_ease-in_forwards]">
|
||||
<div className="flex flex-col group">
|
||||
{isEditing ? (
|
||||
// Truly wide, centered, in-place edit box replacing the bubble
|
||||
<div className="w-full max-w-4xl mx-auto text-text-primary rounded-xl border border-border-primary shadow-lg py-4 px-4 my-2 transition-all duration-200 ease-in-out">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -215,7 +251,31 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
aria-label={intl.formatMessage(i18n.editAriaLabel)}
|
||||
aria-describedby={error ? `error-${message.id}` : undefined}
|
||||
/>
|
||||
{/* Error message */}
|
||||
{messageImages.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-text-secondary mb-2">
|
||||
{intl.formatMessage(i18n.editImagesHeading)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{messageImages.map((img, index) => {
|
||||
if (removedImageIndices.has(index)) return null;
|
||||
const dataUrl = `data:${img.mimeType};base64,${img.data}`;
|
||||
return (
|
||||
<div key={index} className="relative group/image">
|
||||
<ImagePreview src={dataUrl} />
|
||||
<button
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
className="absolute -top-1.5 -right-1.5 bg-text-primary text-background-primary rounded-full p-0.5 transition-opacity hover:cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
|
||||
aria-label={intl.formatMessage(i18n.removeImageFromEdit)}
|
||||
>
|
||||
<Close className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
id={`error-${message.id}`}
|
||||
@@ -233,7 +293,11 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleCancel} variant="ghost" aria-label={intl.formatMessage(i18n.cancelAriaLabel)}>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="ghost"
|
||||
aria-label={intl.formatMessage(i18n.cancelAriaLabel)}
|
||||
>
|
||||
{intl.formatMessage(i18n.cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -255,7 +319,6 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Normal message display
|
||||
<div className="message flex justify-end w-full">
|
||||
<div className="flex-col max-w-[85%] w-fit">
|
||||
<div className="flex flex-col group">
|
||||
@@ -292,7 +355,9 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-text-secondary hover:cursor-pointer hover:text-text-primary transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-50 rounded"
|
||||
aria-label={intl.formatMessage(i18n.editMessageAriaLabel, { preview: `${textContent.substring(0, 50)}${textContent.length > 50 ? '...' : ''}` })}
|
||||
aria-label={intl.formatMessage(i18n.editMessageAriaLabel, {
|
||||
preview: `${textContent.substring(0, 50)}${textContent.length > 50 ? '...' : ''}`,
|
||||
})}
|
||||
aria-expanded={isEditing}
|
||||
title={intl.formatMessage(i18n.editMessageTitle)}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Session } from '../types/session';
|
||||
|
||||
import {
|
||||
createUserMessage,
|
||||
type ImageData,
|
||||
type Message,
|
||||
type NotificationEvent,
|
||||
type UserInput,
|
||||
@@ -280,12 +281,24 @@ export function useChatSession({
|
||||
}, [sessionId]);
|
||||
|
||||
const onMessageUpdate = useCallback(
|
||||
async (messageId: string, newContent: string, editType: 'fork' | 'edit' = 'fork') => {
|
||||
async (
|
||||
messageId: string,
|
||||
newContent: string,
|
||||
editType: 'fork' | 'edit',
|
||||
retainedImages: ImageData[]
|
||||
) => {
|
||||
try {
|
||||
await acpChatSessionController.updateMessage(sessionId, messageId, newContent, editType, {
|
||||
getCurrentSnapshot,
|
||||
onFinish,
|
||||
});
|
||||
await acpChatSessionController.updateMessage(
|
||||
sessionId,
|
||||
messageId,
|
||||
newContent,
|
||||
editType,
|
||||
retainedImages,
|
||||
{
|
||||
getCurrentSnapshot,
|
||||
onFinish,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMsg = errorMessage(error);
|
||||
console.error('Failed to edit message:', error);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChatState } from '../types/chatState';
|
||||
import type { TokenState } from '../types/chat';
|
||||
import type { Message, NotificationEvent, UserInput } from '../types/message';
|
||||
import type { ImageData, Message, NotificationEvent, UserInput } from '../types/message';
|
||||
import type { Session } from '../types/session';
|
||||
|
||||
export interface UseChatSessionParams {
|
||||
@@ -30,6 +30,7 @@ export interface UseChatSessionResult {
|
||||
onMessageUpdate: (
|
||||
messageId: string,
|
||||
newContent: string,
|
||||
editType?: 'fork' | 'edit'
|
||||
editType: 'fork' | 'edit',
|
||||
retainedImages: ImageData[]
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "Bearbeiten"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "Angehängte Bilder:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "An Ort und Stelle bearbeiten"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "Eine neue Sitzung mit der bearbeiteten Nachricht erstellen"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Bild aus Nachricht entfernen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "Attached images:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "Edit in Place"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "Create a new session with the edited message"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Remove image from message"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "Editar"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "Imágenes adjuntas:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "Editar en el sitio"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "Crear una nueva sesión con el mensaje editado"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Eliminar imagen del mensaje"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "Modifier"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "Images jointes :"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "Modifier sur place"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "Créer une nouvelle session avec le message modifié"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Supprimer l'image du message"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "संपादित करें"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "संलग्न छवियाँ:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "यथास्थान संपादित करें"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "संपादित संदेश के साथ एक नया सत्र बनाएं"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "संदेश से छवि हटाएँ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "Gambar terlampir:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "Edit di Tempat"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "Buat sesi baru dengan pesan yang diedit"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Hapus gambar dari pesan"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "Modifica"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "Immagini allegate:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "Modifica sul posto"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "Crea una nuova sessione con il messaggio modificato"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Rimuovi immagine dal messaggio"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "編集"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "添付画像:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "その場で編集"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "編集したメッセージで新しいセッションを作成"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "メッセージから画像を削除"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "편집"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "첨부된 이미지:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "바로 편집"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "편집한 메시지로 새 세션 만들기"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "메시지에서 이미지 제거"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "Sunting"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "Imej dilampirkan:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "Sunting di Tempat"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "Cipta sesi baharu dengan mesej yang disunting"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Alih keluar imej daripada mesej"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "Editar"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "Imagens anexadas:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "Editar no Local"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "Criar uma nova sessão com a mensagem editada"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Remover imagem da mensagem"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "Изменить"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "Прикрепленные изображения:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "Изменить на месте"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "Создать новый сеанс с измененным сообщением"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Удалить изображение из сообщения"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "Düzenle"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "Ekli görseller:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "Yerinde Düzenle"
|
||||
},
|
||||
@@ -4545,7 +4548,7 @@
|
||||
"defaultMessage": "Mesajı yerinde düzenleyin"
|
||||
},
|
||||
"userMessage.editInPlaceDescription": {
|
||||
"defaultMessage": "<b>Edit in Place</b> bu oturumu günceller • <b>Fork Session</b> yeni bir oturum oluşturur"
|
||||
"defaultMessage": "<b>Yerinde Düzenle</b> bu oturumu günceller • <b>Oturumu Çatalla</b> yeni bir oturum oluşturur"
|
||||
},
|
||||
"userMessage.editInPlaceTitle": {
|
||||
"defaultMessage": "Bu oturumdaki mesajı güncelleyin"
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "Düzenlenen mesajla yeni bir oturum oluşturun"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Görseli mesajdan kaldır"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "Chỉnh sửa"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "Hình ảnh đính kèm:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "Chỉnh sửa tại chỗ"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "Tạo một phiên làm việc mới với tin nhắn đã chỉnh sửa"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Xóa hình ảnh khỏi tin nhắn"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "编辑"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "已附加的图片:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "原地编辑"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "用编辑过的消息创建新会话"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "从消息中移除图片"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4538,6 +4538,9 @@
|
||||
"userMessage.editButton": {
|
||||
"defaultMessage": "編輯"
|
||||
},
|
||||
"userMessage.editImagesHeading": {
|
||||
"defaultMessage": "附加圖片:"
|
||||
},
|
||||
"userMessage.editInPlace": {
|
||||
"defaultMessage": "就地編輯"
|
||||
},
|
||||
@@ -4570,5 +4573,8 @@
|
||||
},
|
||||
"userMessage.forkSessionTitle": {
|
||||
"defaultMessage": "以編輯後的訊息建立新的工作階段"
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "從訊息中移除圖片"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,9 +269,24 @@ export type LiveOutputNotificationChunk = {
|
||||
output: string;
|
||||
};
|
||||
|
||||
export type ImageMessageContent = Extract<Message['content'][number], { type: 'image' }>;
|
||||
|
||||
export interface ImageData {
|
||||
data: string; // base64 encoded image data
|
||||
data: string;
|
||||
mimeType: string;
|
||||
_meta?: ImageMessageContent['_meta'];
|
||||
annotations?: ImageMessageContent['annotations'];
|
||||
}
|
||||
|
||||
export function imageDataFromMessage(message: Message): ImageData[] {
|
||||
return message.content
|
||||
.filter((c): c is ImageMessageContent => c.type === 'image')
|
||||
.map((c) => ({
|
||||
data: c.data,
|
||||
mimeType: c.mimeType,
|
||||
...(c._meta ? { _meta: c._meta } : {}),
|
||||
...(c.annotations ? { annotations: c.annotations } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface UserInput {
|
||||
@@ -292,6 +307,8 @@ export function createUserMessage(text: string, images?: ImageData[]): Message {
|
||||
type: 'image',
|
||||
data: img.data,
|
||||
mimeType: img.mimeType,
|
||||
...(img._meta ? { _meta: img._meta } : {}),
|
||||
...(img.annotations ? { annotations: img.annotations } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user