mirror of
https://github.com/geoffsee/open-gsio.git
synced 2025-09-08 22:56:46 +00:00
saves a message
This commit is contained in:

committed by
Geoff Seemueller

parent
810846bb3c
commit
9e6ef975a9
@@ -1,11 +1,10 @@
|
||||
import React, { KeyboardEvent, useState } from "react";
|
||||
import React, { KeyboardEvent, useEffect } from "react";
|
||||
import { Box, Flex, IconButton, Textarea } from "@chakra-ui/react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Instance } from "mobx-state-tree";
|
||||
import Message from "../../../models/Message";
|
||||
import clientChatStore from "../../../stores/ClientChatStore";
|
||||
import UserOptionsStore from "../../../stores/UserOptionsStore";
|
||||
import messageEditorStore from "../../../stores/MessageEditorStore";
|
||||
|
||||
interface MessageEditorProps {
|
||||
message: Instance<typeof Message>;
|
||||
@@ -13,113 +12,38 @@ interface MessageEditorProps {
|
||||
}
|
||||
|
||||
const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
|
||||
const [editedContent, setEditedContent] = useState(message.content);
|
||||
useEffect(() => {
|
||||
messageEditorStore.setMessage(message);
|
||||
|
||||
const handleSave = async () => {
|
||||
message.setContent(editedContent);
|
||||
|
||||
// Find the index of the edited message
|
||||
const messageIndex = clientChatStore.items.indexOf(message);
|
||||
if (messageIndex !== -1) {
|
||||
// Remove all messages after the edited message
|
||||
clientChatStore.removeAfter(messageIndex);
|
||||
|
||||
// Set follow mode and loading state
|
||||
UserOptionsStore.setFollowModeEnabled(true);
|
||||
clientChatStore.setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Add a small delay before adding the assistant message (for better UX)
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Add an empty assistant message
|
||||
clientChatStore.add(Message.create({ content: "", role: "assistant" }));
|
||||
const payload = { messages: clientChatStore.items.slice(), model: clientChatStore.model };
|
||||
|
||||
// Make API call
|
||||
const response = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
clientChatStore.updateLast("Too many requests • please slow down.");
|
||||
clientChatStore.setIsLoading(false);
|
||||
UserOptionsStore.setFollowModeEnabled(false);
|
||||
return;
|
||||
}
|
||||
if (response.status > 200) {
|
||||
clientChatStore.updateLast("Error • something went wrong.");
|
||||
clientChatStore.setIsLoading(false);
|
||||
UserOptionsStore.setFollowModeEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { streamUrl } = await response.json();
|
||||
const eventSource = new EventSource(streamUrl);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
if (parsed.type === "error") {
|
||||
clientChatStore.updateLast(parsed.error);
|
||||
clientChatStore.setIsLoading(false);
|
||||
UserOptionsStore.setFollowModeEnabled(false);
|
||||
eventSource.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "chat" && parsed.data.choices[0]?.finish_reason === "stop") {
|
||||
clientChatStore.appendLast(parsed.data.choices[0]?.delta?.content ?? "");
|
||||
clientChatStore.setIsLoading(false);
|
||||
UserOptionsStore.setFollowModeEnabled(false);
|
||||
eventSource.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "chat") {
|
||||
clientChatStore.appendLast(parsed.data.choices[0]?.delta?.content ?? "");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("stream parse error", err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
clientChatStore.updateLast("Error • connection lost.");
|
||||
clientChatStore.setIsLoading(false);
|
||||
UserOptionsStore.setFollowModeEnabled(false);
|
||||
eventSource.close();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("sendMessage", err);
|
||||
clientChatStore.updateLast("Sorry • network error.");
|
||||
clientChatStore.setIsLoading(false);
|
||||
UserOptionsStore.setFollowModeEnabled(false);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
messageEditorStore.onCancel();
|
||||
};
|
||||
}, [message]);
|
||||
|
||||
const handleCancel = () => {
|
||||
messageEditorStore.onCancel();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
messageEditorStore.handleSave();
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box width="100%">
|
||||
<Textarea
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
value={messageEditorStore.editedContent}
|
||||
onChange={(e) => messageEditorStore.setEditedContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
minHeight="100px"
|
||||
bg="transparent"
|
||||
@@ -134,7 +58,7 @@ const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
|
||||
<IconButton
|
||||
aria-label="Cancel edit"
|
||||
icon={<X />}
|
||||
onClick={onCancel}
|
||||
onClick={handleCancel}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={"accent.danger"}
|
||||
@@ -142,7 +66,7 @@ const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
|
||||
<IconButton
|
||||
aria-label="Save edit"
|
||||
icon={<Check />}
|
||||
onClick={handleSave}
|
||||
onClick={() => messageEditorStore.handleSave()}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={"accent.confirm"}
|
||||
|
@@ -3,8 +3,9 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import MessageEditor from '../MessageEditorComponent';
|
||||
|
||||
// Import the mocked clientChatStore
|
||||
// Import the mocked stores
|
||||
import clientChatStore from '../../../../stores/ClientChatStore';
|
||||
import messageEditorStore from '../../../../stores/MessageEditorStore';
|
||||
|
||||
// Mock the Message model
|
||||
vi.mock('../../../../models/Message', () => {
|
||||
@@ -15,13 +16,22 @@ vi.mock('../../../../models/Message', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fetch globally
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({})
|
||||
})
|
||||
);
|
||||
|
||||
// Mock the ClientChatStore
|
||||
vi.mock('../../../../stores/ClientChatStore', () => {
|
||||
const mockStore = {
|
||||
items: [],
|
||||
removeAfter: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
setIsLoading: vi.fn()
|
||||
setIsLoading: vi.fn(),
|
||||
editMessage: vi.fn().mockReturnValue(true)
|
||||
};
|
||||
|
||||
// Add the mockUserMessage to the items array
|
||||
@@ -32,6 +42,25 @@ vi.mock('../../../../stores/ClientChatStore', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the MessageEditorStore
|
||||
vi.mock('../../../../stores/MessageEditorStore', () => {
|
||||
const mockStore = {
|
||||
editedContent: 'Test message', // Set initial value to match the test expectation
|
||||
message: null,
|
||||
setEditedContent: vi.fn(),
|
||||
setMessage: vi.fn((message) => {
|
||||
mockStore.message = message;
|
||||
mockStore.editedContent = message.content;
|
||||
}),
|
||||
onCancel: vi.fn(),
|
||||
handleSave: vi.fn()
|
||||
};
|
||||
|
||||
return {
|
||||
default: mockStore
|
||||
};
|
||||
});
|
||||
|
||||
describe('MessageEditor', () => {
|
||||
// Create a message object with a setContent method
|
||||
const mockUserMessage = {
|
||||
@@ -51,6 +80,7 @@ describe('MessageEditor', () => {
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
expect(textarea).toHaveValue('Test message');
|
||||
expect(messageEditorStore.setMessage).toHaveBeenCalledWith(mockUserMessage);
|
||||
});
|
||||
|
||||
it('should update the content when typing', () => {
|
||||
@@ -59,22 +89,16 @@ describe('MessageEditor', () => {
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'Updated message' } });
|
||||
|
||||
expect(textarea).toHaveValue('Updated message');
|
||||
expect(messageEditorStore.setEditedContent).toHaveBeenCalledWith('Updated message');
|
||||
});
|
||||
|
||||
it('should call setContent, removeAfter, sendMessage, and onCancel when save button is clicked', () => {
|
||||
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'Updated message' } });
|
||||
it('should call handleSave when save button is clicked', () => {
|
||||
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel}/>);
|
||||
|
||||
const saveButton = screen.getByLabelText('Save edit');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
expect(mockUserMessage.setContent).toHaveBeenCalledWith('Updated message');
|
||||
expect(clientChatStore.removeAfter).toHaveBeenCalledWith(0);
|
||||
expect(clientChatStore.sendMessage).toHaveBeenCalled();
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
expect(messageEditorStore.handleSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
@@ -83,43 +107,35 @@ describe('MessageEditor', () => {
|
||||
const cancelButton = screen.getByLabelText('Cancel edit');
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(messageEditorStore.onCancel).toHaveBeenCalled();
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
expect(mockUserMessage.setContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save when Ctrl+Enter is pressed', () => {
|
||||
it('should call handleSave when Ctrl+Enter is pressed', () => {
|
||||
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'Updated message' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true });
|
||||
|
||||
expect(mockUserMessage.setContent).toHaveBeenCalledWith('Updated message');
|
||||
expect(clientChatStore.removeAfter).toHaveBeenCalledWith(0);
|
||||
expect(clientChatStore.sendMessage).toHaveBeenCalled();
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
expect(messageEditorStore.handleSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save when Meta+Enter is pressed', () => {
|
||||
it('should call handleSave when Meta+Enter is pressed', () => {
|
||||
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'Updated message' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true });
|
||||
|
||||
expect(mockUserMessage.setContent).toHaveBeenCalledWith('Updated message');
|
||||
expect(clientChatStore.removeAfter).toHaveBeenCalledWith(0);
|
||||
expect(clientChatStore.sendMessage).toHaveBeenCalled();
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
expect(messageEditorStore.handleSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should cancel when Escape is pressed', () => {
|
||||
it('should call onCancel when Escape is pressed', () => {
|
||||
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.keyDown(textarea, { key: 'Escape' });
|
||||
|
||||
expect(messageEditorStore.onCancel).toHaveBeenCalled();
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
expect(mockUserMessage.setContent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@@ -238,7 +238,7 @@ const CustomTable: React.FC<{
|
||||
};
|
||||
|
||||
const CustomHtmlBlock: React.FC<{ content: string }> = ({content}) => {
|
||||
return <Box dangerouslySetInnerHTML={{__html: content}} mb={2}/>;
|
||||
return <Box as="span" display="inline" dangerouslySetInnerHTML={{__html: content}} mb={2}/>;
|
||||
};
|
||||
|
||||
const CustomText: React.FC<{ text: React.ReactNode }> = ({text}) => {
|
||||
|
Reference in New Issue
Block a user