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
13
README.md
13
README.md
@@ -1,7 +1,6 @@
|
|||||||
# open-gsio
|
# open-gsio
|
||||||
[](https://github.com/geoffsee/open-gsio/actions/workflows/test.yml)
|
[](https://github.com/geoffsee/open-gsio/actions/workflows/test.yml)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||

|
|
||||||
|
|
||||||
### Stack:
|
### Stack:
|
||||||
- vike
|
- vike
|
||||||
@@ -51,6 +50,18 @@ sed -i '' '/^OPENAI_API_ENDPOINT=/d' .dev.vars; echo 'OPENAI_API_ENDPOINT=http:/
|
|||||||
### Restart open-gsio server so it uses the new variables
|
### Restart open-gsio server so it uses the new variables
|
||||||
bun run server:dev
|
bun run server:dev
|
||||||
~~~
|
~~~
|
||||||
|
## Adding models for local inference
|
||||||
|
~~~console
|
||||||
|
MODEL_TO_ADD=mlx-community/gemma-3-4b-it-8bit
|
||||||
|
# Chat completions endpoint
|
||||||
|
curl http://localhost:10240/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model: ,
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
|
}'
|
||||||
|
~~~
|
||||||
|
|
||||||
History
|
History
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -19,9 +19,9 @@
|
|||||||
"tail:analytics-service": "wrangler tail -c workers/analytics/wrangler-analytics.toml",
|
"tail:analytics-service": "wrangler tail -c workers/analytics/wrangler-analytics.toml",
|
||||||
"tail:session-proxy": "wrangler tail -c workers/session-proxy/wrangler-session-proxy.toml --env production",
|
"tail:session-proxy": "wrangler tail -c workers/session-proxy/wrangler-session-proxy.toml --env production",
|
||||||
"openai:local": "./scripts/start_inference_server.sh",
|
"openai:local": "./scripts/start_inference_server.sh",
|
||||||
"test": "NODE_OPTIONS=--no-experimental-fetch vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "NODE_OPTIONS=--no-experimental-fetch vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "NODE_OPTIONS=--no-experimental-fetch vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.32.1",
|
"@anthropic-ai/sdk": "^0.32.1",
|
||||||
|
@@ -5,4 +5,4 @@ SERVER_TYPE="mlx-omni-server"
|
|||||||
printf "Starting Inference Server: %s\n" ${SERVER_TYPE}
|
printf "Starting Inference Server: %s\n" ${SERVER_TYPE}
|
||||||
|
|
||||||
|
|
||||||
mlx-omni-server
|
mlx-omni-server --log-level debug
|
@@ -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 { Box, Flex, IconButton, Textarea } from "@chakra-ui/react";
|
||||||
import { Check, X } from "lucide-react";
|
import { Check, X } from "lucide-react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Instance } from "mobx-state-tree";
|
import { Instance } from "mobx-state-tree";
|
||||||
import Message from "../../../models/Message";
|
import Message from "../../../models/Message";
|
||||||
import clientChatStore from "../../../stores/ClientChatStore";
|
import messageEditorStore from "../../../stores/MessageEditorStore";
|
||||||
import UserOptionsStore from "../../../stores/UserOptionsStore";
|
|
||||||
|
|
||||||
interface MessageEditorProps {
|
interface MessageEditorProps {
|
||||||
message: Instance<typeof Message>;
|
message: Instance<typeof Message>;
|
||||||
@@ -13,113 +12,38 @@ interface MessageEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
|
const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
|
||||||
const [editedContent, setEditedContent] = useState(message.content);
|
useEffect(() => {
|
||||||
|
messageEditorStore.setMessage(message);
|
||||||
|
|
||||||
const handleSave = async () => {
|
return () => {
|
||||||
message.setContent(editedContent);
|
messageEditorStore.onCancel();
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
const handleCancel = () => {
|
||||||
clientChatStore.updateLast("Error • connection lost.");
|
messageEditorStore.onCancel();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onCancel();
|
onCancel();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSave();
|
messageEditorStore.handleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onCancel();
|
handleCancel();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box width="100%">
|
<Box width="100%">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={editedContent}
|
value={messageEditorStore.editedContent}
|
||||||
onChange={(e) => setEditedContent(e.target.value)}
|
onChange={(e) => messageEditorStore.setEditedContent(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
minHeight="100px"
|
minHeight="100px"
|
||||||
bg="transparent"
|
bg="transparent"
|
||||||
@@ -134,7 +58,7 @@ const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
aria-label="Cancel edit"
|
aria-label="Cancel edit"
|
||||||
icon={<X />}
|
icon={<X />}
|
||||||
onClick={onCancel}
|
onClick={handleCancel}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color={"accent.danger"}
|
color={"accent.danger"}
|
||||||
@@ -142,7 +66,7 @@ const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
aria-label="Save edit"
|
aria-label="Save edit"
|
||||||
icon={<Check />}
|
icon={<Check />}
|
||||||
onClick={handleSave}
|
onClick={() => messageEditorStore.handleSave()}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color={"accent.confirm"}
|
color={"accent.confirm"}
|
||||||
|
@@ -3,8 +3,9 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import MessageEditor from '../MessageEditorComponent';
|
import MessageEditor from '../MessageEditorComponent';
|
||||||
|
|
||||||
// Import the mocked clientChatStore
|
// Import the mocked stores
|
||||||
import clientChatStore from '../../../../stores/ClientChatStore';
|
import clientChatStore from '../../../../stores/ClientChatStore';
|
||||||
|
import messageEditorStore from '../../../../stores/MessageEditorStore';
|
||||||
|
|
||||||
// Mock the Message model
|
// Mock the Message model
|
||||||
vi.mock('../../../../models/Message', () => {
|
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
|
// Mock the ClientChatStore
|
||||||
vi.mock('../../../../stores/ClientChatStore', () => {
|
vi.mock('../../../../stores/ClientChatStore', () => {
|
||||||
const mockStore = {
|
const mockStore = {
|
||||||
items: [],
|
items: [],
|
||||||
removeAfter: vi.fn(),
|
removeAfter: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
setIsLoading: vi.fn()
|
setIsLoading: vi.fn(),
|
||||||
|
editMessage: vi.fn().mockReturnValue(true)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add the mockUserMessage to the items array
|
// 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', () => {
|
describe('MessageEditor', () => {
|
||||||
// Create a message object with a setContent method
|
// Create a message object with a setContent method
|
||||||
const mockUserMessage = {
|
const mockUserMessage = {
|
||||||
@@ -51,6 +80,7 @@ describe('MessageEditor', () => {
|
|||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
expect(textarea).toBeInTheDocument();
|
expect(textarea).toBeInTheDocument();
|
||||||
expect(textarea).toHaveValue('Test message');
|
expect(textarea).toHaveValue('Test message');
|
||||||
|
expect(messageEditorStore.setMessage).toHaveBeenCalledWith(mockUserMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update the content when typing', () => {
|
it('should update the content when typing', () => {
|
||||||
@@ -59,22 +89,16 @@ describe('MessageEditor', () => {
|
|||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
fireEvent.change(textarea, { target: { value: 'Updated message' } });
|
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', () => {
|
it('should call handleSave when save button is clicked', () => {
|
||||||
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel}/>);
|
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel}/>);
|
||||||
|
|
||||||
const textarea = screen.getByRole('textbox');
|
|
||||||
fireEvent.change(textarea, { target: { value: 'Updated message' } });
|
|
||||||
|
|
||||||
const saveButton = screen.getByLabelText('Save edit');
|
const saveButton = screen.getByLabelText('Save edit');
|
||||||
fireEvent.click(saveButton);
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
expect(mockUserMessage.setContent).toHaveBeenCalledWith('Updated message');
|
expect(messageEditorStore.handleSave).toHaveBeenCalled();
|
||||||
expect(clientChatStore.removeAfter).toHaveBeenCalledWith(0);
|
|
||||||
expect(clientChatStore.sendMessage).toHaveBeenCalled();
|
|
||||||
expect(mockOnCancel).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onCancel when cancel button is clicked', () => {
|
it('should call onCancel when cancel button is clicked', () => {
|
||||||
@@ -83,43 +107,35 @@ describe('MessageEditor', () => {
|
|||||||
const cancelButton = screen.getByLabelText('Cancel edit');
|
const cancelButton = screen.getByLabelText('Cancel edit');
|
||||||
fireEvent.click(cancelButton);
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
expect(messageEditorStore.onCancel).toHaveBeenCalled();
|
||||||
expect(mockOnCancel).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} />);
|
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
fireEvent.change(textarea, { target: { value: 'Updated message' } });
|
|
||||||
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true });
|
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true });
|
||||||
|
|
||||||
expect(mockUserMessage.setContent).toHaveBeenCalledWith('Updated message');
|
expect(messageEditorStore.handleSave).toHaveBeenCalled();
|
||||||
expect(clientChatStore.removeAfter).toHaveBeenCalledWith(0);
|
|
||||||
expect(clientChatStore.sendMessage).toHaveBeenCalled();
|
|
||||||
expect(mockOnCancel).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should save when Meta+Enter is pressed', () => {
|
it('should call handleSave when Meta+Enter is pressed', () => {
|
||||||
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel} />);
|
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
fireEvent.change(textarea, { target: { value: 'Updated message' } });
|
|
||||||
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true });
|
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true });
|
||||||
|
|
||||||
expect(mockUserMessage.setContent).toHaveBeenCalledWith('Updated message');
|
expect(messageEditorStore.handleSave).toHaveBeenCalled();
|
||||||
expect(clientChatStore.removeAfter).toHaveBeenCalledWith(0);
|
|
||||||
expect(clientChatStore.sendMessage).toHaveBeenCalled();
|
|
||||||
expect(mockOnCancel).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should cancel when Escape is pressed', () => {
|
it('should call onCancel when Escape is pressed', () => {
|
||||||
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel} />);
|
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
fireEvent.keyDown(textarea, { key: 'Escape' });
|
fireEvent.keyDown(textarea, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(messageEditorStore.onCancel).toHaveBeenCalled();
|
||||||
expect(mockOnCancel).toHaveBeenCalled();
|
expect(mockOnCancel).toHaveBeenCalled();
|
||||||
expect(mockUserMessage.setContent).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -238,7 +238,7 @@ const CustomTable: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CustomHtmlBlock: React.FC<{ content: string }> = ({content}) => {
|
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}) => {
|
const CustomText: React.FC<{ text: React.ReactNode }> = ({text}) => {
|
||||||
|
@@ -1,7 +1,13 @@
|
|||||||
import { types } from "mobx-state-tree";
|
import { types } from "mobx-state-tree";
|
||||||
|
|
||||||
|
// Simple function to generate a unique ID
|
||||||
|
export const generateId = () => {
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
||||||
|
};
|
||||||
|
|
||||||
export default types
|
export default types
|
||||||
.model("Message", {
|
.model("Message", {
|
||||||
|
id: types.optional(types.identifier, generateId),
|
||||||
content: types.string,
|
content: types.string,
|
||||||
role: types.enumeration(["user", "assistant"]),
|
role: types.enumeration(["user", "assistant"]),
|
||||||
})
|
})
|
||||||
|
148
src/stores/MessageEditorStore.ts
Normal file
148
src/stores/MessageEditorStore.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import {types, Instance} from "mobx-state-tree";
|
||||||
|
import clientChatStore from "./ClientChatStore";
|
||||||
|
import UserOptionsStore from "./UserOptionsStore";
|
||||||
|
import Message from "../models/Message";
|
||||||
|
import {MessagesStore} from "./MessagesStore";
|
||||||
|
|
||||||
|
export const MessageEditorStore = types
|
||||||
|
.compose(
|
||||||
|
MessagesStore,
|
||||||
|
types.model("MessageEditorStore", {
|
||||||
|
editedContent: types.optional(types.string, ""),
|
||||||
|
messageId: types.optional(types.string, "")
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.views((self) => ({
|
||||||
|
getMessage() {
|
||||||
|
// Find the message in clientChatStore by ID
|
||||||
|
if (!self.messageId) return null;
|
||||||
|
|
||||||
|
const message = clientChatStore.items.find(item => item.id === self.messageId);
|
||||||
|
return message || null;
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.actions((self) => ({
|
||||||
|
setEditedContent(content: string) {
|
||||||
|
self.editedContent = content;
|
||||||
|
},
|
||||||
|
setMessage(message: Instance<typeof Message>) {
|
||||||
|
self.messageId = message.id;
|
||||||
|
self.editedContent = message.content;
|
||||||
|
},
|
||||||
|
onCancel() {
|
||||||
|
self.messageId = "";
|
||||||
|
self.editedContent = "";
|
||||||
|
},
|
||||||
|
handleSave: async () => {
|
||||||
|
// Get the message using the ID
|
||||||
|
const message = self.getMessage();
|
||||||
|
|
||||||
|
// Check if message reference is still valid
|
||||||
|
if (!message) {
|
||||||
|
// Message reference is no longer valid, just cancel the edit
|
||||||
|
self.onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the content we want to update
|
||||||
|
const contentToUpdate = self.editedContent;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the editMessage function from MessagesStore
|
||||||
|
const success = clientChatStore.editMessage(message, contentToUpdate);
|
||||||
|
if (!success) {
|
||||||
|
// Message not found in the items array, just cancel
|
||||||
|
self.onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 to clientChatStore's items
|
||||||
|
clientChatStore.add(Message.create({content: "", role: "assistant"}));
|
||||||
|
// Use clientChatStore for the API call since it has the model property
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error in handleSave:", err);
|
||||||
|
// If any error occurs, just cancel the edit
|
||||||
|
self.onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always clean up at the end
|
||||||
|
self.onCancel();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const messageEditorStore = MessageEditorStore.create();
|
||||||
|
|
||||||
|
export default messageEditorStore;
|
@@ -30,6 +30,22 @@ export const MessagesStore = types
|
|||||||
reset() {
|
reset() {
|
||||||
self.items.clear();
|
self.items.clear();
|
||||||
},
|
},
|
||||||
|
editMessage(message: Instance<typeof Message>, newContent: string) {
|
||||||
|
// Find the index of the message in the items array
|
||||||
|
const messageIndex = self.items.indexOf(message);
|
||||||
|
if (messageIndex === -1) {
|
||||||
|
// Message not found in the items array
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the message content
|
||||||
|
message.setContent(newContent);
|
||||||
|
|
||||||
|
// Remove all messages after the edited message
|
||||||
|
self.removeAfter(messageIndex);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export interface IMessagesStore extends Instance<typeof MessagesStore> {}
|
export interface IMessagesStore extends Instance<typeof MessagesStore> {}
|
||||||
|
@@ -3,6 +3,7 @@ import {
|
|||||||
Instance,
|
Instance,
|
||||||
flow,
|
flow,
|
||||||
types,
|
types,
|
||||||
|
applyAction,
|
||||||
} from "mobx-state-tree";
|
} from "mobx-state-tree";
|
||||||
import type { IMessagesStore } from "./MessagesStore";
|
import type { IMessagesStore } from "./MessagesStore";
|
||||||
import type { IUIStore } from "./UIStore";
|
import type { IUIStore } from "./UIStore";
|
||||||
|
@@ -71,7 +71,7 @@ export default defineConfig(({command}) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
esbuild: {
|
esbuild: {
|
||||||
drop: ["console"]
|
// drop: ["console"]
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
emitAssets: false,
|
emitAssets: false,
|
||||||
|
@@ -121,7 +121,10 @@ const ChatService = types
|
|||||||
console.log("getting local models")
|
console.log("getting local models")
|
||||||
const openaiClient = new OpenAI({baseURL: self.env.OPENAI_API_ENDPOINT})
|
const openaiClient = new OpenAI({baseURL: self.env.OPENAI_API_ENDPOINT})
|
||||||
const models = await openaiClient.models.list();
|
const models = await openaiClient.models.list();
|
||||||
return Response.json(models.data.map(model => model.id));
|
return Response.json(
|
||||||
|
models.data
|
||||||
|
.filter(model => model.id.includes("mlx"))
|
||||||
|
.map(model => model.id));
|
||||||
}
|
}
|
||||||
return Response.json(SUPPORTED_MODELS);
|
return Response.json(SUPPORTED_MODELS);
|
||||||
},
|
},
|
||||||
@@ -187,7 +190,9 @@ const ChatService = types
|
|||||||
|
|
||||||
const handler = useModelHandler();
|
const handler = useModelHandler();
|
||||||
|
|
||||||
|
|
||||||
if (handler) {
|
if (handler) {
|
||||||
|
console.log(`Using provider: ${modelFamily}`);
|
||||||
try {
|
try {
|
||||||
await handler(streamParams, handleStreamData(controller, encoder));
|
await handler(streamParams, handleStreamData(controller, encoder));
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user