Optimize
This commit is contained in:
parent
97533daa91
commit
a6a189de1d
|
|
@ -52,8 +52,14 @@ export const importStoredReducer = <T>(key: string): boolean => {
|
|||
return true;
|
||||
};
|
||||
|
||||
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, 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 = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T, options: RemoteReducerOptions = {}) => {
|
||||
const storedKey = remoteReducerKey(key);
|
||||
const paused = options.paused ?? false;
|
||||
|
||||
const getInitialValue = (): T => {
|
||||
try {
|
||||
|
|
@ -73,8 +79,9 @@ export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, init
|
|||
}, [storedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
saveObject(storedKey, state);
|
||||
}, [storedKey, state]);
|
||||
}, [storedKey, state, paused]);
|
||||
|
||||
return [state, dispatch] as [T, Dispatch<A>];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<P extends object>(Render: ComponentType<P>) {
|
||||
return class Memoized extends Component<P> {
|
||||
override shouldComponentUpdate(nextProps: P) {
|
||||
const prevProps = this.props as Record<string, unknown>;
|
||||
const next = nextProps as Record<string, unknown>;
|
||||
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]);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return <Render {...this.props} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface RoleHeaderProps {
|
||||
message: ChatMessage;
|
||||
chatMessages: ChatMessage[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
displayName: string;
|
||||
roleLabel: string | null;
|
||||
}
|
||||
|
||||
const RoleHeader = ({ displayName, roleLabel }: RoleHeaderProps) => {
|
||||
return (
|
||||
<div class={styles.role}>
|
||||
<span>{displayName}</span>
|
||||
{roleLabel && (
|
||||
<span class={styles.toolBadge}>
|
||||
{message.role}
|
||||
{roleLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div class={styles.message} data-role={message.role}>
|
||||
<div class={styles.messageHeader}>
|
||||
<RoleHeader displayName={displayName} roleLabel={roleLabel} />
|
||||
|
||||
{!isLoading && canEdit && (
|
||||
<div class={styles.messageActions}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
class={clsx(styles.iconButton, styles.saveButton)}
|
||||
onClick={() => onSaveEdit(message.id, editingContent)}
|
||||
disabled={!editingContent.trim()}
|
||||
title="Save (Ctrl+Enter)"
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
<button
|
||||
class={clsx(styles.iconButton, styles.cancelButton)}
|
||||
onClick={onCancelEdit}
|
||||
title="Cancel (Esc)"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{chatOnly && (
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => onFork(message.id)}
|
||||
title="Fork chat up to this message"
|
||||
>
|
||||
<GitFork size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => onStartEdit(message)}
|
||||
title="Edit message"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => onDelete(message.id)}
|
||||
title="Delete this and all following messages"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div class={styles.editContainer}>
|
||||
<ContentEditable
|
||||
autoLines
|
||||
highlight
|
||||
class={styles.editTextarea}
|
||||
value={editingContent}
|
||||
onInput={onEditInput}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
onSaveEdit(message.id, editingContent);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancelEdit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{message.role === 'assistant' && message.reasoning_content?.trim() && (
|
||||
<div class={styles.reasoningContent}>
|
||||
{message.reasoning_content.trim()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
class={styles.content}
|
||||
dangerouslySetInnerHTML={{ __html: contentHtml }}
|
||||
/>
|
||||
|
||||
{message.role === 'assistant' && message.tool_calls && (
|
||||
<div class={styles.toolCalls}>
|
||||
{message.tool_calls.map((tool) => (
|
||||
<span key={tool.id} class={styles.toolBadge}>
|
||||
{tool.function.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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<string, string>();
|
||||
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<string>();
|
||||
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 }) => {
|
|||
<div class={styles.messages} ref={messagesRef}>
|
||||
{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 (
|
||||
<div key={message.id} class={styles.message} data-role={message.role}>
|
||||
<div class={styles.messageHeader}>
|
||||
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
|
||||
|
||||
{!isLoading && canEdit && (
|
||||
<div class={styles.messageActions}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
class={clsx(styles.iconButton, styles.saveButton)}
|
||||
onClick={handleSaveEdit}
|
||||
disabled={!editingContent.trim()}
|
||||
title="Save (Ctrl+Enter)"
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
<button
|
||||
class={clsx(styles.iconButton, styles.cancelButton)}
|
||||
onClick={handleCancelEdit}
|
||||
title="Cancel (Esc)"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{currentWorld?.chatOnly && (
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => handleForkChat(message.id)}
|
||||
title="Fork chat up to this message"
|
||||
>
|
||||
<GitFork size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => handleStartEdit(message)}
|
||||
title="Edit message"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => handleDeleteMessage(message.id)}
|
||||
title="Delete this and all following messages"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div class={styles.editContainer}>
|
||||
<ContentEditable
|
||||
autoLines
|
||||
highlight
|
||||
class={styles.editTextarea}
|
||||
value={editingContent}
|
||||
onInput={setEditingContent}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
handleSaveEdit();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelEdit();
|
||||
}
|
||||
}}
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
displayName={displayName}
|
||||
roleLabel={roleLabel}
|
||||
isEditing={isEditing}
|
||||
isLoading={isLoading}
|
||||
chatOnly={currentWorld?.chatOnly ?? false}
|
||||
substitution={substitution}
|
||||
editingContent={isEditing ? editingContent : ''}
|
||||
onEditInput={setEditingContent}
|
||||
onStartEdit={handleStartEdit}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onDelete={handleDeleteMessage}
|
||||
onFork={handleForkChat}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{message.role === 'assistant' && message.reasoning_content?.trim() && (
|
||||
<div class={styles.reasoningContent}>
|
||||
{message.reasoning_content.trim()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
class={styles.content}
|
||||
dangerouslySetInnerHTML={{ __html: highlight(Prompt.substituteVars(appState, message.content), false).trim() }}
|
||||
/>
|
||||
|
||||
{message.role === 'assistant' && message.tool_calls && (
|
||||
<div class={styles.toolCalls}>
|
||||
{message.tool_calls.map((tool) => (
|
||||
<span key={tool.id} class={styles.toolBadge}>
|
||||
{tool.function.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -67,10 +67,11 @@ export const Editor = () => {
|
|||
const editorRef = useRef<HTMLDivElement>(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) {
|
||||
|
|
|
|||
|
|
@ -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<AppState>(() => {
|
||||
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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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!`
|
||||
|
|
|
|||
Loading…
Reference in New Issue