feat(llm): basic auto-title

This commit is contained in:
Elian Doran
2026-03-30 18:52:22 +03:00
parent 5feccae2a0
commit b4fcf41420
11 changed files with 249 additions and 144 deletions

View File

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

View File

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

View File

@@ -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<void>;
@@ -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<StoredMessage[]>([]);
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<string | undefined>(initialContextNoteId);
const [chatNoteId, setChatNoteIdState] = useState<string | undefined>(initialChatNoteId);
const [lastPromptTokens, setLastPromptTokens] = useState<number>(0);
const [hasProvider, setHasProvider] = useState<boolean>(true); // Assume true initially
const [isCheckingProvider, setIsCheckingProvider] = useState<boolean>(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,

View File

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

View File

@@ -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<void> {
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}"`);
}
}

View File

@@ -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<string> {
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();
}
}

View File

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

View File

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

View File

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

View File

@@ -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<string>;
}

View File

@@ -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;
}
/**