mirror of
https://github.com/geoffsee/open-gsio.git
synced 2025-09-08 22:56:46 +00:00
Refactor ClientChatStore
into separate stores for modularity and improve maintainability.
This commit is contained in:

committed by
Geoff Seemueller

parent
ebbfd4d31a
commit
df6e18bbdf
@@ -25,8 +25,8 @@ const Chat = observer(({ height, width }) => {
|
|||||||
width={width}
|
width={width}
|
||||||
gap={0}
|
gap={0}
|
||||||
>
|
>
|
||||||
<GridItem alignSelf="center" hidden={!(chatStore.messages.length < 1)}>
|
<GridItem alignSelf="center" hidden={!(chatStore.items.length < 1)}>
|
||||||
<WelcomeHome visible={chatStore.messages.length < 1} />
|
<WelcomeHome visible={chatStore.items.length < 1} />
|
||||||
</GridItem>
|
</GridItem>
|
||||||
|
|
||||||
<GridItem
|
<GridItem
|
||||||
|
@@ -71,7 +71,7 @@ const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(
|
|||||||
|
|
||||||
const handleCopyConversation = useCallback(() => {
|
const handleCopyConversation = useCallback(() => {
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(formatConversationMarkdown(ClientChatStore.messages))
|
.writeText(formatConversationMarkdown(ClientchatStore.items))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
window.alert(
|
window.alert(
|
||||||
"Conversation copied to clipboard. \n\nPaste it somewhere safe!",
|
"Conversation copied to clipboard. \n\nPaste it somewhere safe!",
|
||||||
|
@@ -27,8 +27,8 @@ const ChatMessages: React.FC<ChatMessagesProps> = observer(({ scrollRef }) => {
|
|||||||
boxShadow="md"
|
boxShadow="md"
|
||||||
whiteSpace="pre-wrap"
|
whiteSpace="pre-wrap"
|
||||||
>
|
>
|
||||||
{chatStore.messages.map((msg, index) => {
|
{chatStore.items.map((msg, index) => {
|
||||||
if (index < chatStore.messages.length - 1) {
|
if (index < chatStore.items.length - 1) {
|
||||||
return (
|
return (
|
||||||
<GridItem key={index}>
|
<GridItem key={index}>
|
||||||
<MessageBubble x scrollRef={scrollRef} msg={msg} />
|
<MessageBubble x scrollRef={scrollRef} msg={msg} />
|
||||||
|
@@ -63,12 +63,12 @@ const MessageBubble = observer(({ msg, scrollRef }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
clientChatStore.messages.length > 0 &&
|
clientChatStore.items.length > 0 &&
|
||||||
clientChatStore.isLoading &&
|
clientChatStore.isLoading &&
|
||||||
UserOptionsStore.followModeEnabled
|
UserOptionsStore.followModeEnabled
|
||||||
) {
|
) {
|
||||||
console.log(
|
console.log(
|
||||||
`${clientChatStore.messages.length}/${clientChatStore.isLoading}/${UserOptionsStore.followModeEnabled}`,
|
`${clientChatStore.items.length}/${clientChatStore.isLoading}/${UserOptionsStore.followModeEnabled}`,
|
||||||
);
|
);
|
||||||
scrollRef.current?.scrollTo({
|
scrollRef.current?.scrollTo({
|
||||||
top: scrollRef.current.scrollHeight,
|
top: scrollRef.current.scrollHeight,
|
||||||
|
@@ -1,229 +1,18 @@
|
|||||||
// ---------------------------
|
|
||||||
// stores/MessagesStore.ts
|
|
||||||
// ---------------------------
|
|
||||||
import { Instance, types } from "mobx-state-tree";
|
|
||||||
import Message from "../models/Message";
|
|
||||||
|
|
||||||
export const MessagesStore = types
|
|
||||||
.model("MessagesStore", {
|
|
||||||
items: types.optional(types.array(Message), []),
|
|
||||||
})
|
|
||||||
.actions((self) => ({
|
|
||||||
add(message: Instance<typeof Message>) {
|
|
||||||
self.items.push(message);
|
|
||||||
},
|
|
||||||
updateLast(content: string) {
|
|
||||||
if (self.items.length) {
|
|
||||||
self.items[self.items.length - 1].content = content;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
appendLast(content: string) {
|
|
||||||
if (self.items.length) {
|
|
||||||
self.items[self.items.length - 1].content += content;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
removeAfter(index: number) {
|
|
||||||
if (index >= 0 && index < self.items.length) {
|
|
||||||
self.items.splice(index + 1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
reset() {
|
|
||||||
self.items.clear();
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export interface IMessagesStore extends Instance<typeof MessagesStore> {}
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// stores/UIStore.ts
|
|
||||||
// ---------------------------
|
|
||||||
import { Instance, types } from "mobx-state-tree";
|
|
||||||
|
|
||||||
export const UIStore = types
|
|
||||||
.model("UIStore", {
|
|
||||||
input: types.optional(types.string, ""),
|
|
||||||
isLoading: types.optional(types.boolean, false),
|
|
||||||
})
|
|
||||||
.actions((self) => ({
|
|
||||||
setInput(value: string) {
|
|
||||||
self.input = value;
|
|
||||||
},
|
|
||||||
setIsLoading(value: boolean) {
|
|
||||||
self.isLoading = value;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export interface IUIStore extends Instance<typeof UIStore> {}
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// stores/ModelStore.ts
|
|
||||||
// ---------------------------
|
|
||||||
import { Instance, types } from "mobx-state-tree";
|
|
||||||
|
|
||||||
export const ModelStore = types
|
|
||||||
.model("ModelStore", {
|
|
||||||
model: types.optional(
|
|
||||||
types.string,
|
|
||||||
"meta-llama/llama-4-scout-17b-16e-instruct",
|
|
||||||
),
|
|
||||||
imageModel: types.optional(types.string, "black-forest-labs/flux-1.1-pro"),
|
|
||||||
supportedModels: types.optional(types.array(types.string), []),
|
|
||||||
})
|
|
||||||
.actions((self) => ({
|
|
||||||
setModel(value: string) {
|
|
||||||
self.model = value;
|
|
||||||
try {
|
|
||||||
localStorage.setItem("recentModel", value);
|
|
||||||
} catch (_) {}
|
|
||||||
},
|
|
||||||
setImageModel(value: string) {
|
|
||||||
self.imageModel = value;
|
|
||||||
},
|
|
||||||
setSupportedModels(list: string[]) {
|
|
||||||
self.supportedModels = list;
|
|
||||||
if (!list.includes(self.model)) {
|
|
||||||
// fall back to last entry (arbitrary but predictable)
|
|
||||||
self.model = list[list.length - 1] ?? self.model;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export interface IModelStore extends Instance<typeof ModelStore> {}
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// stores/StreamStore.ts
|
|
||||||
// Handles networking + SSE lifecycle.
|
|
||||||
// Depends on MessagesStore, UIStore, and ModelStore via composition.
|
|
||||||
// ---------------------------
|
|
||||||
import {
|
|
||||||
getParent,
|
|
||||||
Instance,
|
|
||||||
flow,
|
|
||||||
types,
|
|
||||||
} from "mobx-state-tree";
|
|
||||||
import type { IMessagesStore } from "./MessagesStore";
|
|
||||||
import type { IUIStore } from "./UIStore";
|
|
||||||
import type { IModelStore } from "./ModelStore";
|
|
||||||
import UserOptionsStore from "./UserOptionsStore";
|
|
||||||
import Message from "../models/Message";
|
|
||||||
|
|
||||||
interface RootDeps extends IMessagesStore, IUIStore, IModelStore {}
|
|
||||||
|
|
||||||
export const StreamStore = types
|
|
||||||
.model("StreamStore", {})
|
|
||||||
.volatile(() => ({
|
|
||||||
eventSource: null as EventSource | null,
|
|
||||||
}))
|
|
||||||
.actions((self) => {
|
|
||||||
// helpers
|
|
||||||
const root = getParent<RootDeps>(self);
|
|
||||||
|
|
||||||
function cleanup() {
|
|
||||||
if (self.eventSource) {
|
|
||||||
self.eventSource.close();
|
|
||||||
self.eventSource = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendMessage = flow(function* () {
|
|
||||||
if (!root.input.trim() || root.isLoading) return;
|
|
||||||
cleanup();
|
|
||||||
yield UserOptionsStore.setFollowModeEnabled(true);
|
|
||||||
root.setIsLoading(true);
|
|
||||||
|
|
||||||
const userMessage = Message.create({
|
|
||||||
content: root.input,
|
|
||||||
role: "user" as const,
|
|
||||||
});
|
|
||||||
root.add(userMessage);
|
|
||||||
root.setInput("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = { messages: root.items.slice(), model: root.model };
|
|
||||||
// optimistic UI delay (demo‑purpose)
|
|
||||||
yield new Promise((r) => setTimeout(r, 500));
|
|
||||||
root.add(Message.create({ content: "", role: "assistant" }));
|
|
||||||
|
|
||||||
const response: Response = yield fetch("/api/chat", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 429) {
|
|
||||||
root.updateLast("Too many requests • please slow down.");
|
|
||||||
cleanup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.status > 200) {
|
|
||||||
root.updateLast("Error • something went wrong.");
|
|
||||||
cleanup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { streamUrl } = (yield response.json()) as { streamUrl: string };
|
|
||||||
self.eventSource = new EventSource(streamUrl);
|
|
||||||
|
|
||||||
self.eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(event.data);
|
|
||||||
if (parsed.type === "error") {
|
|
||||||
root.updateLast(parsed.error);
|
|
||||||
cleanup();
|
|
||||||
root.setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
parsed.type === "chat" &&
|
|
||||||
parsed.data.choices[0]?.finish_reason === "stop"
|
|
||||||
) {
|
|
||||||
root.appendLast(parsed.data.choices[0]?.delta?.content ?? "");
|
|
||||||
cleanup();
|
|
||||||
root.setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.type === "chat") {
|
|
||||||
root.appendLast(parsed.data.choices[0]?.delta?.content ?? "");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("stream parse error", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
self.eventSource.onerror = () => cleanup();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("sendMessage", err);
|
|
||||||
root.updateLast("Sorry • network error.");
|
|
||||||
cleanup();
|
|
||||||
root.setIsLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopIncomingMessage = () => {
|
|
||||||
cleanup();
|
|
||||||
root.setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { sendMessage, stopIncomingMessage, cleanup };
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface IStreamStore extends Instance<typeof StreamStore> {}
|
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// stores/ClientChatStore.ts (root)
|
// stores/ClientChatStore.ts (root)
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
import { types } from "mobx-state-tree";
|
import { types, type Instance } from "mobx-state-tree";
|
||||||
// import { MessagesStore } from "./MessagesStore";
|
import { MessagesStore } from "./MessagesStore";
|
||||||
// import { UIStore } from "./UIStore";
|
import { UIStore } from "./UIStore";
|
||||||
// import { ModelStore } from "./ModelStore";
|
import { ModelStore } from "./ModelStore";
|
||||||
// import { StreamStore } from "./StreamStore";
|
import { StreamStore } from "./StreamStore";
|
||||||
|
|
||||||
export const ClientChatStore = types
|
export const ClientChatStore = types
|
||||||
.compose(MessagesStore, UIStore, ModelStore, StreamStore)
|
.compose(MessagesStore, UIStore, ModelStore, StreamStore)
|
||||||
.named("ClientChatStore");
|
.named("ClientChatStore");
|
||||||
|
|
||||||
export const clientChatStore = ClientChatStore.create();
|
const clientChatStore = ClientChatStore.create();
|
||||||
|
|
||||||
export type IClientChatStore = Instance<typeof ClientChatStore>;
|
export type IClientChatStore = Instance<typeof ClientChatStore>;
|
||||||
|
|
||||||
|
export default clientChatStore;
|
||||||
|
35
src/stores/MessagesStore.ts
Normal file
35
src/stores/MessagesStore.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// ---------------------------
|
||||||
|
// stores/MessagesStore.ts
|
||||||
|
// ---------------------------
|
||||||
|
import { Instance, types } from "mobx-state-tree";
|
||||||
|
import Message from "../models/Message";
|
||||||
|
|
||||||
|
export const MessagesStore = types
|
||||||
|
.model("MessagesStore", {
|
||||||
|
items: types.optional(types.array(Message), []),
|
||||||
|
})
|
||||||
|
.actions((self) => ({
|
||||||
|
add(message: Instance<typeof Message>) {
|
||||||
|
self.items.push(message);
|
||||||
|
},
|
||||||
|
updateLast(content: string) {
|
||||||
|
if (self.items.length) {
|
||||||
|
self.items[self.items.length - 1].content = content;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
appendLast(content: string) {
|
||||||
|
if (self.items.length) {
|
||||||
|
self.items[self.items.length - 1].content += content;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeAfter(index: number) {
|
||||||
|
if (index >= 0 && index < self.items.length) {
|
||||||
|
self.items.splice(index + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
self.items.clear();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface IMessagesStore extends Instance<typeof MessagesStore> {}
|
35
src/stores/ModelStore.ts
Normal file
35
src/stores/ModelStore.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// stores/ModelStore.ts
|
||||||
|
// ---------------------------
|
||||||
|
import { Instance, types } from "mobx-state-tree";
|
||||||
|
|
||||||
|
export const ModelStore = types
|
||||||
|
.model("ModelStore", {
|
||||||
|
model: types.optional(
|
||||||
|
types.string,
|
||||||
|
"meta-llama/llama-4-scout-17b-16e-instruct",
|
||||||
|
),
|
||||||
|
imageModel: types.optional(types.string, "black-forest-labs/flux-1.1-pro"),
|
||||||
|
supportedModels: types.optional(types.array(types.string), []),
|
||||||
|
})
|
||||||
|
.actions((self) => ({
|
||||||
|
setModel(value: string) {
|
||||||
|
self.model = value;
|
||||||
|
try {
|
||||||
|
localStorage.setItem("recentModel", value);
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
setImageModel(value: string) {
|
||||||
|
self.imageModel = value;
|
||||||
|
},
|
||||||
|
setSupportedModels(list: string[]) {
|
||||||
|
self.supportedModels = list;
|
||||||
|
if (!list.includes(self.model)) {
|
||||||
|
// fall back to last entry (arbitrary but predictable)
|
||||||
|
self.model = list[list.length - 1] ?? self.model;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface IModelStore extends Instance<typeof ModelStore> {}
|
121
src/stores/StreamStore.ts
Normal file
121
src/stores/StreamStore.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
getParent,
|
||||||
|
Instance,
|
||||||
|
flow,
|
||||||
|
types,
|
||||||
|
} from "mobx-state-tree";
|
||||||
|
import type { IMessagesStore } from "./MessagesStore";
|
||||||
|
import type { IUIStore } from "./UIStore";
|
||||||
|
import type { IModelStore } from "./ModelStore";
|
||||||
|
import UserOptionsStore from "./UserOptionsStore";
|
||||||
|
import Message from "../models/Message";
|
||||||
|
|
||||||
|
interface RootDeps extends IMessagesStore, IUIStore, IModelStore {}
|
||||||
|
|
||||||
|
export const StreamStore = types
|
||||||
|
.model("StreamStore", {})
|
||||||
|
.volatile(() => ({
|
||||||
|
eventSource: null as EventSource | null,
|
||||||
|
}))
|
||||||
|
.actions((self: any) => { // ← annotate `self` so it isn’t implicitly `any`
|
||||||
|
let root: RootDeps;
|
||||||
|
try {
|
||||||
|
root = getParent<RootDeps>(self);
|
||||||
|
} catch {
|
||||||
|
root = self as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (self.eventSource) {
|
||||||
|
self.eventSource.close();
|
||||||
|
self.eventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = flow(function* () {
|
||||||
|
if (!root.input.trim() || root.isLoading) return;
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
// ← **DO NOT** `yield` a synchronous action
|
||||||
|
UserOptionsStore.setFollowModeEnabled(true);
|
||||||
|
root.setIsLoading(true);
|
||||||
|
|
||||||
|
const userMessage = Message.create({
|
||||||
|
content: root.input,
|
||||||
|
role: "user" as const,
|
||||||
|
});
|
||||||
|
root.add(userMessage);
|
||||||
|
root.setInput("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = { messages: root.items.slice(), model: root.model };
|
||||||
|
|
||||||
|
yield new Promise((r) => setTimeout(r, 500));
|
||||||
|
root.add(Message.create({ content: "", role: "assistant" }));
|
||||||
|
|
||||||
|
const response: Response = yield fetch("/api/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
root.updateLast("Too many requests • please slow down.");
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.status > 200) {
|
||||||
|
root.updateLast("Error • something went wrong.");
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { streamUrl } = (yield response.json()) as { streamUrl: string };
|
||||||
|
self.eventSource = new EventSource(streamUrl);
|
||||||
|
|
||||||
|
self.eventSource.onmessage = (event: MessageEvent) => { // ← annotate `event`
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data);
|
||||||
|
if (parsed.type === "error") {
|
||||||
|
root.updateLast(parsed.error);
|
||||||
|
cleanup();
|
||||||
|
root.setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.type === "chat" &&
|
||||||
|
parsed.data.choices[0]?.finish_reason === "stop"
|
||||||
|
) {
|
||||||
|
root.appendLast(parsed.data.choices[0]?.delta?.content ?? "");
|
||||||
|
cleanup();
|
||||||
|
root.setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "chat") {
|
||||||
|
root.appendLast(parsed.data.choices[0]?.delta?.content ?? "");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("stream parse error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.eventSource.onerror = () => cleanup();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("sendMessage", err);
|
||||||
|
root.updateLast("Sorry • network error.");
|
||||||
|
cleanup();
|
||||||
|
root.setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopIncomingMessage = () => {
|
||||||
|
cleanup();
|
||||||
|
root.setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { sendMessage, stopIncomingMessage, cleanup };
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IStreamStore extends Instance<typeof StreamStore> {}
|
21
src/stores/UIStore.ts
Normal file
21
src/stores/UIStore.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// stores/UIStore.ts
|
||||||
|
// ---------------------------
|
||||||
|
import { Instance, types } from "mobx-state-tree";
|
||||||
|
|
||||||
|
export const UIStore = types
|
||||||
|
.model("UIStore", {
|
||||||
|
input: types.optional(types.string, ""),
|
||||||
|
isLoading: types.optional(types.boolean, false),
|
||||||
|
})
|
||||||
|
.actions((self) => ({
|
||||||
|
setInput(value: string) {
|
||||||
|
self.input = value;
|
||||||
|
},
|
||||||
|
setIsLoading(value: boolean) {
|
||||||
|
self.isLoading = value;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface IUIStore extends Instance<typeof UIStore> {}
|
Reference in New Issue
Block a user