diff --git a/apps/client/package.json b/apps/client/package.json index 54569f7588..452dc0d7d0 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -81,7 +81,6 @@ "@ckeditor/ckeditor5-inspector": "5.0.0", "@prefresh/vite": "2.4.12", "@types/bootstrap": "5.2.10", - "@types/dompurify": "3.2.0", "@types/jquery": "4.0.0", "@types/leaflet": "1.9.21", "@types/leaflet-gpx": "1.3.8", diff --git a/apps/client/src/widgets/sidebar/SidebarChat.tsx b/apps/client/src/widgets/sidebar/SidebarChat.tsx index 2386e447fb..096da4e624 100644 --- a/apps/client/src/widgets/sidebar/SidebarChat.tsx +++ b/apps/client/src/widgets/sidebar/SidebarChat.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import dateNoteService, { type RecentLlmChat } from "../../services/date_notes.js"; import { t } from "../../services/i18n.js"; import server from "../../services/server.js"; +import { formatDateTime } from "../../utils/formatters"; import ActionButton from "../react/ActionButton.js"; import Dropdown from "../react/Dropdown.js"; import { FormListItem } from "../react/FormList.js"; @@ -80,6 +81,13 @@ export default function SidebarChat() { console.error("Failed to save chat:", err); } }, 500); + + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = undefined; + } + }; }, [shouldSave, chatNoteId, chat]); // Load the most recent chat on mount (runs once) @@ -254,7 +262,7 @@ export default function SidebarChat() { ? {chatItem.title} : {chatItem.title}} - {new Date(chatItem.dateModified).toLocaleDateString()} + {formatDateTime(new Date(chatItem.dateModified), "short", "short")} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx index 76539667a5..e450521a1c 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -1,6 +1,6 @@ import "./LlmChat.css"; -import { marked } from "marked"; +import { Marked } from "marked"; import { useMemo } from "preact/hooks"; import { t } from "../../../services/i18n.js"; @@ -15,14 +15,14 @@ function shortenNumber(n: number): string { } // Configure marked for safe rendering -marked.setOptions({ +const markedInstance = new Marked({ breaks: true, // Convert \n to
gfm: true // GitHub Flavored Markdown }); /** Parse markdown to HTML. Sanitization is handled by SanitizedHtml. */ function renderMarkdown(markdown: string): string { - return marked.parse(markdown) as string; + return markedInstance.parse(markdown) as string; } interface Props { 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 b3088db20b..eb5033cc73 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts +++ b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts @@ -1,10 +1,11 @@ import type { LlmCitation, LlmMessage, LlmModelInfo, LlmUsage } from "@triliumnext/commons"; +import { RefObject } from "preact"; import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { t } from "../../../services/i18n.js"; import { getAvailableModels, streamChatCompletion } from "../../../services/llm_chat.js"; import { randomString } from "../../../services/utils.js"; -import type { ContentBlock, LlmChatContent, StoredMessage, ToolCall } from "./llm_chat_types.js"; +import type { ContentBlock, LlmChatContent, StoredMessage } from "./llm_chat_types.js"; export interface ModelOption extends LlmModelInfo { costDescription?: string; @@ -37,8 +38,8 @@ export interface UseLlmChatReturn { enableExtendedThinking: boolean; contextNoteId: string | undefined; lastPromptTokens: number; - messagesEndRef: React.RefObject; - textareaRef: React.RefObject; + messagesEndRef: RefObject; + textareaRef: RefObject; /** Whether a provider is configured and available */ hasProvider: boolean; /** Whether we're still checking for providers */ diff --git a/apps/server/src/routes/api/llm_chat.ts b/apps/server/src/routes/api/llm_chat.ts index 9f81c2e4fd..8302687e13 100644 --- a/apps/server/src/routes/api/llm_chat.ts +++ b/apps/server/src/routes/api/llm_chat.ts @@ -1,9 +1,11 @@ -import type { Request, Response } from "express"; import type { LlmMessage } from "@triliumnext/commons"; +import type { Request, Response } from "express"; +import { generateChatTitle } from "../../services/llm/chat_title.js"; 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"; +import log from "../../services/log.js"; +import { safeExtractMessageAndStackFromError } from "../../services/utils.js"; interface ChatRequest { messages: LlmMessage[]; @@ -34,7 +36,6 @@ async function streamChat(req: Request, res: Response) { res.setHeader("Cache-Control", "no-cache, no-transform"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering - res.setHeader("Content-Encoding", "none"); // Disable compression res.flushHeaders(); // Mark response as handled to prevent double-handling by apiResultHandler @@ -46,7 +47,6 @@ async function streamChat(req: Request, res: Response) { try { if (!hasConfiguredProviders()) { res.write(`data: ${JSON.stringify({ type: "error", error: "No LLM providers configured. Please add a provider in Options → AI / LLM." })}\n\n`); - res.end(); return; } @@ -54,7 +54,12 @@ async function streamChat(req: Request, res: Response) { const result = provider.chat(messages, config); // Get pricing and display name for the model - const modelId = config.model || "claude-sonnet-4-6"; + const modelId = config.model || provider.getAvailableModels().find(m => m.isDefault)?.id; + if (!modelId) { + res.write(`data: ${JSON.stringify({ type: "error", error: "No model specified and no default model available for the provider." })}\n\n`); + return; + } + const pricing = provider.getModelPricing(modelId); const modelDisplayName = provider.getAvailableModels().find(m => m.id === modelId)?.name || modelId; for await (const chunk of streamToChunks(result, { model: modelDisplayName, pricing })) { @@ -71,7 +76,7 @@ async function streamChat(req: Request, res: Response) { 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); + log.error(`Failed to generate chat title: ${safeExtractMessageAndStackFromError(err)}`); } } } catch (error) { diff --git a/apps/server/src/services/llm/tools/note_tools.ts b/apps/server/src/services/llm/tools/note_tools.ts index 2f04811377..90eb475e16 100644 --- a/apps/server/src/services/llm/tools/note_tools.ts +++ b/apps/server/src/services/llm/tools/note_tools.ts @@ -56,7 +56,7 @@ export const searchNotes = tool({ if (!note) return null; return { noteId: note.noteId, - title: note.title, + title: note.getTitleOrProtected(), type: note.type }; }).filter(Boolean); @@ -76,13 +76,13 @@ export const readNote = tool({ if (!note) { return { error: "Note not found" }; } - if (note.isProtected) { + if (!note.isContentAvailable()) { return { error: "Note is protected" }; } return { noteId: note.noteId, - title: note.title, + title: note.getTitleOrProtected(), type: note.type, content: getNoteContentForLlm(note) }; @@ -103,7 +103,7 @@ export const updateNoteContent = tool({ if (!note) { return { error: "Note not found" }; } - if (note.isProtected) { + if (!note.isContentAvailable()) { return { error: "Note is protected and cannot be modified" }; } if (!note.hasStringContent()) { @@ -115,7 +115,7 @@ export const updateNoteContent = tool({ return { success: true, noteId: note.noteId, - title: note.title + title: note.getTitleOrProtected() }; } }); @@ -134,7 +134,7 @@ export const appendToNote = tool({ if (!note) { return { error: "Note not found" }; } - if (note.isProtected) { + if (!note.isContentAvailable()) { return { error: "Note is protected and cannot be modified" }; } if (!note.hasStringContent()) { @@ -148,7 +148,7 @@ export const appendToNote = tool({ let newContent: string; if (note.type === "text") { - const htmlToAppend = markdownImport.renderToHtml(content, note.title); + const htmlToAppend = markdownImport.renderToHtml(content, note.getTitleOrProtected()); newContent = existingContent + htmlToAppend; } else { newContent = existingContent + (existingContent.endsWith("\n") ? "" : "\n") + content; @@ -159,7 +159,7 @@ export const appendToNote = tool({ return { success: true, noteId: note.noteId, - title: note.title + title: note.getTitleOrProtected() }; } }); @@ -180,7 +180,7 @@ export const createNote = tool({ if (!parentNote) { return { error: "Parent note not found" }; } - if (parentNote.isProtected) { + if (!parentNote.isContentAvailable()) { return { error: "Cannot create note under a protected parent" }; } @@ -199,7 +199,7 @@ export const createNote = tool({ return { success: true, noteId: note.noteId, - title: note.title, + title: note.getTitleOrProtected(), type: note.type }; } catch (err) { @@ -222,13 +222,13 @@ export function currentNoteTools(contextNoteId: string) { if (!note) { return { error: "Note not found" }; } - if (note.isProtected) { + if (!note.isContentAvailable()) { return { error: "Note is protected" }; } return { noteId: note.noteId, - title: note.title, + title: note.getTitleOrProtected(), type: note.type, content: getNoteContentForLlm(note) }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52c4dbc834..87680d1bbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -376,9 +376,6 @@ importers: '@types/bootstrap': specifier: 5.2.10 version: 5.2.10 - '@types/dompurify': - specifier: 3.2.0 - version: 3.2.0 '@types/jquery': specifier: 4.0.0 version: 4.0.0 @@ -6067,10 +6064,6 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/dompurify@3.2.0': - resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} - deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. - '@types/ejs@3.1.5': resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} @@ -17004,8 +16997,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 '@ckeditor/ckeditor5-widget': 47.6.1 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-cloud-services@47.6.1': dependencies: @@ -17390,8 +17381,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-highlight@47.6.1': dependencies: @@ -17401,8 +17390,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-horizontal-line@47.6.1': dependencies: @@ -17412,8 +17399,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 '@ckeditor/ckeditor5-widget': 47.6.1 ckeditor5: 47.6.1 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-html-embed@47.6.1': dependencies: @@ -17423,8 +17408,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 '@ckeditor/ckeditor5-widget': 47.6.1 ckeditor5: 47.6.1 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-html-support@47.6.1': dependencies: @@ -17604,6 +17587,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-operations-compressor@47.6.1': dependencies: @@ -22170,10 +22155,6 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/dompurify@3.2.0': - dependencies: - dompurify: 3.3.3 - '@types/ejs@3.1.5': {} '@types/electron-squirrel-startup@1.0.2': {}