From a6a189de1dfd64bbe8f4f0f4ed880bf67e70eec0 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Tue, 3 Nov 2026 07:36:12 +0000 Subject: [PATCH] Optimize --- src/common/hooks/useRemote.ts | 11 +- .../storywriter/components/chat-sidebar.tsx | 391 +++++++++++------- src/games/storywriter/components/editor.tsx | 3 +- src/games/storywriter/contexts/state.tsx | 8 +- src/games/storywriter/utils/prompt.ts | 33 +- src/games/storywriter/utils/tools.ts | 2 +- 6 files changed, 290 insertions(+), 158 deletions(-) diff --git a/src/common/hooks/useRemote.ts b/src/common/hooks/useRemote.ts index 12879d1..6b9f606 100644 --- a/src/common/hooks/useRemote.ts +++ b/src/common/hooks/useRemote.ts @@ -52,8 +52,14 @@ export const importStoredReducer = (key: string): boolean => { return true; }; -export const useRemoteReducer = (key: string, reducer: Reducer, initialValue: T) => { +export interface RemoteReducerOptions { + /** Skip persisting changes while true; the latest state is saved when it turns false again. */ + paused?: boolean; +} + +export const useRemoteReducer = (key: string, reducer: Reducer, initialValue: T, options: RemoteReducerOptions = {}) => { const storedKey = remoteReducerKey(key); + const paused = options.paused ?? false; const getInitialValue = (): T => { try { @@ -73,8 +79,9 @@ export const useRemoteReducer = (key: string, reducer: Reducer, init }, [storedKey]); useEffect(() => { + if (paused) return; saveObject(storedKey, state); - }, [storedKey, state]); + }, [storedKey, state, paused]); return [state, dispatch] as [T, Dispatch]; }; diff --git a/src/games/storywriter/components/chat-sidebar.tsx b/src/games/storywriter/components/chat-sidebar.tsx index 7c0e1c6..e7488fa 100644 --- a/src/games/storywriter/components/chat-sidebar.tsx +++ b/src/games/storywriter/components/chat-sidebar.tsx @@ -4,6 +4,8 @@ import { useInputState } from "@common/hooks/useInputState"; import { useMediaQuery } from "@common/hooks/useMediaQuery"; import clsx from "clsx"; import { Check, ChevronsRight, Edit2, GitFork, RefreshCw, SendHorizonal, Sparkles, Square, Trash2, X } from "lucide-preact"; +import { Component } from "preact"; +import type { ComponentType } from "preact"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { MOBILE_BREAKPOINT } from '../assets/breakpoints'; import styles from '../assets/chat-sidebar.module.css'; @@ -15,55 +17,193 @@ import { Tools } from "../utils/tools"; import { useChapterSummarization } from "../utils/useChapterSummarization"; import { Sidebar } from "./sidebar"; +/** Minimal `memo` for preact core (avoids pulling in the whole `preact/compat` layer). */ +function memo

