Optimize
This commit is contained in:
parent
97533daa91
commit
a6a189de1d
|
|
@ -52,8 +52,14 @@ export const importStoredReducer = <T>(key: string): boolean => {
|
||||||
return true;
|
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 storedKey = remoteReducerKey(key);
|
||||||
|
const paused = options.paused ?? false;
|
||||||
|
|
||||||
const getInitialValue = (): T => {
|
const getInitialValue = (): T => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -73,8 +79,9 @@ export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, init
|
||||||
}, [storedKey]);
|
}, [storedKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (paused) return;
|
||||||
saveObject(storedKey, state);
|
saveObject(storedKey, state);
|
||||||
}, [storedKey, state]);
|
}, [storedKey, state, paused]);
|
||||||
|
|
||||||
return [state, dispatch] as [T, Dispatch<A>];
|
return [state, dispatch] as [T, Dispatch<A>];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { useInputState } from "@common/hooks/useInputState";
|
||||||
import { useMediaQuery } from "@common/hooks/useMediaQuery";
|
import { useMediaQuery } from "@common/hooks/useMediaQuery";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Check, ChevronsRight, Edit2, GitFork, RefreshCw, SendHorizonal, Sparkles, Square, Trash2, X } from "lucide-preact";
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import { MOBILE_BREAKPOINT } from '../assets/breakpoints';
|
import { MOBILE_BREAKPOINT } from '../assets/breakpoints';
|
||||||
import styles from '../assets/chat-sidebar.module.css';
|
import styles from '../assets/chat-sidebar.module.css';
|
||||||
|
|
@ -15,55 +17,193 @@ import { Tools } from "../utils/tools";
|
||||||
import { useChapterSummarization } from "../utils/useChapterSummarization";
|
import { useChapterSummarization } from "../utils/useChapterSummarization";
|
||||||
import { Sidebar } from "./sidebar";
|
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 {
|
override render() {
|
||||||
message: ChatMessage;
|
return <Render {...this.props} />;
|
||||||
chatMessages: ChatMessage[];
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => {
|
interface RoleHeaderProps {
|
||||||
const { currentWorld, userName } = useAppState();
|
displayName: string;
|
||||||
|
roleLabel: string | null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const RoleHeader = ({ displayName, roleLabel }: RoleHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<div class={styles.role}>
|
<div class={styles.role}>
|
||||||
<span>{displayName}</span>
|
<span>{displayName}</span>
|
||||||
{roleLabel && (
|
{roleLabel && (
|
||||||
<span class={styles.toolBadge}>
|
<span class={styles.toolBadge}>
|
||||||
{message.role}
|
{roleLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 }) => {
|
export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
const appState = useAppState();
|
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 { summarizeAll, isSummarizing } = useChapterSummarization();
|
||||||
const [input, setInput] = useInputState('');
|
const [input, setInput] = useInputState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
@ -82,6 +222,25 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
|
|
||||||
const lastMessage = currentStory?.chatMessages.at(-1);
|
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(() => {
|
useEffect(() => {
|
||||||
if (messagesRef.current) {
|
if (messagesRef.current) {
|
||||||
messagesRef.current.scrollTo({
|
messagesRef.current.scrollTo({
|
||||||
|
|
@ -254,7 +413,6 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tool_calls) {
|
if (tool_calls) {
|
||||||
console.log(tool_calls);
|
|
||||||
const toolMessages: ChatMessage[] = [];
|
const toolMessages: ChatMessage[] = [];
|
||||||
for (const tool of tool_calls) {
|
for (const tool of tool_calls) {
|
||||||
if (abortControllerRef.current?.signal.aborted) {
|
if (abortControllerRef.current?.signal.aborted) {
|
||||||
|
|
@ -305,6 +463,8 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
abortControllerRef.current = new AbortController();
|
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>();
|
const excludedMessageIds = new Set<string>();
|
||||||
try {
|
try {
|
||||||
|
|
@ -320,9 +480,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
}
|
}
|
||||||
await sendMessage({ excludedMessageIds });
|
await sendMessage({ excludedMessageIds });
|
||||||
} finally {
|
} finally {
|
||||||
|
setSavingPaused(false);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage]);
|
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage, setSavingPaused]);
|
||||||
|
|
||||||
const handleSendMessage = useCallback(async () => {
|
const handleSendMessage = useCallback(async () => {
|
||||||
if (!currentStory || !connection || !model || isLoading) return;
|
if (!currentStory || !connection || !model || isLoading) return;
|
||||||
|
|
@ -338,6 +499,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
|
setSavingPaused(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
|
|
@ -348,9 +510,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
setSavingPaused(false);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate]);
|
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate, setSavingPaused]);
|
||||||
|
|
||||||
const handleContinue = useCallback(async () => {
|
const handleContinue = useCallback(async () => {
|
||||||
if (!currentStory || !connection || !model || isLoading) return;
|
if (!currentStory || !connection || !model || isLoading) return;
|
||||||
|
|
@ -359,6 +522,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
|
setSavingPaused(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentWorld?.chatOnly) {
|
if (currentWorld?.chatOnly) {
|
||||||
|
|
@ -373,9 +537,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
setSavingPaused(false);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage]);
|
}, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage, setSavingPaused]);
|
||||||
|
|
||||||
const handleStopGeneration = useCallback(() => {
|
const handleStopGeneration = useCallback(() => {
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
|
|
@ -390,6 +555,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteMessage = useCallback((messageId: string) => {
|
const handleDeleteMessage = useCallback((messageId: string) => {
|
||||||
|
const { currentWorld, currentStory } = appStateRef.current;
|
||||||
if (!currentStory || !currentWorld) return;
|
if (!currentStory || !currentWorld) return;
|
||||||
if (!confirm('Delete this and all following messages?')) return;
|
if (!confirm('Delete this and all following messages?')) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
@ -398,9 +564,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
messageId,
|
messageId,
|
||||||
});
|
});
|
||||||
}, [currentStory, currentWorld, dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleForkChat = useCallback((messageId: string) => {
|
const handleForkChat = useCallback((messageId: string) => {
|
||||||
|
const { currentWorld, currentStory } = appStateRef.current;
|
||||||
if (!currentStory || !currentWorld) return;
|
if (!currentStory || !currentWorld) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'DUPLICATE_STORY',
|
type: 'DUPLICATE_STORY',
|
||||||
|
|
@ -408,27 +575,28 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
id: currentStory.id,
|
id: currentStory.id,
|
||||||
upToMessageId: messageId,
|
upToMessageId: messageId,
|
||||||
});
|
});
|
||||||
}, [currentStory, currentWorld, dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleStartEdit = useCallback((message: ChatMessage) => {
|
const handleStartEdit = useCallback((message: ChatMessage) => {
|
||||||
setEditingMessageId(message.id);
|
setEditingMessageId(message.id);
|
||||||
setEditingContent(message.content);
|
setEditingContent(message.content);
|
||||||
}, [setEditingContent]);
|
}, [setEditingContent]);
|
||||||
|
|
||||||
const handleSaveEdit = useCallback(() => {
|
const handleSaveEdit = useCallback((messageId: string, content: string) => {
|
||||||
if (!currentStory || !currentWorld || !editingMessageId) return;
|
const { currentWorld, currentStory } = appStateRef.current;
|
||||||
|
if (!currentStory || !currentWorld || !content.trim()) return;
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'EDIT_CHAT_MESSAGE',
|
type: 'EDIT_CHAT_MESSAGE',
|
||||||
worldId: currentWorld.id,
|
worldId: currentWorld.id,
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
messageId: editingMessageId,
|
messageId,
|
||||||
content: editingContent.trim(),
|
content: content.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
setEditingMessageId(null);
|
setEditingMessageId(null);
|
||||||
setEditingContent('');
|
setEditingContent('');
|
||||||
}, [currentStory, currentWorld, editingMessageId, editingContent, dispatch, setEditingContent]);
|
}, [dispatch, setEditingContent]);
|
||||||
|
|
||||||
const handleCancelEdit = useCallback(() => {
|
const handleCancelEdit = useCallback(() => {
|
||||||
setEditingMessageId(null);
|
setEditingMessageId(null);
|
||||||
|
|
@ -457,109 +625,44 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
<div class={styles.messages} ref={messagesRef}>
|
<div class={styles.messages} ref={messagesRef}>
|
||||||
{currentStory.chatMessages.map((message) => {
|
{currentStory.chatMessages.map((message) => {
|
||||||
const isEditing = editingMessageId === message.id;
|
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 (
|
return (
|
||||||
<div key={message.id} class={styles.message} data-role={message.role}>
|
<MessageRow
|
||||||
<div class={styles.messageHeader}>
|
key={message.id}
|
||||||
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
|
message={message}
|
||||||
|
displayName={displayName}
|
||||||
{!isLoading && canEdit && (
|
roleLabel={roleLabel}
|
||||||
<div class={styles.messageActions}>
|
isEditing={isEditing}
|
||||||
{isEditing ? (
|
isLoading={isLoading}
|
||||||
<>
|
chatOnly={currentWorld?.chatOnly ?? false}
|
||||||
<button
|
substitution={substitution}
|
||||||
class={clsx(styles.iconButton, styles.saveButton)}
|
editingContent={isEditing ? editingContent : ''}
|
||||||
onClick={handleSaveEdit}
|
onEditInput={setEditingContent}
|
||||||
disabled={!editingContent.trim()}
|
onStartEdit={handleStartEdit}
|
||||||
title="Save (Ctrl+Enter)"
|
onCancelEdit={handleCancelEdit}
|
||||||
>
|
onSaveEdit={handleSaveEdit}
|
||||||
<Check size={12} />
|
onDelete={handleDeleteMessage}
|
||||||
</button>
|
onFork={handleForkChat}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -67,10 +67,11 @@ export const Editor = () => {
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const promptPreview = useMemo(() => {
|
const promptPreview = useMemo(() => {
|
||||||
|
if (currentTab !== 'prompt') return '';
|
||||||
let text = Prompt.formatSystemPrompt(appState);
|
let text = Prompt.formatSystemPrompt(appState);
|
||||||
text = Prompt.substituteVars(appState, text);
|
text = Prompt.substituteVars(appState, text);
|
||||||
return highlight(text, false);
|
return highlight(text, false);
|
||||||
}, [appState]);
|
}, [appState, currentTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStory?.lastEditedText) {
|
if (currentStory?.lastEditedText) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useRemoteReducer } from "@common/hooks/useRemote";
|
import { useRemoteReducer } from "@common/hooks/useRemote";
|
||||||
import { createContext } from "preact";
|
import { createContext } from "preact";
|
||||||
import { useContext, useMemo } from "preact/hooks";
|
import { useContext, useMemo, useState } from "preact/hooks";
|
||||||
import Chapters from "../utils/chapters";
|
import Chapters from "../utils/chapters";
|
||||||
import LLM from "../utils/llm";
|
import LLM from "../utils/llm";
|
||||||
|
|
||||||
|
|
@ -686,6 +686,8 @@ export interface AppState {
|
||||||
imageGenerationSettings: ImageGenerationSettings;
|
imageGenerationSettings: ImageGenerationSettings;
|
||||||
/** Effective system instruction: world override if set, otherwise global */
|
/** Effective system instruction: world override if set, otherwise global */
|
||||||
effectiveSystemInstruction: string;
|
effectiveSystemInstruction: string;
|
||||||
|
/** Pause persistence (used while streaming; the latest state is saved once streaming ends) */
|
||||||
|
setSavingPaused: (paused: boolean) => void;
|
||||||
dispatch: (action: Action) => void;
|
dispatch: (action: Action) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -698,7 +700,8 @@ export const useAppState = () => useContext(StateContext);
|
||||||
export const STATE_KEY = 'state';
|
export const STATE_KEY = 'state';
|
||||||
|
|
||||||
export const StateContextProvider = ({ children }: { children?: any }) => {
|
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 value = useMemo<AppState>(() => {
|
||||||
const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;
|
const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;
|
||||||
|
|
@ -748,6 +751,7 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
|
||||||
? chatSystemInstruction
|
? chatSystemInstruction
|
||||||
: systemInstruction)
|
: systemInstruction)
|
||||||
|| systemInstruction,
|
|| systemInstruction,
|
||||||
|
setSavingPaused,
|
||||||
dispatch,
|
dispatch,
|
||||||
};
|
};
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
|
||||||
|
|
@ -299,13 +299,30 @@ namespace Prompt {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function substituteVars(state: AppState, text: string): string {
|
/** Values used by {@link substituteVars}, extracted so they can be memoized independently of the full app state. */
|
||||||
const charName = state.currentWorld?.title || 'Assistant';
|
export interface SubstitutionVars {
|
||||||
const userName = state.userName || 'User';
|
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
|
return text
|
||||||
.replaceAll('{{system}}', state.chatSystemInstruction)
|
.replaceAll('{{system}}', vars.chatSystemInstruction)
|
||||||
.replaceAll('{{char}}', charName)
|
.replaceAll('{{char}}', vars.charName)
|
||||||
.replaceAll('{{user}}', userName);
|
.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 {
|
export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string {
|
||||||
|
|
@ -465,13 +482,13 @@ namespace Prompt {
|
||||||
|
|
||||||
export function applyDelta(input: any, delta: any) {
|
export function applyDelta(input: any, delta: any) {
|
||||||
if (Array.isArray(delta)) {
|
if (Array.isArray(delta)) {
|
||||||
const result = [...(input ?? [])];
|
const result = input ?? [];
|
||||||
for (const item of delta) {
|
for (const item of delta) {
|
||||||
result[item.index] = applyDelta(result[item.index], item);
|
result[item.index] = applyDelta(result[item.index], item);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} else if (typeof delta === 'object') {
|
} else if (typeof delta === 'object') {
|
||||||
const result = { ...input };
|
const result = input ?? {};
|
||||||
for (const key of Object.keys(delta)) {
|
for (const key of Object.keys(delta)) {
|
||||||
const value = delta[key];
|
const value = delta[key];
|
||||||
result[key] = applyDelta(result[key], value);
|
result[key] = applyDelta(result[key], value);
|
||||||
|
|
|
||||||
|
|
@ -377,7 +377,7 @@ export namespace Tools {
|
||||||
cropped = true;
|
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 });
|
appState.dispatch({ type: 'SET_CURRENT_TAB', tab });
|
||||||
let message = cropped
|
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!`
|
? `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