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 (
{!isLoading && canEdit && (
)}
{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 && (
error
{error}
)}
)} {currentStory && (
{tokenCount && {tokenCount.taken} / {tokenCount.total} tokens} {!currentWorld?.chatOnly && ( )}
{isLoading ? ( ) : (
{!currentWorld?.chatOnly && ( )}
)}
)}
); }; 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} > ); };