From 0ffcfb8f43a2edfce98f9c4397e55a042c8dbb3b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 29 Mar 2026 19:45:45 +0300 Subject: [PATCH] feat(llm): identify sidebar chat notes by note ID --- apps/client/src/services/date_notes.ts | 19 ++++- .../src/widgets/sidebar/SidebarChat.tsx | 83 ++++++++++++------- apps/server/src/routes/api/special_notes.ts | 10 ++- apps/server/src/routes/routes.ts | 1 + apps/server/src/services/special_notes.ts | 32 ++++++- 5 files changed, 108 insertions(+), 37 deletions(-) diff --git a/apps/client/src/services/date_notes.ts b/apps/client/src/services/date_notes.ts index e96c92f28c..09ff1e8ca5 100644 --- a/apps/client/src/services/date_notes.ts +++ b/apps/client/src/services/date_notes.ts @@ -84,8 +84,20 @@ async function createSearchNote(opts = {}) { return await froca.getNote(note.noteId); } -async function createLlmChat() { - const note = await server.post("special-notes/llm-chat"); +async function createLlmChat(sourceNoteId?: string) { + const note = await server.post("special-notes/llm-chat", { sourceNoteId }); + + await ws.waitForMaxKnownEntityChangeId(); + + return await froca.getNote(note.noteId); +} + +/** + * Gets an existing LLM chat linked to the given note, or creates a new one. + * Used by sidebar chat to maintain 1:1 mapping between notes and their chats. + */ +async function getOrCreateLlmChatForNote(noteId: string) { + const note = await server.get(`special-notes/llm-chat-for-note/${noteId}`); await ws.waitForMaxKnownEntityChangeId(); @@ -103,5 +115,6 @@ export default { getYearNote, createSqlConsole, createSearchNote, - createLlmChat + createLlmChat, + getOrCreateLlmChatForNote }; diff --git a/apps/client/src/widgets/sidebar/SidebarChat.tsx b/apps/client/src/widgets/sidebar/SidebarChat.tsx index 5a78d2f5a8..13b5772efa 100644 --- a/apps/client/src/widgets/sidebar/SidebarChat.tsx +++ b/apps/client/src/widgets/sidebar/SidebarChat.tsx @@ -9,7 +9,7 @@ import options from "../../services/options.js"; import server from "../../services/server.js"; import { randomString } from "../../services/utils.js"; import ActionButton from "../react/ActionButton.js"; -import { useTriliumEvent } from "../react/hooks.js"; +import { useActiveNoteContext, useTriliumEvent } from "../react/hooks.js"; import ChatMessage from "../type_widgets/llm_chat/ChatMessage.js"; import RightPanelWidget from "./RightPanelWidget.js"; import "./SidebarChat.css"; @@ -49,10 +49,12 @@ interface ModelOption extends LlmModelInfo { /** * Sidebar chat widget that appears in the right panel. - * Uses a hidden LLM chat note for persistence. + * Uses a hidden LLM chat note for persistence, with 1:1 mapping to the active note. */ export default function SidebarChat() { + const { note: activeNote } = useActiveNoteContext(); const [visible, setVisible] = useState(false); + const [sourceNoteId, setSourceNoteId] = useState(null); const [chatNoteId, setChatNoteId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); @@ -69,29 +71,55 @@ export default function SidebarChat() { const textareaRef = useRef(null); const saveTimeoutRef = useRef>(); + // Initialize chat for a specific note + const initializeChatForNote = useCallback(async (noteId: string) => { + try { + const note = await dateNoteService.getOrCreateLlmChatForNote(noteId); + if (note) { + setSourceNoteId(noteId); + setChatNoteId(note.noteId); + // Load existing messages if any + await loadChatContent(note.noteId); + } + } catch (err) { + console.error("Failed to initialize sidebar chat:", err); + } + }, []); + + // Track when active note changes and load/create corresponding chat + useEffect(() => { + if (!visible || !activeNote) return; + + const noteId = activeNote.noteId; + // Don't switch chats if we're already on this note's chat + if (noteId === sourceNoteId) return; + + // Load or create chat for the new note + initializeChatForNote(noteId); + }, [activeNote, visible, sourceNoteId, initializeChatForNote]); + // Listen for toggle event useTriliumEvent("toggleSidebarChat", useCallback(() => { setVisible(v => { const newValue = !v; - if (newValue && !chatNoteId) { - // Create a new chat when first opened - initializeChat(); + if (newValue && activeNote) { + initializeChatForNote(activeNote.noteId); } return newValue; }); - }, [chatNoteId])); + }, [activeNote, initializeChatForNote])); // Listen for open event (always opens, creates chat if needed) useTriliumEvent("openSidebarChat", useCallback(() => { setVisible(true); - if (!chatNoteId) { - initializeChat(); + if (activeNote) { + initializeChatForNote(activeNote.noteId); } // Ensure right pane is visible if (!options.is("rightPaneVisible")) { appContext.triggerEvent("toggleRightPane", {}); } - }, [chatNoteId])); + }, [activeNote, initializeChatForNote])); // Fetch available models on mount useEffect(() => { @@ -120,18 +148,6 @@ export default function SidebarChat() { scrollToBottom(); }, [messages, streamingContent, streamingThinking, toolActivity, scrollToBottom]); - const initializeChat = async () => { - try { - const note = await dateNoteService.createLlmChat(); - if (note) { - setChatNoteId(note.noteId); - setMessages([]); - } - } catch (err) { - console.error("Failed to create sidebar chat:", err); - } - }; - const loadChatContent = async (noteId: string) => { try { const blob = await server.get<{ content: string }>(`notes/${noteId}/blob`); @@ -311,24 +327,31 @@ export default function SidebarChat() { }, [handleSubmit]); const handleNewChat = useCallback(async () => { - // Save current chat first if it has messages - if (chatNoteId && messages.length > 0) { - await saveChat(messages); + if (!activeNote) return; + // Clear messages for a fresh start with the same note + setMessages([]); + // Save cleared state + if (chatNoteId) { + await saveChat([]); } - // Create a new chat - await initializeChat(); - }, [chatNoteId, messages, saveChat]); + }, [activeNote, chatNoteId, saveChat]); const handleSaveChat = useCallback(async () => { if (!chatNoteId) return; try { await server.post("special-notes/save-llm-chat", { llmChatNoteId: chatNoteId }); - // After saving, create a new chat for continued use - await initializeChat(); + // Clear the current chat after saving (note link is preserved) + setMessages([]); + // Force reload to get a fresh chat for this note + if (activeNote) { + setSourceNoteId(null); + setChatNoteId(null); + initializeChatForNote(activeNote.noteId); + } } catch (err) { console.error("Failed to save chat to permanent location:", err); } - }, [chatNoteId]); + }, [chatNoteId, activeNote, initializeChatForNote]); if (!visible) { return null; diff --git a/apps/server/src/routes/api/special_notes.ts b/apps/server/src/routes/api/special_notes.ts index 5a52dde685..09f0215244 100644 --- a/apps/server/src/routes/api/special_notes.ts +++ b/apps/server/src/routes/api/special_notes.ts @@ -86,8 +86,13 @@ function createSearchNote(req: Request) { return specialNotesService.createSearchNote(searchString, ancestorNoteId); } -function createLlmChat() { - return specialNotesService.createLlmChat(); +function createLlmChat(req: Request) { + const sourceNoteId = req.body.sourceNoteId; + return specialNotesService.createLlmChat(sourceNoteId); +} + +function getOrCreateLlmChatForNote(req: Request<{ noteId: string }>) { + return specialNotesService.getOrCreateLlmChatForNote(req.params.noteId); } function saveLlmChat(req: Request) { @@ -128,6 +133,7 @@ export default { createSearchNote, saveSearchNote, createLlmChat, + getOrCreateLlmChatForNote, saveLlmChat, createLauncher, resetLauncher, diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index 56c5a38c5e..9c7b7d052b 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -293,6 +293,7 @@ function register(app: express.Application) { apiRoute(PST, "/api/special-notes/search-note", specialNotesRoute.createSearchNote); apiRoute(PST, "/api/special-notes/save-search-note", specialNotesRoute.saveSearchNote); apiRoute(PST, "/api/special-notes/llm-chat", specialNotesRoute.createLlmChat); + apiRoute(GET, "/api/special-notes/llm-chat-for-note/:noteId", specialNotesRoute.getOrCreateLlmChatForNote); apiRoute(PST, "/api/special-notes/save-llm-chat", specialNotesRoute.saveLlmChat); apiRoute(PST, "/api/special-notes/launchers/:noteId/reset", specialNotesRoute.resetLauncher); apiRoute(PST, "/api/special-notes/launchers/:parentNoteId/:launcherType", specialNotesRoute.createLauncher); diff --git a/apps/server/src/services/special_notes.ts b/apps/server/src/services/special_notes.ts index 165e710cf5..0d95a48652 100644 --- a/apps/server/src/services/special_notes.ts +++ b/apps/server/src/services/special_notes.ts @@ -123,10 +123,13 @@ function saveSearchNote(searchNoteId: string) { return result satisfies SaveSearchNoteResponse; } -function createLlmChat() { +function createLlmChat(sourceNoteId?: string) { + const sourceNote = sourceNoteId ? becca.getNote(sourceNoteId) : null; + const titleSuffix = sourceNote ? sourceNote.title : dateUtils.localNowDateTime(); + const { note } = noteService.createNewNote({ parentNoteId: getMonthlyParentNoteId("_llmChat", "llmChat"), - title: `${t("special_notes.llm_chat_prefix")} ${dateUtils.localNowDateTime()}`, + title: `${t("special_notes.llm_chat_prefix")} ${titleSuffix}`, content: JSON.stringify({ version: 1, messages: [] @@ -138,9 +141,33 @@ function createLlmChat() { note.setLabel("iconClass", "bx bx-message-square-dots"); note.setLabel("keepCurrentHoisting"); + // Link to source note if provided + if (sourceNoteId) { + note.setRelation("sourceNote", sourceNoteId); + } + return note; } +/** + * Gets an existing LLM chat linked to the given note, or creates a new one. + * Used by sidebar chat to maintain 1:1 mapping between notes and their chats. + */ +function getOrCreateLlmChatForNote(sourceNoteId: string) { + // Search for existing chat with this source note relation + const existingChat = searchService.findFirstNoteWithQuery( + `~sourceNote.noteId="${sourceNoteId}"`, + new SearchContext({ ancestorNoteId: "_llmChat" }) + ); + + if (existingChat) { + return existingChat; + } + + // Create new chat linked to this note + return createLlmChat(sourceNoteId); +} + function getLlmChatHome() { const workspaceNote = hoistedNoteService.getWorkspaceNote(); if (!workspaceNote) { @@ -335,6 +362,7 @@ export default { createSearchNote, saveSearchNote, createLlmChat, + getOrCreateLlmChatForNote, saveLlmChat, createLauncher, resetLauncher,