feat(llm): identify sidebar chat notes by note ID

This commit is contained in:
Elian Doran
2026-03-29 19:45:45 +03:00
parent ad1b3df74e
commit 0ffcfb8f43
5 changed files with 108 additions and 37 deletions

View File

@@ -84,8 +84,20 @@ async function createSearchNote(opts = {}) {
return await froca.getNote(note.noteId);
}
async function createLlmChat() {
const note = await server.post<FNoteRow>("special-notes/llm-chat");
async function createLlmChat(sourceNoteId?: string) {
const note = await server.post<FNoteRow>("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<FNoteRow>(`special-notes/llm-chat-for-note/${noteId}`);
await ws.waitForMaxKnownEntityChangeId();
@@ -103,5 +115,6 @@ export default {
getYearNote,
createSqlConsole,
createSearchNote,
createLlmChat
createLlmChat,
getOrCreateLlmChatForNote
};

View File

@@ -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<string | null>(null);
const [chatNoteId, setChatNoteId] = useState<string | null>(null);
const [messages, setMessages] = useState<StoredMessage[]>([]);
const [input, setInput] = useState("");
@@ -69,29 +71,55 @@ export default function SidebarChat() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// 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;

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,