mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix: parse FILE markers in attachment fallback (#3795)
This commit is contained in:
@@ -256,6 +256,41 @@ describe('parseMessageImages', () => {
|
||||
expect(result.text).toBe('');
|
||||
expect(result.dataUris).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('strips FILE markers without including them in dataUris', () => {
|
||||
const result = parseMessageImages('read this [FILE:data:application/pdf;base64,xyz]');
|
||||
expect(result.text).toBe('read this');
|
||||
expect(result.dataUris).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles mixed IMAGE and FILE markers', () => {
|
||||
const result = parseMessageImages(
|
||||
'check this [IMAGE:data:image/png;base64,a] and [FILE:data:application/pdf;base64,b] thanks'
|
||||
);
|
||||
expect(result.text).toBe('check this and thanks');
|
||||
expect(result.dataUris).toEqual(['data:image/png;base64,a']);
|
||||
});
|
||||
|
||||
it('strips multiple FILE markers', () => {
|
||||
const result = parseMessageImages(
|
||||
'[FILE:data:application/pdf;base64,a] [FILE:data:text/plain;base64,b]'
|
||||
);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.dataUris).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves paragraph breaks (double newlines) in user text', () => {
|
||||
const result = parseMessageImages('first paragraph\n\nsecond paragraph');
|
||||
expect(result.text).toBe('first paragraph\n\nsecond paragraph');
|
||||
});
|
||||
|
||||
it('preserves newlines when stripping a marker', () => {
|
||||
const result = parseMessageImages(
|
||||
'first paragraph\n\n[FILE:data:application/pdf;base64,x]\n\nsecond paragraph'
|
||||
);
|
||||
expect(result.text).toBe('first paragraph\n\n\n\nsecond paragraph');
|
||||
expect(result.text).toContain('\n\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
|
||||
@@ -247,8 +247,9 @@ export function buildMessageWithAttachments(text: string, attachments: Attachmen
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `[IMAGE:<data-uri>]` markers out of a stored message string.
|
||||
* Returns the clean text (markers removed) and the list of data URIs found.
|
||||
* Parse `[IMAGE:<data-uri>]` and `[FILE:<data-uri>]` markers out of a stored message string.
|
||||
* Returns the clean text (markers removed) and the list of image data URIs found.
|
||||
* File markers are stripped from text but not returned (file data lives in extraMetadata).
|
||||
*/
|
||||
export function parseMessageImages(content: string): { text: string; dataUris: string[] } {
|
||||
const dataUris: string[] = [];
|
||||
@@ -257,6 +258,11 @@ export function parseMessageImages(content: string): { text: string; dataUris: s
|
||||
dataUris.push(uri);
|
||||
return '';
|
||||
})
|
||||
.replace(/\[FILE:([^\]]+)\]/g, '') // Strip file markers
|
||||
// Collapse only runs of plain spaces (not \s) left behind by marker
|
||||
// removal — using \s here would also eat intentional newlines/paragraph
|
||||
// breaks in the user's own text.
|
||||
.replace(/ {2,}/g, ' ')
|
||||
.trim();
|
||||
return { text, dataUris };
|
||||
}
|
||||
|
||||
@@ -1835,6 +1835,13 @@ const Conversations = ({
|
||||
)}
|
||||
{visibleMessages.map(msg => {
|
||||
const isAgentTextMode = msg.sender === 'agent' && agentMessageViewMode === 'text';
|
||||
// Parsed once per message: for current messages (extraMetadata
|
||||
// present, or agent messages) msg.content already has no markers,
|
||||
// so this is a no-op. For legacy persisted user messages with raw
|
||||
// [IMAGE:...]/[FILE:...] markers and no extraMetadata, this is
|
||||
// what keeps the marker text out of both the rendered bubble and
|
||||
// the copy-to-clipboard action.
|
||||
const parsedContent = parseMessageImages(msg.content ?? '');
|
||||
return (
|
||||
<div key={msg.id}>
|
||||
<div
|
||||
@@ -1893,9 +1900,10 @@ const Conversations = ({
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{(() => {
|
||||
const displayText = parsedContent.text;
|
||||
const dataUris = Array.isArray(msg.extraMetadata?.attachmentDataUris)
|
||||
? (msg.extraMetadata.attachmentDataUris as string[])
|
||||
: parseMessageImages(msg.content ?? '').dataUris;
|
||||
: parsedContent.dataUris;
|
||||
const hasImages = dataUris.length > 0;
|
||||
// Document attachments carry no image data-URI (only
|
||||
// images do); surface them as filename chips from the
|
||||
@@ -1953,14 +1961,14 @@ const Conversations = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(msg.content || showTime) && (
|
||||
{(displayText || showTime) && (
|
||||
<div className="rounded-2xl px-4 py-2.5 bg-primary-500 text-white rounded-br-md break-words overflow-hidden">
|
||||
{msg.content && (
|
||||
<BubbleMarkdown content={msg.content} tone="user" />
|
||||
{displayText && (
|
||||
<BubbleMarkdown content={displayText} tone="user" />
|
||||
)}
|
||||
{showTime && (
|
||||
<p
|
||||
className={`${msg.content ? 'mt-1' : ''} text-[10px] text-white/60`}>
|
||||
className={`${displayText ? 'mt-1' : ''} text-[10px] text-white/60`}>
|
||||
{formatRelativeTime(msg.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
@@ -1974,7 +1982,7 @@ const Conversations = ({
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-message-copy"
|
||||
onClick={() => handleCopyMessage(msg.id, msg.content)}
|
||||
onClick={() => handleCopyMessage(msg.id, parsedContent.text)}
|
||||
className={`absolute -top-1 ${
|
||||
isAgentTextMode
|
||||
? 'right-0'
|
||||
|
||||
@@ -637,6 +637,73 @@ describe('Conversations — attachment feature', () => {
|
||||
expect(document.body.textContent).toContain('report.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
it('strips raw IMAGE/FILE markers from a legacy message with no extraMetadata', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
|
||||
const thread = makeThread({ id: 'legacy-thread', title: 'Legacy Thread' });
|
||||
const dataUri = 'data:image/png;base64,legacy123';
|
||||
const message = {
|
||||
id: 'msg-legacy-1',
|
||||
content: `read this [IMAGE:${dataUri}] and [FILE:data:application/pdf;base64,xyz]`,
|
||||
type: 'text' as const,
|
||||
sender: 'user' as const,
|
||||
createdAt: new Date().toISOString(),
|
||||
extraMetadata: {},
|
||||
};
|
||||
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
mockGetThreadMessages.mockResolvedValue({ messages: [message], count: 1 });
|
||||
|
||||
const store = buildStore({
|
||||
thread: {
|
||||
threads: [thread],
|
||||
selectedThreadId: thread.id,
|
||||
activeThreadIds: {},
|
||||
welcomeThreadId: null,
|
||||
messagesByThreadId: { [thread.id]: [message] },
|
||||
messages: [message],
|
||||
isLoadingThreads: false,
|
||||
isLoadingMessages: false,
|
||||
messagesError: null,
|
||||
},
|
||||
socket: socketState('connected'),
|
||||
});
|
||||
|
||||
const { default: Conversations } = await import('../Conversations');
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<SidebarSlotProvider>
|
||||
<SidebarSlotOutlet />
|
||||
<Conversations />
|
||||
</SidebarSlotProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
// The image marker's data URI still renders as an <img> (parsed out for display)...
|
||||
await waitFor(() => {
|
||||
const img = document.querySelector(`img[src="${dataUri}"]`);
|
||||
expect(img).not.toBeNull();
|
||||
});
|
||||
|
||||
// ...but the raw marker syntax must never leak into the rendered bubble text.
|
||||
expect(document.body.textContent).not.toContain('[IMAGE:');
|
||||
expect(document.body.textContent).not.toContain('[FILE:');
|
||||
expect(document.body.textContent).toContain('read this');
|
||||
expect(document.body.textContent).toContain('and');
|
||||
|
||||
// Copy-to-clipboard must use the same cleaned text as the bubble, not the
|
||||
// raw msg.content with markers still embedded.
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTitle('Copy response'));
|
||||
});
|
||||
expect(writeText).toHaveBeenCalledWith('read this and');
|
||||
expect(writeText).not.toHaveBeenCalledWith(expect.stringContaining('[IMAGE:'));
|
||||
expect(writeText).not.toHaveBeenCalledWith(expect.stringContaining('[FILE:'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conversations — thread rename', () => {
|
||||
|
||||
Reference in New Issue
Block a user