(Render: ComponentType

) { + return class Memoized extends Component

{ + override shouldComponentUpdate(nextProps: P) { + const prevProps = this.props as Record; + const next = nextProps as Record; + const prevKeys = Object.keys(prevProps); + const nextKeys = Object.keys(next); + if (prevKeys.length !== nextKeys.length) return true; + return nextKeys.some(key => prevProps[key] !== next[key]); + } -interface RoleHeaderProps { - message: ChatMessage; - chatMessages: ChatMessage[]; + override render() { + return ; + } + }; } -const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => { - const { currentWorld, userName } = useAppState(); - - const toolName = useMemo(() => { - if (message.role !== 'tool') return; - for (const m of chatMessages.toReversed()) { - if (m.role !== 'assistant') continue; - const toolCall = m.tool_calls?.find(tc => tc.id === message.tool_call_id); - if (toolCall) return toolCall.function.name; - } - }, [message, chatMessages]); - - let displayName: string = message.role; - let roleLabel = null; - - if (message.role === 'tool' && toolName) { - displayName = toolName; - roleLabel = message.role; - } else if (currentWorld?.chatOnly) { - if (message.role === 'user' && userName) { - displayName = userName; - roleLabel = message.role; - } else if (message.role === 'assistant' && currentWorld.title) { - displayName = currentWorld.title; - roleLabel = message.role; - } - } +interface RoleHeaderProps { + displayName: string; + roleLabel: string | null; +} +const RoleHeader = ({ displayName, roleLabel }: RoleHeaderProps) => { return (

{displayName} {roleLabel && ( - {message.role} + {roleLabel} )}
); }; +interface MessageRowProps { + message: ChatMessage; + displayName: string; + roleLabel: string | null; + isEditing: boolean; + isLoading: boolean; + chatOnly: boolean; + substitution: Prompt.SubstitutionVars; + editingContent: string; + onEditInput: (e: Event | string) => void; + onStartEdit: (message: ChatMessage) => void; + onCancelEdit: () => void; + onSaveEdit: (messageId: string, content: string) => void; + onDelete: (messageId: string) => void; + onFork: (messageId: string) => void; +} + +const MessageRow = memo(function MessageRow({ + message, + displayName, + roleLabel, + isEditing, + isLoading, + chatOnly, + substitution, + editingContent, + onEditInput, + onStartEdit, + onCancelEdit, + onSaveEdit, + onDelete, + onFork, +}: MessageRowProps) { + const canEdit = message.role === 'user' || message.role === 'assistant'; + + // Markdown highlighting is expensive; only redo it when this message (or the substitution vars) change. + const contentHtml = useMemo( + () => highlight(Prompt.substitute(message.content, substitution), false).trim(), + [message.content, substitution], + ); + + return ( +
+
+ + + {!isLoading && canEdit && ( +
+ {isEditing ? ( + <> + + + + ) : ( + <> + {chatOnly && ( + + )} + + + + )} +
+ )} +
+ + {isEditing ? ( +
+ { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); + onSaveEdit(message.id, editingContent); + } + if (e.key === 'Escape') { + e.preventDefault(); + onCancelEdit(); + } + }} + /> +
+ ) : ( + <> + {message.role === 'assistant' && message.reasoning_content?.trim() && ( +
+ {message.reasoning_content.trim()} +
+ )} + +
+ + {message.role === 'assistant' && message.tool_calls && ( +
+ {message.tool_calls.map((tool) => ( + + {tool.function.name} + + ))} +
+ )} + + )} +
+ ); +}); + export const ChatPanel = ({ visible }: { visible: boolean }) => { const appState = useAppState(); - const { currentWorld, currentStory, dispatch, connection, model, enableThinking, chatOpen, continuePrompt } = appState; + const { + currentWorld, currentStory, dispatch, connection, model, enableThinking, + chatOpen, continuePrompt, userName, chatSystemInstruction, setSavingPaused, + } = appState; const { summarizeAll, isSummarizing } = useChapterSummarization(); const [input, setInput] = useInputState(''); const [isLoading, setIsLoading] = useState(false); @@ -82,6 +222,25 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => { const lastMessage = currentStory?.chatMessages.at(-1); + // Stable values for the memoized message rows: they must not change on every streamed chunk. + const substitution = useMemo( + () => Prompt.getSubstitutionVars(appState), + // eslint-disable-next-line react-hooks/exhaustive-deps -- exactly the fields read by getSubstitutionVars + [currentWorld?.title, userName, chatSystemInstruction], + ); + + // Resolve tool call ids once per message list change instead of scanning the whole history per message. + const toolNames = useMemo(() => { + const names = new Map(); + for (const message of currentStory?.chatMessages ?? []) { + if (message.role !== 'assistant') continue; + for (const toolCall of message.tool_calls ?? []) { + names.set(toolCall.id, toolCall.function.name); + } + } + return names; + }, [currentStory?.chatMessages]); + useEffect(() => { if (messagesRef.current) { messagesRef.current.scrollTo({ @@ -254,7 +413,6 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => { }); if (tool_calls) { - console.log(tool_calls); const toolMessages: ChatMessage[] = []; for (const tool of tool_calls) { if (abortControllerRef.current?.signal.aborted) { @@ -305,6 +463,8 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => { setIsLoading(true); setError(null); abortControllerRef.current = new AbortController(); + // Don't persist on every streamed chunk — the final state is saved when this finishes. + setSavingPaused(true); const excludedMessageIds = new Set(); try { @@ -320,9 +480,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => { } await sendMessage({ excludedMessageIds }); } finally { + setSavingPaused(false); setIsLoading(false); } - }, [currentStory, currentWorld, connection, model, isLoading, sendMessage]); + }, [currentStory, currentWorld, connection, model, isLoading, sendMessage, setSavingPaused]); const handleSendMessage = useCallback(async () => { if (!currentStory || !connection || !model || isLoading) return; @@ -338,6 +499,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => { setIsLoading(true); setError(null); abortControllerRef.current = new AbortController(); + setSavingPaused(true); try { await sendMessage({ @@ -348,9 +510,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => { }] }); } finally { + setSavingPaused(false); setIsLoading(false); } - }, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate]); + }, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate, setSavingPaused]); const handleContinue = useCallback(async () => { if (!currentStory || !connection || !model || isLoading) return; @@ -359,6 +522,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => { setIsLoading(true); setError(null); abortControllerRef.current = new AbortController(); + setSavingPaused(true); try { if (currentWorld?.chatOnly) { @@ -373,9 +537,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => { }); } } finally { + setSavingPaused(false); setIsLoading(false); } - }, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage]); + }, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage, setSavingPaused]); const handleStopGeneration = useCallback(() => { abortControllerRef.current?.abort(); @@ -390,6 +555,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => { }; const handleDeleteMessage = useCallback((messageId: string) => { + const { currentWorld, currentStory } = appStateRef.current; if (!currentStory || !currentWorld) return; if (!confirm('Delete this and all following messages?')) return; dispatch({ @@ -398,9 +564,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => { storyId: currentStory.id, messageId, }); - }, [currentStory, currentWorld, dispatch]); + }, [dispatch]); const handleForkChat = useCallback((messageId: string) => { + const { currentWorld, currentStory } = appStateRef.current; if (!currentStory || !currentWorld) return; dispatch({ type: 'DUPLICATE_STORY', @@ -408,27 +575,28 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => { id: currentStory.id, upToMessageId: messageId, }); - }, [currentStory, currentWorld, dispatch]); + }, [dispatch]); const handleStartEdit = useCallback((message: ChatMessage) => { setEditingMessageId(message.id); setEditingContent(message.content); }, [setEditingContent]); - const handleSaveEdit = useCallback(() => { - if (!currentStory || !currentWorld || !editingMessageId) return; + const handleSaveEdit = useCallback((messageId: string, content: string) => { + const { currentWorld, currentStory } = appStateRef.current; + if (!currentStory || !currentWorld || !content.trim()) return; dispatch({ type: 'EDIT_CHAT_MESSAGE', worldId: currentWorld.id, storyId: currentStory.id, - messageId: editingMessageId, - content: editingContent.trim(), + messageId, + content: content.trim(), }); setEditingMessageId(null); setEditingContent(''); - }, [currentStory, currentWorld, editingMessageId, editingContent, dispatch, setEditingContent]); + }, [dispatch, setEditingContent]); const handleCancelEdit = useCallback(() => { setEditingMessageId(null); @@ -457,109 +625,44 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
{currentStory.chatMessages.map((message) => { const isEditing = editingMessageId === message.id; - const canEdit = message.role === 'user' || message.role === 'assistant'; + const toolName = message.role === 'tool' && message.tool_call_id + ? toolNames.get(message.tool_call_id) + : undefined; + + let displayName: string = message.role; + let roleLabel: string | null = null; + + if (message.role === 'tool' && toolName) { + displayName = toolName; + roleLabel = message.role; + } else if (currentWorld?.chatOnly) { + if (message.role === 'user' && userName) { + displayName = userName; + roleLabel = message.role; + } else if (message.role === 'assistant' && currentWorld.title) { + displayName = currentWorld.title; + roleLabel = message.role; + } + } return ( -
-
- - - {!isLoading && canEdit && ( -
- {isEditing ? ( - <> - - - - ) : ( - <> - {currentWorld?.chatOnly && ( - - )} - - - - )} -
- )} -
- - {isEditing ? ( -
- { - if (e.key === 'Enter' && e.ctrlKey) { - e.preventDefault(); - handleSaveEdit(); - } - if (e.key === 'Escape') { - e.preventDefault(); - handleCancelEdit(); - } - }} - /> -
- ) : ( - <> - {message.role === 'assistant' && message.reasoning_content?.trim() && ( -
- {message.reasoning_content.trim()} -
- )} - -
- - {message.role === 'assistant' && message.tool_calls && ( -
- {message.tool_calls.map((tool) => ( - - {tool.function.name} - - ))} -
- )} - - )} -
+ ); })} {error && ( diff --git a/src/games/storywriter/components/editor.tsx b/src/games/storywriter/components/editor.tsx index de346ca..581c0b8 100644 --- a/src/games/storywriter/components/editor.tsx +++ b/src/games/storywriter/components/editor.tsx @@ -67,10 +67,11 @@ export const Editor = () => { const editorRef = useRef(null); const promptPreview = useMemo(() => { + if (currentTab !== 'prompt') return ''; let text = Prompt.formatSystemPrompt(appState); text = Prompt.substituteVars(appState, text); return highlight(text, false); - }, [appState]); + }, [appState, currentTab]); useEffect(() => { if (currentStory?.lastEditedText) { diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index ac055a2..13b1ca4 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -1,6 +1,6 @@ import { useRemoteReducer } from "@common/hooks/useRemote"; import { createContext } from "preact"; -import { useContext, useMemo } from "preact/hooks"; +import { useContext, useMemo, useState } from "preact/hooks"; import Chapters from "../utils/chapters"; import LLM from "../utils/llm"; @@ -686,6 +686,8 @@ export interface AppState { imageGenerationSettings: ImageGenerationSettings; /** Effective system instruction: world override if set, otherwise global */ effectiveSystemInstruction: string; + /** Pause persistence (used while streaming; the latest state is saved once streaming ends) */ + setSavingPaused: (paused: boolean) => void; dispatch: (action: Action) => void; } @@ -698,7 +700,8 @@ export const useAppState = () => useContext(StateContext); export const STATE_KEY = 'state'; export const StateContextProvider = ({ children }: { children?: any }) => { - const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE); + const [savingPaused, setSavingPaused] = useState(false); + const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE, { paused: savingPaused }); const value = useMemo(() => { const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null; @@ -748,6 +751,7 @@ export const StateContextProvider = ({ children }: { children?: any }) => { ? chatSystemInstruction : systemInstruction) || systemInstruction, + setSavingPaused, dispatch, }; }, [state]); diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index 22c2b64..74538a9 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -299,13 +299,30 @@ namespace Prompt { }); } - export function substituteVars(state: AppState, text: string): string { - const charName = state.currentWorld?.title || 'Assistant'; - const userName = state.userName || 'User'; + /** Values used by {@link substituteVars}, extracted so they can be memoized independently of the full app state. */ + export interface SubstitutionVars { + charName: string; + userName: string; + chatSystemInstruction: string; + } + + export function getSubstitutionVars(state: AppState): SubstitutionVars { + return { + charName: state.currentWorld?.title || 'Assistant', + userName: state.userName || 'User', + chatSystemInstruction: state.chatSystemInstruction, + }; + } + + export function substitute(text: string, vars: SubstitutionVars): string { return text - .replaceAll('{{system}}', state.chatSystemInstruction) - .replaceAll('{{char}}', charName) - .replaceAll('{{user}}', userName); + .replaceAll('{{system}}', vars.chatSystemInstruction) + .replaceAll('{{char}}', vars.charName) + .replaceAll('{{user}}', vars.userName); + } + + export function substituteVars(state: AppState, text: string): string { + return substitute(text, getSubstitutionVars(state)); } export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string { @@ -465,13 +482,13 @@ namespace Prompt { export function applyDelta(input: any, delta: any) { if (Array.isArray(delta)) { - const result = [...(input ?? [])]; + const result = input ?? []; for (const item of delta) { result[item.index] = applyDelta(result[item.index], item); } return result; } else if (typeof delta === 'object') { - const result = { ...input }; + const result = input ?? {}; for (const key of Object.keys(delta)) { const value = delta[key]; result[key] = applyDelta(result[key], value); diff --git a/src/games/storywriter/utils/tools.ts b/src/games/storywriter/utils/tools.ts index ab99b2a..0a62746 100644 --- a/src/games/storywriter/utils/tools.ts +++ b/src/games/storywriter/utils/tools.ts @@ -377,7 +377,7 @@ export namespace Tools { cropped = true; } - dispatchEdit(currentText.trim() + '\n' + args.new_text.trim()); + dispatchEdit(currentText.trim() + '\n\n' + args.new_text.trim()); appState.dispatch({ type: 'SET_CURRENT_TAB', tab }); let message = cropped ? `Added text:\n${args.new_text.split('\n').filter(l => l.trim()).map(l => '> ' + l).join('\n')}\n\nNote: The rest was cropped due to ${LINES_LIMIT} lines limit!`