1
0
Fork 0
This commit is contained in:
Pabloader 2026-11-03 07:36:12 +00:00
parent 97533daa91
commit a6a189de1d
6 changed files with 290 additions and 158 deletions

View File

@ -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>];
};

View File

@ -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]);
}
interface RoleHeaderProps {
message: ChatMessage;
chatMessages: ChatMessage[];
override render() {
return <Render {...this.props} />;
}
};
}
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 (
<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();
}
}}
/>
</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>
<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}
/>
);
})}
{error && (

View File

@ -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) {

View File

@ -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]);

View File

@ -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);

View File

@ -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!`