import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useInputState } from "@common/hooks/useInputState";
import clsx from "clsx";
import { Check, ChevronsRight, Edit2, RefreshCw, Sparkles, Trash2, X } from "lucide-preact";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import styles from '../assets/chat-sidebar.module.css';
import sidebarStyles from '../assets/sidebar.module.css';
import { useAppState, type ChatMessage } from "../contexts/state";
import LLM from "../utils/llm";
import Prompt from "../utils/prompt";
import { Tools } from "../utils/tools";
import { useChapterSummarization } from "../utils/useChapterSummarization";
import { Sidebar } from "./sidebar";
const CONTINUE_PROMPT = "Continue the story naturally.\nUse `edit_text` tool in append mode to add new text to the story.\nWait for the approval after adding.\nNote: added text could be cropped due to limit, do not make any attempts to add it back.";
interface RoleHeaderProps {
message: ChatMessage;
chatMessages: ChatMessage[];
}
const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => {
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]);
return (
{message.role}
{toolName && (
{toolName}
)}
);
};
export const ChatPanel = () => {
const appState = useAppState();
const { currentWorld, currentStory, dispatch, connection, model, enableThinking, chatOpen } = appState;
const { summarizeAll, isSummarizing } = useChapterSummarization();
const [input, setInput] = useInputState('');
const [isLoading, setIsLoading] = useState(false);
const [tokenCount, setTokenCount] = useState<{ taken: number; total: number } | null>(null);
const [error, setError] = useState(null);
const messagesRef = useRef(null);
const abortControllerRef = useRef(new AbortController());
// Edit state
const [editingMessageId, setEditingMessageId] = useState(null);
const [editingContent, setEditingContent] = useInputState('');
const appStateRef = useRef(appState);
appStateRef.current = appState;
const lastMessage = currentStory?.chatMessages.at(-1);
useEffect(() => {
if (messagesRef.current) {
messagesRef.current.scrollTo({
top: messagesRef.current.scrollHeight,
behavior: 'smooth',
});
}
}, [
currentStory?.chatMessages.length,
lastMessage?.content.length,
lastMessage?.role === 'assistant' && lastMessage?.reasoning_content?.length,
]);
useEffect(() => {
if (messagesRef.current) {
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
}
}, [chatOpen]);
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
useEffect(() => {
if (!currentStory || !connection || !model) {
setTokenCount(null);
return;
}
const countTokens = async () => {
try {
const messages: ChatMessage[] = [];
if (input.trim()) {
messages.push({
id: crypto.randomUUID(),
role: 'user',
content: input.trim(),
});
}
const chatRequest = Prompt.compilePrompt(appStateRef.current, messages);
const countRequest: LLM.CountTokensRequest = {
model: model.id,
input: chatRequest?.messages ?? [],
tools: chatRequest?.tools,
enable_thinking: chatRequest?.enable_thinking,
};
const response = await LLM.countTokens(connection, countRequest);
setTokenCount({
taken: response.input_tokens,
total: model.max_context ?? response.input_tokens,
});
} catch {
setTokenCount(null);
}
};
const timeoutId = setTimeout(countTokens, 300);
return () => clearTimeout(timeoutId);
}, [currentStory, connection, model, input, currentStory?.chatMessages.length]);
const sendMessage = useCallback(async (
newMessages: Iterable,
excludedMessageIds: string[] = [],
) => {
if (!currentStory || !currentWorld || !connection || !model) return;
for (const message of newMessages) {
if (excludedMessageIds.includes(message.id)) continue;
dispatch({
type: 'ADD_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id,
message,
});
}
const assistantMessageId = crypto.randomUUID();
dispatch({
type: 'ADD_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id,
message: {
id: assistantMessageId,
role: 'assistant',
content: '',
reasoning_content: 'Generating...',
},
});
const request = Prompt.compilePrompt(appStateRef.current, newMessages, excludedMessageIds);
if (!request) {
setError('Failed to compile prompt');
setIsLoading(false);
return;
}
try {
const charName = currentWorld.title ?? 'Assistant';
const prefix = `${charName}: `;
let accumulatedContent = '';
let accumulatedReasoning = '';
let tool_calls: LLM.ToolCall[] | undefined;
for await (const chunk of LLM.generateStream(connection, request)) {
if (abortControllerRef.current?.signal.aborted) {
break;
}
const delta = chunk.choices[0]?.delta;
if (delta?.tool_calls) {
tool_calls = delta.tool_calls;
}
const content = delta?.content;
if (content) {
accumulatedContent += content;
if (currentWorld?.chatOnly && accumulatedContent.trimStart().startsWith(prefix)) {
accumulatedContent = accumulatedContent.trimStart().slice(prefix.length).trimStart();
}
}
const reasoningContent = delta?.reasoning_content;
if (reasoningContent) {
accumulatedReasoning += reasoningContent;
}
if (content || reasoningContent) {
dispatch({
type: 'ADD_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id,
message: {
id: assistantMessageId,
role: 'assistant',
content: accumulatedContent,
reasoning_content: accumulatedReasoning,
tool_calls,
},
});
}
}
const assistantMessage: ChatMessage = {
id: assistantMessageId,
role: 'assistant',
content: accumulatedContent,
reasoning_content: accumulatedReasoning,
tool_calls,
};
dispatch({
type: 'ADD_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id,
message: assistantMessage,
});
if (tool_calls) {
const toolMessages: ChatMessage[] = [];
for (const tool of tool_calls) {
if (abortControllerRef.current?.signal.aborted) {
break;
}
const content = await Tools.executeTool(appStateRef.current, tool);
const message: ChatMessage = {
id: crypto.randomUUID(),
role: 'tool',
content,
tool_call_id: tool.id,
};
dispatch({
type: 'ADD_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id,
message,
});
toolMessages.push(message);
}
if (!abortControllerRef.current?.signal.aborted) {
return sendMessage([...newMessages, assistantMessage, ...toolMessages]);
}
}
} catch (err) {
const errorMessage = err instanceof Error
? err.message
: 'Failed to generate response';
setError(errorMessage);
}
}, [currentStory, connection, model]);
const handleRegenerate = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return;
// Only regenerate if last message is assistant
const lastMessage = currentStory.chatMessages.at(-1);
const isAssistant = lastMessage?.role === 'assistant';
setIsLoading(true);
setError(null);
abortControllerRef.current = new AbortController();
const excludedMessages: string[] = [];
try {
if (isAssistant) {
// Delete the last assistant message and regenerate
dispatch({
type: 'DELETE_CHAT_MESSAGES_FROM',
worldId: currentWorld!.id,
storyId: currentStory.id,
messageId: lastMessage.id,
});
excludedMessages.push(lastMessage.id);
}
await sendMessage([], excludedMessages);
} finally {
setIsLoading(false);
}
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage]);
const handleSendMessage = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return;
if (!input.trim()) {
if (lastMessage?.role === 'user') {
handleRegenerate();
}
return;
}
setInput('');
setIsLoading(true);
setError(null);
abortControllerRef.current = new AbortController();
try {
await sendMessage([{
id: crypto.randomUUID(),
role: 'user' as const,
content: input.trim(),
}]);
} finally {
setIsLoading(false);
}
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate]);
const handleContinue = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return;
setInput('');
setIsLoading(true);
setError(null);
abortControllerRef.current = new AbortController();
try {
await sendMessage([{
id: crypto.randomUUID(),
role: 'user' as const,
content: (CONTINUE_PROMPT + '\n\n' + input).trim(),
}]);
} finally {
setIsLoading(false);
}
}, [currentStory, input, connection, model, isLoading, sendMessage]);
const handleStopGeneration = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
}, []);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const handleClear = () => {
if (!currentStory || !currentWorld) return;
dispatch({
type: 'CLEAR_CHAT',
worldId: currentWorld.id,
storyId: currentStory.id,
});
};
const handleDeleteMessage = useCallback((messageId: string) => {
if (!currentStory || !currentWorld) return;
dispatch({
type: 'DELETE_CHAT_MESSAGES_FROM',
worldId: currentWorld.id,
storyId: currentStory.id,
messageId,
});
}, [currentStory, currentWorld, dispatch]);
const handleStartEdit = useCallback((message: ChatMessage) => {
setEditingMessageId(message.id);
setEditingContent(message.content);
}, [setEditingContent]);
const handleSaveEdit = useCallback(() => {
if (!currentStory || !currentWorld || !editingMessageId) return;
dispatch({
type: 'EDIT_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id,
messageId: editingMessageId,
content: editingContent.trim(),
});
setEditingMessageId(null);
setEditingContent('');
}, [currentStory, currentWorld, editingMessageId, editingContent, dispatch, setEditingContent]);
const handleCancelEdit = useCallback(() => {
setEditingMessageId(null);
setEditingContent('');
}, [setEditingContent]);
const isDisabled = !currentStory || !connection || !model || isLoading;
return (
{!currentStory ? (
Select a chat to start
) : !connection || !model ? (
{!connection ? 'Connect to an LLM server' : 'Select a model'} to start chatting
) : currentStory.chatMessages.length === 0 ? (
No messages yet
) : (
{currentStory.chatMessages.map((message) => {
const isEditing = editingMessageId === message.id;
const canEdit = message.role === 'user' || message.role === 'assistant';
return (
{isEditing ? (
{
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
handleSaveEdit();
}
if (e.key === 'Escape') {
e.preventDefault();
handleCancelEdit();
}
}}
/>
) : (
<>
{message.role === 'assistant' && message.reasoning_content && (
{message.reasoning_content}
)}
{message.role === 'assistant' && message.tool_calls && (
{message.tool_calls.map((tool) => (
{tool.function.name}
))}
)}
>
)}
);
})}
{error && (
)}
Clear chat
)}
{currentStory && (
)}
);
};
export const ChatSidebar = () => {
const { currentWorld, chatOpen, dispatch } = useAppState();
// In chat-only worlds, chat is a full editor tab — no sidebar needed
if (currentWorld?.chatOnly) return null;
return (
dispatch({
type: 'SET_CHAT_OPEN',
open: !chatOpen,
})}
class={sidebarStyles.mobileOverlay}
>
);
};