mirror of
https://github.com/zadam/trilium.git
synced 2026-05-09 15:26:23 +02:00
feat(llm): basic auto-title
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
|
||||
28
apps/server/src/services/llm/chat_title.ts
Normal file
28
apps/server/src/services/llm/chat_title.ts
Normal 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}"`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
137
apps/server/src/services/llm/tools/attribute_tools.ts
Normal file
137
apps/server/src/services/llm/tools/attribute_tools.ts
Normal 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
|
||||
};
|
||||
7
apps/server/src/services/llm/tools/index.ts
Normal file
7
apps/server/src/services/llm/tools/index.ts
Normal 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";
|
||||
@@ -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
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user