From 23dbedd13952c1b34eeba17c3db6a9df04cf03fb Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 29 Mar 2026 20:28:19 +0300 Subject: [PATCH] refactor(llm): deduplicate LLM chat widgets --- .../src/widgets/sidebar/SidebarChat.tsx | 395 +++++------------- .../widgets/type_widgets/llm_chat/LlmChat.tsx | 368 +++------------- .../type_widgets/llm_chat/llm_chat_types.ts | 33 ++ .../type_widgets/llm_chat/useLlmChat.ts | 339 +++++++++++++++ 4 files changed, 527 insertions(+), 608 deletions(-) create mode 100644 apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts create mode 100644 apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts diff --git a/apps/client/src/widgets/sidebar/SidebarChat.tsx b/apps/client/src/widgets/sidebar/SidebarChat.tsx index 3fcc54384f..83d56db8dd 100644 --- a/apps/client/src/widgets/sidebar/SidebarChat.tsx +++ b/apps/client/src/widgets/sidebar/SidebarChat.tsx @@ -1,50 +1,16 @@ -import type { LlmCitation, LlmMessage, LlmModelInfo, LlmUsage } from "@triliumnext/commons"; import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import dateNoteService from "../../services/date_notes.js"; import { t } from "../../services/i18n.js"; -import { getAvailableModels, streamChatCompletion } from "../../services/llm_chat.js"; import server from "../../services/server.js"; -import { randomString } from "../../services/utils.js"; import ActionButton from "../react/ActionButton.js"; import NoItems from "../react/NoItems.js"; import ChatMessage from "../type_widgets/llm_chat/ChatMessage.js"; +import type { LlmChatContent } from "../type_widgets/llm_chat/llm_chat_types.js"; +import { useLlmChat } from "../type_widgets/llm_chat/useLlmChat.js"; import RightPanelWidget from "./RightPanelWidget.js"; import "./SidebarChat.css"; -type MessageType = "message" | "error" | "thinking"; - -interface ToolCall { - id: string; - toolName: string; - input: Record; - result?: string; -} - -interface StoredMessage { - id: string; - role: "user" | "assistant" | "system"; - content: string; - createdAt: string; - citations?: LlmCitation[]; - type?: MessageType; - toolCalls?: ToolCall[]; - usage?: LlmUsage; -} - -interface LlmChatContent { - version: 1; - messages: StoredMessage[]; - selectedModel?: string; - enableWebSearch?: boolean; - enableNoteTools?: boolean; - enableExtendedThinking?: boolean; -} - -interface ModelOption extends LlmModelInfo { - costDescription?: string; -} - /** * Sidebar chat widget that appears in the right panel. * Uses a hidden LLM chat note for persistence across all notes. @@ -52,122 +18,35 @@ interface ModelOption extends LlmModelInfo { */ export default function SidebarChat() { const [chatNoteId, setChatNoteId] = useState(null); - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [isStreaming, setIsStreaming] = useState(false); - const [streamingContent, setStreamingContent] = useState(""); - const [streamingThinking, setStreamingThinking] = useState(""); - const [toolActivity, setToolActivity] = useState(null); - const [pendingCitations, setPendingCitations] = useState([]); - const [availableModels, setAvailableModels] = useState([]); - const [selectedModel, setSelectedModel] = useState(""); - const [enableWebSearch, setEnableWebSearch] = useState(true); - const [enableNoteTools, setEnableNoteTools] = useState(true); // Default true for sidebar - const messagesEndRef = useRef(null); - const textareaRef = useRef(null); + const [shouldSave, setShouldSave] = useState(false); const saveTimeoutRef = useRef>(); - // Load the most recent chat on mount - const loadMostRecentChat = useCallback(async () => { - try { - const existingChat = await dateNoteService.getMostRecentLlmChat(); + // Use shared chat hook with sidebar-specific options + const chat = useLlmChat( + // onMessagesChange - trigger save + () => setShouldSave(true), + { defaultEnableNoteTools: true, supportsExtendedThinking: false } + ); - if (existingChat) { - setChatNoteId(existingChat.noteId); - await loadChatContent(existingChat.noteId); - } else { - // No existing chat - will create on first message - setChatNoteId(null); - setMessages([]); - } - } catch (err) { - console.error("Failed to load sidebar chat:", err); - } - }, []); + // Ref to access chat methods in effects without triggering re-runs + const chatRef = useRef(chat); + chatRef.current = chat; - // Create chat note on demand (when user sends first message) - const ensureChatNoteExists = useCallback(async (): Promise => { - if (chatNoteId) { - return chatNoteId; - } - - try { - const note = await dateNoteService.getOrCreateLlmChat(); - if (note) { - setChatNoteId(note.noteId); - return note.noteId; - } - } catch (err) { - console.error("Failed to create sidebar chat:", err); - } - return null; - }, [chatNoteId]); - - // Load the most recent chat on mount + // Handle debounced save when shouldSave is triggered useEffect(() => { - loadMostRecentChat(); - }, [loadMostRecentChat]); - - // Fetch available models on mount - useEffect(() => { - getAvailableModels().then(models => { - const modelsWithDescription = models.map(m => ({ - ...m, - costDescription: m.costMultiplier ? `${m.costMultiplier}x cost` : undefined - })); - setAvailableModels(modelsWithDescription); - if (!selectedModel) { - const defaultModel = models.find(m => m.isDefault) || models[0]; - if (defaultModel) { - setSelectedModel(defaultModel.id); - } - } - }).catch(err => { - console.error("Failed to fetch available models:", err); - }); - }, []); - - const scrollToBottom = useCallback(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, []); - - useEffect(() => { - scrollToBottom(); - }, [messages, streamingContent, streamingThinking, toolActivity, scrollToBottom]); - - const loadChatContent = async (noteId: string) => { - try { - const blob = await server.get<{ content: string }>(`notes/${noteId}/blob`); - if (blob?.content) { - const parsed: LlmChatContent = JSON.parse(blob.content); - setMessages(parsed.messages || []); - if (parsed.selectedModel) setSelectedModel(parsed.selectedModel); - if (typeof parsed.enableWebSearch === "boolean") setEnableWebSearch(parsed.enableWebSearch); - if (typeof parsed.enableNoteTools === "boolean") setEnableNoteTools(parsed.enableNoteTools); - } - } catch (err) { - console.error("Failed to load chat content:", err); + if (!shouldSave || !chatNoteId) { + setShouldSave(false); + return; } - }; - const saveChat = useCallback(async (updatedMessages: StoredMessage[]) => { - if (!chatNoteId) return; + setShouldSave(false); - // Clear any pending save if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } - // Debounce saves saveTimeoutRef.current = setTimeout(async () => { - const content: LlmChatContent = { - version: 1, - messages: updatedMessages, - selectedModel: selectedModel || undefined, - enableWebSearch, - enableNoteTools - }; - + const content = chat.getContent(); try { await server.put(`notes/${chatNoteId}/data`, { content: JSON.stringify(content) @@ -176,142 +55,75 @@ export default function SidebarChat() { console.error("Failed to save chat:", err); } }, 500); - }, [chatNoteId, selectedModel, enableWebSearch, enableNoteTools]); + }, [shouldSave, chatNoteId, chat]); + // Load the most recent chat on mount (runs once) + useEffect(() => { + let cancelled = false; + + const loadMostRecentChat = async () => { + try { + const existingChat = await dateNoteService.getMostRecentLlmChat(); + + if (cancelled) return; + + if (existingChat) { + setChatNoteId(existingChat.noteId); + // Load content inline to avoid dependency issues + try { + const blob = await server.get<{ content: string }>(`notes/${existingChat.noteId}/blob`); + if (!cancelled && blob?.content) { + const parsed: LlmChatContent = JSON.parse(blob.content); + chatRef.current.loadFromContent(parsed); + } + } catch (err) { + console.error("Failed to load chat content:", err); + } + } else { + // No existing chat - will create on first message + setChatNoteId(null); + chatRef.current.clearMessages(); + } + } catch (err) { + console.error("Failed to load sidebar chat:", err); + } + }; + + loadMostRecentChat(); + + return () => { + cancelled = true; + }; + }, []); + + // Custom submit handler that ensures chat note exists first const handleSubmit = useCallback(async (e: Event) => { e.preventDefault(); - if (!input.trim() || isStreaming) return; + if (!chat.input.trim() || chat.isStreaming) return; // Ensure chat note exists before sending (lazy creation) - const noteId = await ensureChatNoteExists(); + let noteId = chatNoteId; + if (!noteId) { + try { + const note = await dateNoteService.getOrCreateLlmChat(); + if (note) { + setChatNoteId(note.noteId); + noteId = note.noteId; + } + } catch (err) { + console.error("Failed to create sidebar chat:", err); + return; + } + } + if (!noteId) { console.error("Cannot send message: no chat note available"); return; } - setToolActivity(null); - setPendingCitations([]); - - const userMessage: StoredMessage = { - id: randomString(), - role: "user", - content: input.trim(), - createdAt: new Date().toISOString() - }; - - const newMessages = [...messages, userMessage]; - setMessages(newMessages); - setInput(""); - setIsStreaming(true); - setStreamingContent(""); - setStreamingThinking(""); - - let assistantContent = ""; - let thinkingContent = ""; - const citations: LlmCitation[] = []; - const toolCalls: ToolCall[] = []; - let usage: LlmUsage | undefined; - - const apiMessages: LlmMessage[] = newMessages.map(m => ({ - role: m.role, - content: m.content - })); - - await streamChatCompletion( - apiMessages, - { model: selectedModel || undefined, enableWebSearch, enableNoteTools }, - { - onChunk: (text) => { - assistantContent += text; - setStreamingContent(assistantContent); - setToolActivity(null); - }, - onThinking: (text) => { - thinkingContent += text; - setStreamingThinking(thinkingContent); - setToolActivity(t("llm_chat.thinking")); - }, - onToolUse: (toolName, toolInput) => { - const toolLabel = toolName === "web_search" - ? t("llm_chat.searching_web") - : `Using ${toolName}...`; - setToolActivity(toolLabel); - toolCalls.push({ - id: randomString(), - toolName, - input: toolInput - }); - }, - onToolResult: (toolName, result) => { - const toolCall = [...toolCalls].reverse().find(tc => tc.toolName === toolName && !tc.result); - if (toolCall) { - toolCall.result = result; - } - }, - onCitation: (citation) => { - citations.push(citation); - setPendingCitations([...citations]); - }, - onUsage: (u) => { - usage = u; - }, - onError: (errorMsg) => { - console.error("Chat error:", errorMsg); - const errorMessage: StoredMessage = { - id: randomString(), - role: "assistant", - content: errorMsg, - createdAt: new Date().toISOString(), - type: "error" - }; - const finalMessages = [...newMessages, errorMessage]; - setMessages(finalMessages); - saveChat(finalMessages); - setStreamingContent(""); - setStreamingThinking(""); - setIsStreaming(false); - setToolActivity(null); - }, - onDone: () => { - const finalNewMessages: StoredMessage[] = []; - - if (thinkingContent) { - finalNewMessages.push({ - id: randomString(), - role: "assistant", - content: thinkingContent, - createdAt: new Date().toISOString(), - type: "thinking" - }); - } - - if (assistantContent || toolCalls.length > 0) { - finalNewMessages.push({ - id: randomString(), - role: "assistant", - content: assistantContent, - createdAt: new Date().toISOString(), - citations: citations.length > 0 ? citations : undefined, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - usage - }); - } - - if (finalNewMessages.length > 0) { - const allMessages = [...newMessages, ...finalNewMessages]; - setMessages(allMessages); - saveChat(allMessages); - } - - setStreamingContent(""); - setStreamingThinking(""); - setPendingCitations([]); - setIsStreaming(false); - setToolActivity(null); - } - } - ); - }, [input, isStreaming, messages, selectedModel, enableWebSearch, enableNoteTools, saveChat, ensureChatNoteExists]); + // Delegate to shared handler + await chat.handleSubmit(e); + }, [chatNoteId, chat]); const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -321,17 +133,16 @@ export default function SidebarChat() { }, [handleSubmit]); const handleNewChat = useCallback(async () => { - // Create a fresh new chat try { const note = await dateNoteService.createLlmChat(); if (note) { setChatNoteId(note.noteId); - setMessages([]); + chat.clearMessages(); } } catch (err) { console.error("Failed to create new chat:", err); } - }, []); + }, [chat]); const handleSaveChat = useCallback(async () => { if (!chatNoteId) return; @@ -341,12 +152,12 @@ export default function SidebarChat() { const note = await dateNoteService.createLlmChat(); if (note) { setChatNoteId(note.noteId); - setMessages([]); + chat.clearMessages(); } } catch (err) { console.error("Failed to save chat to permanent location:", err); } - }, [chatNoteId]); + }, [chatNoteId, chat]); return ( } >
- {messages.length === 0 && !isStreaming && ( + {chat.messages.length === 0 && !chat.isStreaming && ( )} - {messages.map(msg => ( + {chat.messages.map(msg => ( ))} - {toolActivity && !streamingThinking && ( + {chat.toolActivity && !chat.streamingThinking && (
- {toolActivity} + {chat.toolActivity}
)} - {isStreaming && streamingThinking && ( + {chat.isStreaming && chat.streamingThinking && ( )} - {isStreaming && streamingContent && ( + {chat.isStreaming && chat.streamingContent && ( 0 ? pendingCitations : undefined + citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined }} isStreaming /> )} -
+