diff --git a/apps/client/src/widgets/sidebar/SidebarChat.tsx b/apps/client/src/widgets/sidebar/SidebarChat.tsx index 6a52673e22..cb3c4663b9 100644 --- a/apps/client/src/widgets/sidebar/SidebarChat.tsx +++ b/apps/client/src/widgets/sidebar/SidebarChat.tsx @@ -44,6 +44,11 @@ export default function SidebarChat() { chat.setContextNoteId(activeNoteId ?? undefined); }, [activeNoteId, chat.setContextNoteId]); + // Sync chatNoteId into the hook for auto-title generation + useEffect(() => { + chat.setChatNoteId(chatNoteId ?? undefined); + }, [chatNoteId, chat.setChatNoteId]); + // Ref to access chat methods in effects without triggering re-runs const chatRef = useRef(chat); chatRef.current = chat; @@ -137,6 +142,10 @@ export default function SidebarChat() { return; } + // Ensure the hook has the chatNoteId before submitting (state update from + // setChatNoteId above won't be visible until next render) + chat.setChatNoteId(noteId); + // Delegate to shared handler await chat.handleSubmit(e); }, [chatNoteId, chat]); diff --git a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx index cc1b303192..667566d9c4 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx @@ -16,9 +16,14 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { const chat = useLlmChat( // onMessagesChange - trigger save () => setShouldSave(true), - { defaultEnableNoteTools: false, supportsExtendedThinking: true } + { defaultEnableNoteTools: false, supportsExtendedThinking: true, chatNoteId: note?.noteId } ); + // Keep chatNoteId in sync when the note changes + useEffect(() => { + chat.setChatNoteId(note?.noteId); + }, [note?.noteId, chat.setChatNoteId]); + const spacedUpdate = useEditorSpacedUpdate({ note, noteType: "llmChat", diff --git a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts index d8d93e3522..68b83f1a2c 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts +++ b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts @@ -17,6 +17,8 @@ export interface LlmChatOptions { supportsExtendedThinking?: boolean; /** Initial context note ID (the note the user is viewing) */ contextNoteId?: string; + /** The chat note ID (used for auto-renaming on first message) */ + chatNoteId?: string; } export interface UseLlmChatReturn { @@ -50,6 +52,7 @@ export interface UseLlmChatReturn { setEnableNoteTools: (value: boolean) => void; setEnableExtendedThinking: (value: boolean) => void; setContextNoteId: (noteId: string | undefined) => void; + setChatNoteId: (noteId: string | undefined) => void; // Actions handleSubmit: (e: Event) => Promise; @@ -65,7 +68,7 @@ export function useLlmChat( onMessagesChange?: (messages: StoredMessage[]) => void, options: LlmChatOptions = {} ): UseLlmChatReturn { - const { defaultEnableNoteTools = false, supportsExtendedThinking = false, contextNoteId: initialContextNoteId } = options; + const { defaultEnableNoteTools = false, supportsExtendedThinking = false, contextNoteId: initialContextNoteId, chatNoteId: initialChatNoteId } = options; const [messages, setMessagesInternal] = useState([]); const [input, setInput] = useState(""); @@ -80,6 +83,7 @@ export function useLlmChat( const [enableNoteTools, setEnableNoteTools] = useState(defaultEnableNoteTools); const [enableExtendedThinking, setEnableExtendedThinking] = useState(false); const [contextNoteId, setContextNoteId] = useState(initialContextNoteId); + const [chatNoteId, setChatNoteIdState] = useState(initialChatNoteId); const [lastPromptTokens, setLastPromptTokens] = useState(0); const [hasProvider, setHasProvider] = useState(true); // Assume true initially const [isCheckingProvider, setIsCheckingProvider] = useState(true); @@ -97,6 +101,12 @@ export function useLlmChat( enableNoteToolsRef.current = enableNoteTools; const enableExtendedThinkingRef = useRef(enableExtendedThinking); enableExtendedThinkingRef.current = enableExtendedThinking; + const chatNoteIdRef = useRef(chatNoteId); + chatNoteIdRef.current = chatNoteId; + const setChatNoteId = useCallback((noteId: string | undefined) => { + chatNoteIdRef.current = noteId; + setChatNoteIdState(noteId); + }, []); const contextNoteIdRef = useRef(contextNoteId); contextNoteIdRef.current = contextNoteId; @@ -233,7 +243,8 @@ export function useLlmChat( model: selectedModel || undefined, enableWebSearch, enableNoteTools, - contextNoteId + contextNoteId, + chatNoteId: chatNoteIdRef.current }; if (supportsExtendedThinking) { streamOptions.enableExtendedThinking = enableExtendedThinking; @@ -380,6 +391,7 @@ export function useLlmChat( setEnableNoteTools, setEnableExtendedThinking, setContextNoteId, + setChatNoteId, // Actions handleSubmit, diff --git a/apps/server/src/routes/api/llm_chat.ts b/apps/server/src/routes/api/llm_chat.ts index 2cc59df47c..9f81c2e4fd 100644 --- a/apps/server/src/routes/api/llm_chat.ts +++ b/apps/server/src/routes/api/llm_chat.ts @@ -3,6 +3,7 @@ import type { LlmMessage } from "@triliumnext/commons"; import { getProviderByType, hasConfiguredProviders, type LlmProviderConfig } from "../../services/llm/index.js"; import { streamToChunks } from "../../services/llm/stream.js"; +import { generateChatTitle } from "../../services/llm/chat_title.js"; interface ChatRequest { messages: LlmMessage[]; @@ -63,6 +64,16 @@ async function streamChat(req: Request, res: Response) { flushableRes.flush(); } } + // Auto-generate a title for the chat note on the first user message + const userMessages = messages.filter(m => m.role === "user"); + if (userMessages.length === 1 && config.chatNoteId) { + try { + await generateChatTitle(config.chatNoteId, userMessages[0].content); + } catch (err) { + // Title generation is best-effort; don't fail the chat + console.error("Failed to generate chat title:", err); + } + } } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; res.write(`data: ${JSON.stringify({ type: "error", error: errorMessage })}\n\n`); diff --git a/apps/server/src/services/llm/chat_title.ts b/apps/server/src/services/llm/chat_title.ts new file mode 100644 index 0000000000..e6ac53e10b --- /dev/null +++ b/apps/server/src/services/llm/chat_title.ts @@ -0,0 +1,28 @@ +import becca from "../../becca/becca.js"; +import { getProvider } from "./index.js"; +import log from "../log.js"; +import { t } from "i18next"; + +/** + * Generate a short descriptive title for a chat note based on the first user message, + * then rename the note. Only renames if the note still has the default "Chat: ..." title. + */ +export async function generateChatTitle(chatNoteId: string, firstMessage: string): Promise { + const note = becca.getNote(chatNoteId); + if (!note) { + return; + } + + // Only rename notes that still have the default timestamp-based title + const defaultPrefix = t("special_notes.llm_chat_prefix"); + if (!note.title.startsWith(defaultPrefix)) { + return; + } + + const provider = getProvider(); + const title = await provider.generateTitle(firstMessage); + if (title) { + note.title = title; + log.info(`Auto-renamed chat note ${chatNoteId} to "${title}"`); + } +} diff --git a/apps/server/src/services/llm/providers/anthropic.ts b/apps/server/src/services/llm/providers/anthropic.ts index d008e02249..e26691b7f1 100644 --- a/apps/server/src/services/llm/providers/anthropic.ts +++ b/apps/server/src/services/llm/providers/anthropic.ts @@ -1,13 +1,15 @@ import { createAnthropic, type AnthropicProvider as AnthropicSDKProvider } from "@ai-sdk/anthropic"; -import { streamText, stepCountIs, type CoreMessage } from "ai"; +import { generateText, streamText, stepCountIs, type CoreMessage } from "ai"; import type { LlmMessage } from "@triliumnext/commons"; import becca from "../../../becca/becca.js"; -import { noteTools, currentNoteTools } from "../tools.js"; +import { noteTools, attributeTools, currentNoteTools } from "../tools/index.js"; import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js"; const DEFAULT_MODEL = "claude-sonnet-4-6"; const DEFAULT_MAX_TOKENS = 8096; +const TITLE_MODEL = "claude-haiku-4-5-20251001"; +const TITLE_MAX_TOKENS = 30; /** * Calculate effective cost for comparison (weighted average: 1 input + 3 output). @@ -203,6 +205,7 @@ export class AnthropicProvider implements LlmProvider { if (config.enableNoteTools) { Object.assign(tools, noteTools); + Object.assign(tools, attributeTools); } if (Object.keys(tools).length > 0) { @@ -225,4 +228,19 @@ export class AnthropicProvider implements LlmProvider { getAvailableModels(): ModelInfo[] { return AVAILABLE_MODELS; } + + async generateTitle(firstMessage: string): Promise { + const { text } = await generateText({ + model: this.anthropic(TITLE_MODEL), + maxTokens: TITLE_MAX_TOKENS, + messages: [ + { + role: "user", + content: `Summarize the following message as a very short chat title (max 6 words). Reply with ONLY the title, no quotes or punctuation at the end.\n\nMessage: ${firstMessage}` + } + ] + }); + + return text.trim(); + } } diff --git a/apps/server/src/services/llm/tools/attribute_tools.ts b/apps/server/src/services/llm/tools/attribute_tools.ts new file mode 100644 index 0000000000..d08a94fd01 --- /dev/null +++ b/apps/server/src/services/llm/tools/attribute_tools.ts @@ -0,0 +1,137 @@ +/** + * LLM tools for attribute operations (get, set, delete labels/relations). + */ + +import { tool } from "ai"; +import { z } from "zod"; + +import becca from "../../../becca/becca.js"; +import attributeService from "../../attributes.js"; + +/** + * Get all owned attributes (labels/relations) of a note. + */ +export const getAttributes = tool({ + description: "Get all attributes (labels and relations) of a note. Labels store text values; relations link to other notes by ID.", + inputSchema: z.object({ + noteId: z.string().describe("The ID of the note") + }), + execute: async ({ noteId }) => { + const note = becca.getNote(noteId); + if (!note) { + return { error: "Note not found" }; + } + + return note.getOwnedAttributes() + .filter((attr) => !attr.isAutoLink()) + .map((attr) => ({ + attributeId: attr.attributeId, + type: attr.type, + name: attr.name, + value: attr.value, + isInheritable: attr.isInheritable + })); + } +}); + +/** + * Get a single attribute by its ID. + */ +export const getAttribute = tool({ + description: "Get a single attribute by its ID.", + inputSchema: z.object({ + attributeId: z.string().describe("The ID of the attribute") + }), + execute: async ({ attributeId }) => { + const attribute = becca.getAttribute(attributeId); + if (!attribute) { + return { error: "Attribute not found" }; + } + + return { + attributeId: attribute.attributeId, + noteId: attribute.noteId, + type: attribute.type, + name: attribute.name, + value: attribute.value, + isInheritable: attribute.isInheritable + }; + } +}); + +/** + * Add or update an attribute on a note. + */ +export const setAttribute = tool({ + description: "Add or update an attribute on a note. If an attribute with the same type and name exists, it is updated; otherwise a new one is created. Use type 'label' for text values, 'relation' for linking to another note (value must be a noteId).", + inputSchema: z.object({ + noteId: z.string().describe("The ID of the note"), + type: z.enum(["label", "relation"]).describe("The attribute type"), + name: z.string().describe("The attribute name"), + value: z.string().optional().describe("The attribute value (for relations, this must be a target noteId)") + }), + execute: async ({ noteId, type, name, value = "" }) => { + const note = becca.getNote(noteId); + if (!note) { + return { error: "Note not found" }; + } + if (note.isProtected) { + return { error: "Note is protected and cannot be modified" }; + } + if (attributeService.isAttributeDangerous(type, name)) { + return { error: `Attribute '${name}' is potentially dangerous and cannot be set by the LLM` }; + } + if (type === "relation" && value && !becca.getNote(value)) { + return { error: "Target note not found for relation" }; + } + + note.setAttribute(type, name, value); + + return { + success: true, + noteId: note.noteId, + type, + name, + value + }; + } +}); + +/** + * Remove an attribute from a note. + */ +export const deleteAttribute = tool({ + description: "Remove an attribute from a note by its attribute ID.", + inputSchema: z.object({ + noteId: z.string().describe("The ID of the note that owns the attribute"), + attributeId: z.string().describe("The ID of the attribute to delete") + }), + execute: async ({ noteId, attributeId }) => { + const attribute = becca.getAttribute(attributeId); + if (!attribute) { + return { error: "Attribute not found" }; + } + if (attribute.noteId !== noteId) { + return { error: "Attribute does not belong to the specified note" }; + } + + const note = becca.getNote(noteId); + if (note?.isProtected) { + return { error: "Note is protected and cannot be modified" }; + } + + attribute.markAsDeleted(); + + return { + success: true, + attributeId + }; + } +}); + +export const attributeTools = { + get_attributes: getAttributes, + get_attribute: getAttribute, + set_attribute: setAttribute, + delete_attribute: deleteAttribute +}; diff --git a/apps/server/src/services/llm/tools/index.ts b/apps/server/src/services/llm/tools/index.ts new file mode 100644 index 0000000000..dc2257ef1b --- /dev/null +++ b/apps/server/src/services/llm/tools/index.ts @@ -0,0 +1,7 @@ +/** + * LLM tools that wrap existing Trilium services. + * These reuse the same logic as ETAPI without any HTTP overhead. + */ + +export { noteTools, currentNoteTools } from "./note_tools.js"; +export { attributeTools } from "./attribute_tools.js"; diff --git a/apps/server/src/services/llm/tools.ts b/apps/server/src/services/llm/tools/note_tools.ts similarity index 61% rename from apps/server/src/services/llm/tools.ts rename to apps/server/src/services/llm/tools/note_tools.ts index af20e7187a..2f04811377 100644 --- a/apps/server/src/services/llm/tools.ts +++ b/apps/server/src/services/llm/tools/note_tools.ts @@ -1,24 +1,22 @@ /** - * LLM tools that wrap existing Trilium services. - * These reuse the same logic as ETAPI without any HTTP overhead. + * LLM tools for note operations (search, read, create, update, append). */ import { tool } from "ai"; import { z } from "zod"; -import becca from "../../becca/becca.js"; -import attributeService from "../attributes.js"; -import markdownExport from "../export/markdown.js"; -import markdownImport from "../import/markdown.js"; -import noteService from "../notes.js"; -import SearchContext from "../search/search_context.js"; -import searchService from "../search/services/search.js"; +import becca from "../../../becca/becca.js"; +import markdownExport from "../../export/markdown.js"; +import markdownImport from "../../import/markdown.js"; +import noteService from "../../notes.js"; +import SearchContext from "../../search/search_context.js"; +import searchService from "../../search/services/search.js"; /** * Convert note content to a format suitable for LLM consumption. * Text notes are converted from HTML to Markdown to reduce token usage. */ -function getNoteContentForLlm(note: { type: string; getContent: () => string | Buffer }) { +export function getNoteContentForLlm(note: { type: string; getContent: () => string | Buffer }) { const content = note.getContent(); if (typeof content !== "string") { return "[binary content]"; @@ -210,127 +208,6 @@ export const createNote = tool({ } }); -/** - * Get all owned attributes (labels/relations) of a note. - */ -export const getAttributes = tool({ - description: "Get all attributes (labels and relations) of a note. Labels store text values; relations link to other notes by ID.", - inputSchema: z.object({ - noteId: z.string().describe("The ID of the note") - }), - execute: async ({ noteId }) => { - const note = becca.getNote(noteId); - if (!note) { - return { error: "Note not found" }; - } - - return note.getOwnedAttributes() - .filter((attr) => !attr.isAutoLink()) - .map((attr) => ({ - attributeId: attr.attributeId, - type: attr.type, - name: attr.name, - value: attr.value, - isInheritable: attr.isInheritable - })); - } -}); - -/** - * Get a single attribute by its ID. - */ -export const getAttribute = tool({ - description: "Get a single attribute by its ID.", - inputSchema: z.object({ - attributeId: z.string().describe("The ID of the attribute") - }), - execute: async ({ attributeId }) => { - const attribute = becca.getAttribute(attributeId); - if (!attribute) { - return { error: "Attribute not found" }; - } - - return { - attributeId: attribute.attributeId, - noteId: attribute.noteId, - type: attribute.type, - name: attribute.name, - value: attribute.value, - isInheritable: attribute.isInheritable - }; - } -}); - -/** - * Add or update an attribute on a note. - */ -export const setAttribute = tool({ - description: "Add or update an attribute on a note. If an attribute with the same type and name exists, it is updated; otherwise a new one is created. Use type 'label' for text values, 'relation' for linking to another note (value must be a noteId).", - inputSchema: z.object({ - noteId: z.string().describe("The ID of the note"), - type: z.enum(["label", "relation"]).describe("The attribute type"), - name: z.string().describe("The attribute name"), - value: z.string().optional().describe("The attribute value (for relations, this must be a target noteId)") - }), - execute: async ({ noteId, type, name, value = "" }) => { - const note = becca.getNote(noteId); - if (!note) { - return { error: "Note not found" }; - } - if (note.isProtected) { - return { error: "Note is protected and cannot be modified" }; - } - if (attributeService.isAttributeDangerous(type, name)) { - return { error: `Attribute '${name}' is potentially dangerous and cannot be set by the LLM` }; - } - if (type === "relation" && value && !becca.getNote(value)) { - return { error: "Target note not found for relation" }; - } - - note.setAttribute(type, name, value); - - return { - success: true, - noteId: note.noteId, - type, - name, - value - }; - } -}); - -/** - * Remove an attribute from a note. - */ -export const deleteAttribute = tool({ - description: "Remove an attribute from a note by its attribute ID.", - inputSchema: z.object({ - noteId: z.string().describe("The ID of the note that owns the attribute"), - attributeId: z.string().describe("The ID of the attribute to delete") - }), - execute: async ({ noteId, attributeId }) => { - const attribute = becca.getAttribute(attributeId); - if (!attribute) { - return { error: "Attribute not found" }; - } - if (attribute.noteId !== noteId) { - return { error: "Attribute does not belong to the specified note" }; - } - - const note = becca.getNote(noteId); - if (note?.isProtected) { - return { error: "Note is protected and cannot be modified" }; - } - - attribute.markAsDeleted(); - - return { - success: true, - attributeId - }; - } -}); - /** * Read the content of the note the user is currently viewing. * Created dynamically so it captures the contextNoteId. @@ -360,17 +237,10 @@ export function currentNoteTools(contextNoteId: string) { }; } -/** - * All available note tools. - */ export const noteTools = { search_notes: searchNotes, read_note: readNote, update_note_content: updateNoteContent, append_to_note: appendToNote, - create_note: createNote, - get_attributes: getAttributes, - get_attribute: getAttribute, - set_attribute: setAttribute, - delete_attribute: deleteAttribute + create_note: createNote }; diff --git a/apps/server/src/services/llm/types.ts b/apps/server/src/services/llm/types.ts index 5253ab9b19..bbac0687b5 100644 --- a/apps/server/src/services/llm/types.ts +++ b/apps/server/src/services/llm/types.ts @@ -69,4 +69,10 @@ export interface LlmProvider { * Get list of available models for this provider. */ getAvailableModels(): ModelInfo[]; + + /** + * Generate a short title summarizing a message. + * Used for auto-renaming chat notes. Should use a fast, cheap model. + */ + generateTitle(firstMessage: string): Promise; } diff --git a/packages/commons/src/lib/llm_api.ts b/packages/commons/src/lib/llm_api.ts index 99a091c785..5f6525bcc6 100644 --- a/packages/commons/src/lib/llm_api.ts +++ b/packages/commons/src/lib/llm_api.ts @@ -41,6 +41,8 @@ export interface LlmChatConfig { thinkingBudget?: number; /** Current note context (note ID the user is viewing) */ contextNoteId?: string; + /** The note ID of the chat note (used for auto-renaming on first message) */ + chatNoteId?: string; } /**