From f4144b70c74132a46af5818697abead4e3e3cf65 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Sat, 21 Mar 2026 17:28:41 +0000 Subject: [PATCH] Connect chat to llm --- .../assets/chat-sidebar.module.css | 17 +++ .../storywriter/components/chat-sidebar.tsx | 108 +++++++++++++++--- src/games/storywriter/contexts/state.tsx | 21 +++- src/games/storywriter/utils/llm.ts | 76 ++++++------ src/games/storywriter/utils/prompt.ts | 30 +++++ 5 files changed, 192 insertions(+), 60 deletions(-) create mode 100644 src/games/storywriter/utils/prompt.ts diff --git a/src/games/storywriter/assets/chat-sidebar.module.css b/src/games/storywriter/assets/chat-sidebar.module.css index 4e1a751..1b2a71c 100644 --- a/src/games/storywriter/assets/chat-sidebar.module.css +++ b/src/games/storywriter/assets/chat-sidebar.module.css @@ -57,6 +57,23 @@ word-wrap: break-word; } +.loading { + color: var(--text-muted); + font-style: italic; +} + +.errorMessage { + background: rgba(244, 67, 54, 0.1); + border-left: 2px solid var(--error, #f44336); +} + +.errorText { + font-size: 13px; + color: var(--error, #f44336); + white-space: pre-wrap; + word-wrap: break-word; +} + .inputContainer { display: flex; flex-direction: column; diff --git a/src/games/storywriter/components/chat-sidebar.tsx b/src/games/storywriter/components/chat-sidebar.tsx index 44f7cb6..54835fe 100644 --- a/src/games/storywriter/components/chat-sidebar.tsx +++ b/src/games/storywriter/components/chat-sidebar.tsx @@ -2,11 +2,19 @@ import { Sidebar } from "./sidebar"; import { useAppState } from "../contexts/state"; import styles from '../assets/chat-sidebar.module.css'; import { useState, useRef, useEffect } from "preact/hooks"; +import LLM from "../utils/llm"; +import { highlight } from "../utils/highlight"; +import Prompt from "../utils/prompt"; +import clsx from "clsx"; export const ChatSidebar = () => { - const { currentStory, dispatch } = useAppState(); + const appState = useAppState(); + const { currentStory, dispatch, connection, model } = appState; const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); const messagesRef = useRef(null); + const abortControllerRef = useRef(null); useEffect(() => { if (messagesRef.current) { @@ -17,29 +25,81 @@ export const ChatSidebar = () => { } }, [currentStory?.chatMessages.length]); - const sendMessage = () => { - if (!currentStory || !input.trim()) return; + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const sendMessage = async () => { + if (!currentStory || !input.trim() || !connection || !model || isLoading) return; + + const userMessage = { + id: crypto.randomUUID(), + role: 'user' as const, + content: input.trim(), + }; dispatch({ type: 'ADD_CHAT_MESSAGE', storyId: currentStory.id, - message: { - id: crypto.randomUUID(), - role: 'user', - content: input.trim(), - }, + message: userMessage, }); + + const assistantMessageId = crypto.randomUUID(); dispatch({ type: 'ADD_CHAT_MESSAGE', storyId: currentStory.id, message: { - id: crypto.randomUUID(), + id: assistantMessageId, role: 'assistant', - content: 'Assistant message goes here...', + content: 'Generating...', }, }); setInput(''); + setIsLoading(true); + setError(null); + + const request = Prompt.compilePrompt(appState, userMessage); + + if (!request) { + setError('Failed to compile prompt'); + setIsLoading(false); + return; + } + + try { + let accumulatedContent = ''; + + for await (const chunk of LLM.generateStream(connection, request)) { + const delta = chunk.choices[0]?.delta?.content; + if (delta) { + accumulatedContent += delta; + dispatch({ + type: 'ADD_CHAT_MESSAGE', + storyId: currentStory.id, + message: { + id: assistantMessageId, + role: 'assistant', + content: accumulatedContent, + }, + }); + } + if (abortControllerRef.current?.signal.aborted) { + break; + } + } + } catch (err) { + const errorMessage = err instanceof Error + ? err.message + : 'Failed to generate response'; + + setError(errorMessage); + } finally { + setIsLoading(false); + abortControllerRef.current = null; + } }; const handleKeyDown = (e: KeyboardEvent) => { @@ -57,6 +117,8 @@ export const ChatSidebar = () => { }); }; + const isDisabled = !currentStory || !connection || !model || isLoading; + return (
@@ -64,6 +126,10 @@ export const ChatSidebar = () => {
Select a story to start chatting
+ ) : !connection || !model ? ( +
+ {!connection ? 'Connect to an LLM server' : 'Select a model'} to start chatting +
) : currentStory.chatMessages.length === 0 ? (
No messages yet @@ -73,9 +139,18 @@ export const ChatSidebar = () => { {currentStory.chatMessages.map((message) => (
{message.role}
-
{message.content}
+
))} + {error && ( +
+
error
+
{error}
+
+ )} @@ -88,11 +163,16 @@ export const ChatSidebar = () => { value={input} onInput={(e) => setInput((e.target as HTMLTextAreaElement).value)} onKeyDown={handleKeyDown} - placeholder="Type a message..." + placeholder={isDisabled ? 'Connect to an LLM server to chat' : 'Type a message...'} rows={3} + disabled={isDisabled} /> -
)} diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index f3988d9..ad93b5b 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -101,9 +101,18 @@ function reducer(state: IState, action: Action): IState { case 'ADD_CHAT_MESSAGE': { return { ...state, - stories: state.stories.map(s => - s.id === action.storyId ? { ...s, chatMessages: [...s.chatMessages, action.message] } : s - ), + stories: state.stories.map(s => { + if (s.id !== action.storyId) return s; + const existingIndex = s.chatMessages.findIndex(m => m.id === action.message.id); + if (existingIndex !== -1) { + // Overwrite existing message with same id + const updatedMessages = [...s.chatMessages]; + updatedMessages[existingIndex] = action.message; + return { ...s, chatMessages: updatedMessages }; + } + // Append new message + return { ...s, chatMessages: [...s.chatMessages, action.message] }; + }), }; } case 'CLEAR_CHAT': { @@ -131,7 +140,7 @@ function reducer(state: IState, action: Action): IState { // ─── Context ───────────────────────────────────────────────────────────────── -interface IStateContext { +export interface AppState { stories: Story[]; currentStory: Story | null; connection: LLM.Connection | null; @@ -139,7 +148,7 @@ interface IStateContext { dispatch: (action: Action) => void; } -const StateContext = createContext({} as IStateContext); +const StateContext = createContext({} as AppState); export const useAppState = () => useContext(StateContext); @@ -148,7 +157,7 @@ export const useAppState = () => useContext(StateContext); export const StateContextProvider = ({ children }: { children?: any }) => { const [state, dispatch] = useStoredReducer('storywriter.state', reducer, DEFAULT_STATE); - const value = useMemo(() => ({ + const value = useMemo(() => ({ stories: state.stories, currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null, connection: state.connection, diff --git a/src/games/storywriter/utils/llm.ts b/src/games/storywriter/utils/llm.ts index e0f6d45..3c19bbc 100644 --- a/src/games/storywriter/utils/llm.ts +++ b/src/games/storywriter/utils/llm.ts @@ -1,4 +1,5 @@ import { formatError } from '@common/errors'; +import Lock from '@common/lock'; import SSE, { type SSEEvent } from '@common/sse'; namespace LLM { @@ -116,56 +117,51 @@ namespace LLM { payload: body ? JSON.stringify(body) : undefined, }); - while (true) { - const event = await new Promise<{ data: string } | null>((resolve, reject) => { - const onMessage = (e: SSEEvent) => { - cleanup(); + const readable = new ReadableStream({ + start: async (controller) => { + sse.addEventListener('message', (e) => { if (isMessageEvent(e)) { - resolve(e); - } else { - resolve(null); - } - }; - const onError = (e: SSEEvent) => { - cleanup(); - reject(new Error(formatError(e, 'SSE connection error'))); - }; - const onAbort = () => { - cleanup(); - resolve(null); - }; - const onReadyStateChange = (e: SSEEvent) => { - if (e != null && typeof e === 'object' && 'readyState' in e && e.readyState === SSE.CLOSED) { - cleanup(); - resolve(null); + if (e.data === '[DONE]') { + controller.close(); + } else { + controller.enqueue(e.data); + } } + }); + + let closed = false; + const handleEnd = (e?: unknown) => { + if (closed) return; + closed = true; + controller.close(); + + console.log(formatError(e)); }; - const cleanup = () => { - sse.removeEventListener('message', onMessage); - sse.removeEventListener('error', onError); - sse.removeEventListener('abort', onAbort); - sse.removeEventListener('readystatechange', onReadyStateChange); - }; + sse.addEventListener('error', handleEnd); + sse.addEventListener('abort', handleEnd); + sse.addEventListener('readystatechange', (e) => { + if (e.readyState === SSE.CLOSED) handleEnd(); + }); + } + }); - sse.addEventListener('message', onMessage); - sse.addEventListener('error', onError); - sse.addEventListener('abort', onAbort); - sse.addEventListener('readystatechange', onReadyStateChange); - }); + const reader = readable.getReader(); - if (!event || event.data === '[DONE]') { + while (true) { + const { value, done } = await reader.read(); + if (done) { break; } - - if (event.data) { - try { - yield JSON.parse(event.data); - } catch (err) { - console.error('Failed to parse SSE data:', event.data, err); - } + try { + yield JSON.parse(value); + } catch { + break; } } + + await reader.closed; + sse.close(); } function isMessageEvent(e: unknown): e is { data: string } { diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts new file mode 100644 index 0000000..16ead8a --- /dev/null +++ b/src/games/storywriter/utils/prompt.ts @@ -0,0 +1,30 @@ +import LLM from "./llm"; +import type { AppState } from "../contexts/state"; + +namespace Prompt { + export function compilePrompt(state: AppState, newMessage?: LLM.ChatMessage): LLM.ChatCompletionRequest | null { + const { currentStory, model } = state; + + if (!currentStory || !model) { + return null; + } + + const messages: LLM.ChatMessage[] = [ + // TODO system prompt + // TODO part of story + ...currentStory.chatMessages, + ]; + + if (newMessage) { + messages.push(newMessage); + } + + return { + model, + messages, + // TODO banned_tokens + }; + } +} + +export default Prompt;