Compare commits

...

27 Commits

Author SHA1 Message Date
Elian Doran
b551f0fe2d feat(llm): basic Markdown rendering 2026-03-28 21:19:59 +02:00
Elian Doran
f6e8bdb0fd fix(llm): text not selectable 2026-03-28 21:07:54 +02:00
Elian Doran
9029ea8085 fix(llm): last response not saved 2026-03-28 21:06:20 +02:00
Elian Doran
d61ade9fe9 feat(llm): add basic web search support 2026-03-28 21:00:53 +02:00
Elian Doran
aa1fe549c7 feat(llm): make source viewable 2026-03-28 20:52:40 +02:00
Elian Doran
e3701bbcb4 fix(llm): streaming not working due to compression 2026-03-28 20:45:35 +02:00
Elian Doran
fb7fc4bf0c feat(llm): basic chat interface 2026-03-28 20:39:09 +02:00
Elian Doran
dc50ca157d chore(deps): update dependency electron to v41.1.0 (#9211) 2026-03-28 11:11:11 +02:00
Elian Doran
ff2e775b5e chore(deps): update node.js to v24.14.1 (#9184) 2026-03-28 11:10:44 +02:00
Elian Doran
25df43b0be chore(deps): update dependency vite to v8.0.3 (#9194) 2026-03-28 11:02:24 +02:00
Elian Doran
1af1fcd148 chore(deps): update dependency @redocly/cli to v2.25.2 (#9206) 2026-03-28 10:54:11 +02:00
Elian Doran
516f9aad45 fix(deps): update dependency @preact/signals to v2.9.0 (#9212) 2026-03-28 10:53:55 +02:00
Elian Doran
79a420de0f chore(deps): update dependency express-openid-connect to v2.20.1 (#9207) 2026-03-28 10:50:27 +02:00
Elian Doran
ac213b6664 fix(deps): update dependency katex to v0.16.44 (#9208) 2026-03-28 10:50:01 +02:00
Elian Doran
ff2d74029a chore(deps): update dependency axios to v1.14.0 (#9210) 2026-03-28 10:49:46 +02:00
Elian Doran
31ac1d3f2d fix(deps): update dependency react-i18next to v17 (#9214) 2026-03-28 10:49:21 +02:00
renovate[bot]
2c32382ca6 fix(deps): update dependency react-i18next to v17 2026-03-28 01:18:11 +00:00
renovate[bot]
9904df1611 fix(deps): update dependency @preact/signals to v2.9.0 2026-03-28 01:16:17 +00:00
renovate[bot]
2d945d4fb2 chore(deps): update dependency electron to v41.1.0 2026-03-28 01:15:19 +00:00
renovate[bot]
c1f9a22bf3 chore(deps): update dependency axios to v1.14.0 2026-03-28 01:14:20 +00:00
renovate[bot]
b6435bbfc9 fix(deps): update dependency katex to v0.16.44 2026-03-28 01:12:21 +00:00
renovate[bot]
63387cb958 chore(deps): update dependency express-openid-connect to v2.20.1 2026-03-28 01:11:16 +00:00
renovate[bot]
a8d104ec57 chore(deps): update dependency @redocly/cli to v2.25.2 2026-03-28 01:10:12 +00:00
renovate[bot]
10377b527f chore(deps): update dependency vite to v8.0.3 2026-03-27 17:05:56 +00:00
JYC333
4413566e14 chore(deps): update dependency happy-dom to v20.8.9 (#9192) 2026-03-27 15:46:18 +00:00
renovate[bot]
6c295611cc chore(deps): update node.js to v24.14.1 2026-03-27 06:55:05 +00:00
renovate[bot]
c1c98a6955 chore(deps): update dependency happy-dom to v20.8.9 2026-03-27 06:53:56 +00:00
31 changed files with 1426 additions and 279 deletions

View File

@@ -16,7 +16,7 @@
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.33.0",
"devDependencies": {
"@redocly/cli": "2.25.1",
"@redocly/cli": "2.25.2",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",

View File

@@ -28,7 +28,7 @@
"@mermaid-js/layout-elk": "0.2.1",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.8.2",
"@preact/signals": "2.9.0",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
@@ -58,7 +58,7 @@
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.43",
"katex": "0.16.44",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
@@ -68,7 +68,7 @@
"normalize.css": "8.0.1",
"panzoom": "9.4.4",
"preact": "10.29.0",
"react-i18next": "16.6.6",
"react-i18next": "17.0.0",
"react-window": "2.2.7",
"reveal.js": "6.0.0",
"rrule": "2.8.1",
@@ -86,7 +86,7 @@
"@types/mark.js": "8.11.12",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "14.0.0",
"happy-dom": "20.8.8",
"happy-dom": "20.8.9",
"lightningcss": "1.32.0",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.4.0"

View File

@@ -18,7 +18,7 @@ const RELATION = "relation";
* end user. Those types should be used only for checking against, they are
* not for direct use.
*/
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet";
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet" | "llmChat";
export interface NotePathRecord {
isArchived: boolean;

View File

@@ -0,0 +1,105 @@
import server from "./server.js";
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
}
export interface ChatConfig {
provider?: string;
model?: string;
systemPrompt?: string;
enableWebSearch?: boolean;
}
export interface Citation {
url: string;
title?: string;
}
export interface StreamCallbacks {
onChunk: (text: string) => void;
onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
onToolResult?: (toolName: string, result: string) => void;
onCitation?: (citation: Citation) => void;
onError: (error: string) => void;
onDone: () => void;
}
/**
* Stream a chat completion from the LLM API using Server-Sent Events.
*/
export async function streamChatCompletion(
messages: ChatMessage[],
config: ChatConfig,
callbacks: StreamCallbacks
): Promise<void> {
const headers = await server.getHeaders();
const response = await fetch(`${window.glob.baseApiUrl}llm-chat/stream`, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json"
} as HeadersInit,
body: JSON.stringify({ messages, config })
});
if (!response.ok) {
callbacks.onError(`HTTP ${response.status}: ${response.statusText}`);
return;
}
const reader = response.body?.getReader();
if (!reader) {
callbacks.onError("No response body");
return;
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const data = JSON.parse(line.slice(6));
switch (data.type) {
case "text":
callbacks.onChunk(data.content);
break;
case "tool_use":
callbacks.onToolUse?.(data.toolName, data.toolInput);
break;
case "tool_result":
callbacks.onToolResult?.(data.toolName, data.result);
break;
case "citation":
callbacks.onCitation?.({ url: data.url, title: data.title });
break;
case "error":
callbacks.onError(data.error);
break;
case "done":
callbacks.onDone();
break;
}
} catch (e) {
// Ignore JSON parse errors for partial data
}
}
}
}
} finally {
reader.releaseLock();
}
}

View File

@@ -41,6 +41,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
{ type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), icon: "bxs-network-chart" },
// Misc note types
{ type: "llmChat", mime: "application/json", title: t("note_types.llm-chat"), icon: "bx-message-square-dots" },
{ type: "render", mime: "", title: t("note_types.render-note"), icon: "bx-extension" },
{ type: "search", title: t("note_types.saved-search"), icon: "bx-file-find", static: true },
{ type: "webView", mime: "", title: t("note_types.web-view"), icon: "bx-globe-alt" },

View File

@@ -1599,6 +1599,7 @@
"geo-map": "Geo Map",
"beta-feature": "Beta",
"ai-chat": "AI Chat",
"llm-chat": "AI Chat",
"task-list": "Task List",
"new-feature": "New",
"collections": "Collections",
@@ -1610,6 +1611,15 @@
"toggle-on-hint": "Note is not protected, click to make it protected",
"toggle-off-hint": "Note is protected, click to make it unprotected"
},
"llm_chat": {
"placeholder": "Type a message...",
"send": "Send",
"sending": "Sending...",
"empty_state": "Start a conversation by typing a message below.",
"searching_web": "Searching the web...",
"web_search": "Web search",
"sources": "Sources"
},
"shared_switch": {
"shared": "Shared",
"toggle-on-title": "Share the note",

View File

@@ -12,7 +12,7 @@ import { TypeWidgetProps } from "./type_widgets/type_widget";
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
* for protected session or attachment information.
*/
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole";
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined);
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
@@ -147,5 +147,11 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
className: "note-detail-spreadsheet",
printable: true,
isFullHeight: true
},
llmChat: {
view: () => import("./type_widgets/llm_chat/LlmChat"),
className: "note-detail-llm-chat",
printable: true,
isFullHeight: true
}
};

View File

@@ -85,7 +85,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
);
const isElectron = getIsElectron();
const isMac = getIsMac();
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet"].includes(noteType);
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet", "llmChat"].includes(noteType);
const isSearchOrBook = ["search", "book"].includes(noteType);
const isHelpPage = note.noteId.startsWith("_help");
const [syncServerHost] = useTriliumOption("syncServerHost");

View File

@@ -0,0 +1,79 @@
import { useMemo } from "preact/hooks";
import { marked } from "marked";
import { t } from "../../../services/i18n.js";
import type { Citation } from "../../../services/llm_chat.js";
import "./LlmChat.css";
// Configure marked for safe rendering
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true // GitHub Flavored Markdown
});
interface StoredMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
createdAt: string;
citations?: Citation[];
}
interface Props {
message: StoredMessage;
isStreaming?: boolean;
}
export default function ChatMessage({ message, isStreaming }: Props) {
const roleLabel = message.role === "user" ? "You" : "Assistant";
// Only render markdown for assistant messages
const renderedContent = useMemo(() => {
if (message.role === "assistant") {
return marked.parse(message.content) as string;
}
return null;
}, [message.content, message.role]);
return (
<div className={`llm-chat-message llm-chat-message-${message.role}`}>
<div className="llm-chat-message-role">
{roleLabel}
</div>
<div className="llm-chat-message-content">
{message.role === "assistant" ? (
<>
<div
className="llm-chat-markdown"
dangerouslySetInnerHTML={{ __html: renderedContent || "" }}
/>
{isStreaming && <span className="llm-chat-cursor" />}
</>
) : (
message.content
)}
</div>
{message.citations && message.citations.length > 0 && (
<div className="llm-chat-citations">
<div className="llm-chat-citations-label">
<span className="bx bx-link" />
{t("llm_chat.sources")}
</div>
<ul className="llm-chat-citations-list">
{message.citations.map((citation, idx) => (
<li key={idx}>
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
title={citation.url}
>
{citation.title || new URL(citation.url).hostname}
</a>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,367 @@
.llm-chat-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
box-sizing: border-box;
}
.llm-chat-messages {
flex: 1;
overflow-y: auto;
padding-bottom: 1rem;
}
.llm-chat-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--muted-text-color);
font-style: italic;
}
.llm-chat-message {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 8px;
max-width: 85%;
user-select: text;
}
.llm-chat-message-user {
background: var(--accented-background-color);
margin-left: auto;
}
.llm-chat-message-assistant {
background: var(--main-background-color);
border: 1px solid var(--main-border-color);
margin-right: auto;
}
.llm-chat-message-role {
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 0.8rem;
color: var(--muted-text-color);
}
.llm-chat-message-content {
word-wrap: break-word;
line-height: 1.5;
}
/* Preserve whitespace only for user messages (plain text) */
.llm-chat-message-user .llm-chat-message-content {
white-space: pre-wrap;
}
.llm-chat-cursor {
display: inline-block;
width: 8px;
height: 1.1em;
background: currentColor;
margin-left: 2px;
vertical-align: text-bottom;
animation: llm-chat-blink 1s infinite;
}
@keyframes llm-chat-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* Tool activity indicator */
.llm-chat-tool-activity {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
border-radius: 8px;
background: var(--accented-background-color);
color: var(--muted-text-color);
font-size: 0.9rem;
max-width: 85%;
}
.llm-chat-tool-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--muted-text-color);
border-top-color: transparent;
border-radius: 50%;
animation: llm-chat-spin 0.8s linear infinite;
}
@keyframes llm-chat-spin {
to { transform: rotate(360deg); }
}
/* Citations */
.llm-chat-citations {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-citations-label {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted-text-color);
margin-bottom: 0.25rem;
}
.llm-chat-citations-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.llm-chat-citations-list li {
font-size: 0.8rem;
}
.llm-chat-citations-list a {
color: var(--link-color, #007bff);
text-decoration: none;
padding: 0.125rem 0.5rem;
background: var(--accented-background-color);
border-radius: 4px;
display: inline-block;
}
.llm-chat-citations-list a:hover {
text-decoration: underline;
}
/* Error */
.llm-chat-error {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 8px;
background: var(--danger-background-color, #fee);
border: 1px solid var(--danger-border-color, #fcc);
color: var(--danger-text-color, #c00);
}
/* Input form */
.llm-chat-input-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-input-row {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
.llm-chat-input {
flex: 1;
min-height: 60px;
max-height: 200px;
resize: vertical;
padding: 0.75rem;
border: 1px solid var(--main-border-color);
border-radius: 8px;
font-family: inherit;
font-size: inherit;
background: var(--main-background-color);
color: var(--main-text-color);
}
.llm-chat-input:focus {
outline: none;
border-color: var(--main-selection-color);
box-shadow: 0 0 0 2px var(--main-selection-color-soft, rgba(0, 123, 255, 0.25));
}
.llm-chat-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.llm-chat-send-btn {
padding: 0.75rem 1.5rem;
background: var(--button-background-color);
border: 1px solid var(--button-border-color);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: inherit;
color: var(--button-text-color);
transition: background-color 0.15s ease;
}
.llm-chat-send-btn:hover:not(:disabled) {
background: var(--button-hover-background-color, var(--button-background-color));
}
.llm-chat-send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Options row */
.llm-chat-options {
display: flex;
gap: 1rem;
padding-left: 0.25rem;
}
.llm-chat-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.85rem;
color: var(--muted-text-color);
cursor: pointer;
user-select: none;
}
.llm-chat-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
.llm-chat-toggle .bx {
font-size: 1rem;
}
.llm-chat-toggle:has(input:checked) {
color: var(--main-text-color);
}
.llm-chat-toggle:has(input:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
/* Markdown styles */
.llm-chat-markdown {
line-height: 1.6;
}
.llm-chat-markdown p {
margin: 0 0 0.75em 0;
}
.llm-chat-markdown p:last-child {
margin-bottom: 0;
}
.llm-chat-markdown h1,
.llm-chat-markdown h2,
.llm-chat-markdown h3,
.llm-chat-markdown h4,
.llm-chat-markdown h5,
.llm-chat-markdown h6 {
margin: 1em 0 0.5em 0;
font-weight: 600;
line-height: 1.3;
}
.llm-chat-markdown h1:first-child,
.llm-chat-markdown h2:first-child,
.llm-chat-markdown h3:first-child {
margin-top: 0;
}
.llm-chat-markdown h1 { font-size: 1.4em; }
.llm-chat-markdown h2 { font-size: 1.25em; }
.llm-chat-markdown h3 { font-size: 1.1em; }
.llm-chat-markdown ul,
.llm-chat-markdown ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.llm-chat-markdown li {
margin: 0.25em 0;
}
.llm-chat-markdown code {
background: var(--accented-background-color);
padding: 0.15em 0.4em;
border-radius: 4px;
font-family: var(--monospace-font-family, monospace);
font-size: 0.9em;
}
.llm-chat-markdown pre {
background: var(--accented-background-color);
padding: 0.75em 1em;
border-radius: 6px;
overflow-x: auto;
margin: 0.75em 0;
}
.llm-chat-markdown pre code {
background: none;
padding: 0;
font-size: 0.85em;
}
.llm-chat-markdown blockquote {
margin: 0.75em 0;
padding: 0.5em 1em;
border-left: 3px solid var(--main-border-color);
background: var(--accented-background-color);
}
.llm-chat-markdown blockquote p {
margin: 0;
}
.llm-chat-markdown a {
color: var(--link-color, #007bff);
text-decoration: none;
}
.llm-chat-markdown a:hover {
text-decoration: underline;
}
.llm-chat-markdown hr {
border: none;
border-top: 1px solid var(--main-border-color);
margin: 1em 0;
}
.llm-chat-markdown table {
border-collapse: collapse;
width: 100%;
margin: 0.75em 0;
}
.llm-chat-markdown th,
.llm-chat-markdown td {
border: 1px solid var(--main-border-color);
padding: 0.5em 0.75em;
text-align: left;
}
.llm-chat-markdown th {
background: var(--accented-background-color);
font-weight: 600;
}
.llm-chat-markdown strong {
font-weight: 600;
}
.llm-chat-markdown em {
font-style: italic;
}

View File

@@ -0,0 +1,249 @@
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import { streamChatCompletion, type ChatMessage as ChatMessageData, type Citation } from "../../../services/llm_chat.js";
import { useEditorSpacedUpdate } from "../../react/hooks.js";
import { TypeWidgetProps } from "../type_widget.js";
import ChatMessage from "./ChatMessage.js";
import "./LlmChat.css";
interface StoredMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
createdAt: string;
citations?: Citation[];
}
interface LlmChatContent {
version: 1;
messages: StoredMessage[];
enableWebSearch?: boolean;
}
export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
const [messages, setMessages] = useState<StoredMessage[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [toolActivity, setToolActivity] = useState<string | null>(null);
const [pendingCitations, setPendingCitations] = useState<Citation[]>([]);
const [enableWebSearch, setEnableWebSearch] = useState(true);
const [error, setError] = useState<string | null>(null);
const [shouldSave, setShouldSave] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, toolActivity, scrollToBottom]);
// Use a ref to store the latest messages for getData
const messagesRef = useRef(messages);
messagesRef.current = messages;
const enableWebSearchRef = useRef(enableWebSearch);
enableWebSearchRef.current = enableWebSearch;
const spacedUpdate = useEditorSpacedUpdate({
note,
noteType: "llmChat",
noteContext,
getData: () => {
// Use refs to get the latest values, avoiding stale closure issues
const content: LlmChatContent = {
version: 1,
messages: messagesRef.current,
enableWebSearch: enableWebSearchRef.current
};
return { content: JSON.stringify(content) };
},
onContentChange: (content) => {
if (!content) {
setMessages([]);
return;
}
try {
const parsed: LlmChatContent = JSON.parse(content);
setMessages(parsed.messages || []);
if (typeof parsed.enableWebSearch === "boolean") {
setEnableWebSearch(parsed.enableWebSearch);
}
} catch (e) {
console.error("Failed to parse LLM chat content:", e);
setMessages([]);
}
}
});
// Trigger save after state updates when shouldSave is set
useEffect(() => {
if (shouldSave) {
setShouldSave(false);
spacedUpdate.scheduleUpdate();
}
}, [shouldSave, spacedUpdate]);
const handleSubmit = useCallback(async (e: Event) => {
e.preventDefault();
if (!input.trim() || isStreaming) return;
setError(null);
setToolActivity(null);
setPendingCitations([]);
const userMessage: StoredMessage = {
id: crypto.randomUUID(),
role: "user",
content: input.trim(),
createdAt: new Date().toISOString()
};
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput("");
setIsStreaming(true);
setStreamingContent("");
let assistantContent = "";
const citations: Citation[] = [];
const apiMessages: ChatMessageData[] = newMessages.map(m => ({
role: m.role,
content: m.content
}));
await streamChatCompletion(
apiMessages,
{ enableWebSearch },
{
onChunk: (text) => {
assistantContent += text;
setStreamingContent(assistantContent);
setToolActivity(null); // Clear tool activity when text starts
},
onToolUse: (toolName, _input) => {
const toolLabel = toolName === "web_search"
? t("llm_chat.searching_web")
: `Using ${toolName}...`;
setToolActivity(toolLabel);
},
onCitation: (citation) => {
citations.push(citation);
setPendingCitations([...citations]);
},
onError: (errorMsg) => {
console.error("Chat error:", errorMsg);
setError(errorMsg);
setIsStreaming(false);
setToolActivity(null);
},
onDone: () => {
if (assistantContent) {
const assistantMessage: StoredMessage = {
id: crypto.randomUUID(),
role: "assistant",
content: assistantContent,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined
};
setMessages(prev => [...prev, assistantMessage]);
}
setStreamingContent("");
setPendingCitations([]);
setIsStreaming(false);
setToolActivity(null);
// Trigger save after state updates via useEffect
setShouldSave(true);
}
}
);
}, [input, isStreaming, messages, enableWebSearch, spacedUpdate]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}, [handleSubmit]);
const toggleWebSearch = useCallback(() => {
setEnableWebSearch(prev => !prev);
setShouldSave(true);
}, []);
return (
<div className="llm-chat-container">
<div className="llm-chat-messages">
{messages.length === 0 && !isStreaming && (
<div className="llm-chat-empty">
{t("llm_chat.empty_state")}
</div>
)}
{messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{toolActivity && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{toolActivity}
</div>
)}
{isStreaming && streamingContent && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: streamingContent,
createdAt: new Date().toISOString(),
citations: pendingCitations.length > 0 ? pendingCitations : undefined
}}
isStreaming
/>
)}
{error && (
<div className="llm-chat-error">
{error}
</div>
)}
<div ref={messagesEndRef} />
</div>
<form className="llm-chat-input-form" onSubmit={handleSubmit}>
<div className="llm-chat-input-row">
<textarea
ref={textareaRef}
className="llm-chat-input"
value={input}
onInput={(e) => setInput((e.target as HTMLTextAreaElement).value)}
placeholder={t("llm_chat.placeholder")}
disabled={isStreaming}
onKeyDown={handleKeyDown}
rows={3}
/>
<button
type="submit"
className="llm-chat-send-btn"
disabled={isStreaming || !input.trim()}
>
{isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
</button>
</div>
<div className="llm-chat-options">
<label className="llm-chat-toggle">
<input
type="checkbox"
checked={enableWebSearch}
onChange={toggleWebSearch}
disabled={isStreaming}
/>
<span className="bx bx-globe" />
{t("llm_chat.web_search")}
</label>
</div>
</form>
</div>
);
}

View File

@@ -35,7 +35,7 @@
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "14.0.0",
"electron": "41.0.4",
"electron": "41.1.0",
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-dmg": "7.11.1",

View File

@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "41.0.4",
"electron": "41.1.0",
"fs-extra": "11.3.4"
},
"scripts": {

View File

@@ -1,4 +1,4 @@
FROM node:24.14.0-bullseye-slim AS builder
FROM node:24.14.1-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.0-bullseye-slim
FROM node:24.14.1-bullseye-slim
# Install only runtime dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \

View File

@@ -1,4 +1,4 @@
FROM node:24.14.0-alpine AS builder
FROM node:24.14.1-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.0-alpine
FROM node:24.14.1-alpine
# Install runtime dependencies
RUN apk add --no-cache su-exec shadow

View File

@@ -1,4 +1,4 @@
FROM node:24.14.0-alpine AS builder
FROM node:24.14.1-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.0-alpine
FROM node:24.14.1-alpine
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -1,4 +1,4 @@
FROM node:24.14.0-bullseye-slim AS builder
FROM node:24.14.1-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.0-bullseye-slim
FROM node:24.14.1-bullseye-slim
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -30,6 +30,7 @@
"proxy-nginx-subdir": "docker run --name trilium-nginx-subdir --rm --network=host -v ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:latest"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"better-sqlite3": "12.8.0",
"html-to-text": "9.0.5",
"node-html-parser": "7.1.0",
@@ -70,7 +71,7 @@
"@types/xml2js": "0.4.14",
"archiver": "7.0.1",
"async-mutex": "0.5.0",
"axios": "1.13.6",
"axios": "1.14.0",
"bindings": "1.5.0",
"bootstrap": "5.3.8",
"chardet": "2.1.1",
@@ -83,13 +84,13 @@
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "5.0.1",
"electron": "41.0.4",
"electron": "41.1.0",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"express": "5.2.1",
"express-http-proxy": "2.1.2",
"express-openid-connect": "2.20.0",
"express-openid-connect": "2.20.1",
"express-rate-limit": "8.3.1",
"express-session": "1.19.0",
"file-uri-to-path": "2.0.0",
@@ -126,7 +127,7 @@
"tmp": "0.2.5",
"turnish": "1.8.0",
"unescape": "1.0.1",
"vite": "8.0.2",
"vite": "8.0.3",
"ws": "8.20.0",
"xml2js": "0.6.2",
"yauzl": "3.2.1"

View File

@@ -55,7 +55,16 @@ export default async function buildApp() {
});
if (!utils.isElectron) {
app.use(compression()); // HTTP compression
app.use(compression({
// Skip compression for SSE endpoints to enable real-time streaming
filter: (req, res) => {
// Skip compression for LLM chat streaming endpoint
if (req.path === "/api/llm-chat/stream") {
return false;
}
return compression.filter(req, res);
}
}));
}
let resourcePolicy = config["Network"]["corsResourcePolicy"] as 'same-origin' | 'same-site' | 'cross-origin' | undefined;

View File

@@ -0,0 +1,62 @@
import type { Request, Response } from "express";
import { getProvider, type LlmMessage, type LlmProviderConfig } from "../../services/llm/index.js";
interface ChatRequest {
messages: LlmMessage[];
config?: LlmProviderConfig;
}
/**
* SSE endpoint for streaming chat completions.
*
* Response format (Server-Sent Events):
* data: {"type":"text","content":"Hello"}
* data: {"type":"text","content":" world"}
* data: {"type":"done"}
*
* On error:
* data: {"type":"error","error":"Error message"}
*/
async function streamChat(req: Request, res: Response) {
const { messages, config = {} } = req.body as ChatRequest;
if (!messages || !Array.isArray(messages) || messages.length === 0) {
res.status(400).json({ error: "messages array is required" });
return;
}
// Set up SSE headers - disable compression and buffering for real-time streaming
res.setHeader("Content-Type", "text/event-stream");
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
(res as any).triliumResponseHandled = true;
// Type assertion for flush method (available when compression is used)
const flushableRes = res as Response & { flush?: () => void };
try {
const provider = getProvider(config.provider || "anthropic");
for await (const chunk of provider.streamCompletion(messages, config)) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
// Flush immediately to ensure real-time streaming
if (typeof flushableRes.flush === "function") {
flushableRes.flush();
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
res.write(`data: ${JSON.stringify({ type: "error", error: errorMessage })}\n\n`);
} finally {
res.end();
}
}
export default {
streamChat
};

View File

@@ -34,6 +34,7 @@ import fontsRoute from "./api/fonts.js";
import imageRoute from "./api/image.js";
import importRoute from "./api/import.js";
import keysRoute from "./api/keys.js";
import llmChatRoute from "./api/llm_chat.js";
import loginApiRoute from "./api/login.js";
import metricsRoute from "./api/metrics.js";
import noteMapRoute from "./api/note_map.js";
@@ -323,6 +324,9 @@ function register(app: express.Application) {
apiRoute(PST, "/api/script/bundle/:noteId", scriptRoute.getBundle);
apiRoute(GET, "/api/script/relation/:noteId/:relationName", scriptRoute.getRelationBundles);
// LLM chat streaming endpoint (SSE)
asyncRoute(PST, "/api/llm-chat/stream", [auth.checkApiAuth, csrfMiddleware], llmChatRoute.streamChat, null);
// no CSRF since this is called from android app
route(PST, "/api/sender/login", [loginRateLimiter], loginApiRoute.token, apiResultHandler);
asyncRoute(PST, "/api/sender/image", [auth.checkEtapiToken, uploadMiddlewareWithErrorHandling], senderRoute.uploadImage, apiResultHandler);

View File

@@ -0,0 +1,26 @@
import type { LlmProvider } from "./types.js";
import { AnthropicProvider } from "./providers/anthropic.js";
const providers: Record<string, () => LlmProvider> = {
anthropic: () => new AnthropicProvider()
// Future providers can be added here
};
let cachedProviders: Record<string, LlmProvider> = {};
export function getProvider(name: string = "anthropic"): LlmProvider {
if (!cachedProviders[name]) {
const factory = providers[name];
if (!factory) {
throw new Error(`Unknown LLM provider: ${name}. Available: ${Object.keys(providers).join(", ")}`);
}
cachedProviders[name] = factory();
}
return cachedProviders[name];
}
export function clearProviderCache(): void {
cachedProviders = {};
}
export * from "./types.js";

View File

@@ -0,0 +1,110 @@
import Anthropic from "@anthropic-ai/sdk";
import type { LlmProvider, LlmMessage, LlmStreamChunk, LlmProviderConfig } from "../types.js";
const DEFAULT_MODEL = "claude-sonnet-4-20250514";
const DEFAULT_MAX_TOKENS = 8096;
export class AnthropicProvider implements LlmProvider {
name = "anthropic";
private client: Anthropic;
constructor() {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error("ANTHROPIC_API_KEY environment variable is required");
}
this.client = new Anthropic({ apiKey });
}
async *streamCompletion(
messages: LlmMessage[],
config: LlmProviderConfig
): AsyncIterable<LlmStreamChunk> {
const systemPrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content;
const chatMessages = messages.filter(m => m.role !== "system");
// Build tools array - using 'unknown' assertion for server-side tools
// that may not be in the SDK types yet
const tools: unknown[] = [];
if (config.enableWebSearch) {
tools.push({
type: "web_search_20250305",
name: "web_search",
max_uses: 5 // Limit searches per request
});
}
try {
// Cast tools to any since server-side tools may not be in SDK types yet
const streamParams: Anthropic.Messages.MessageStreamParams = {
model: config.model || DEFAULT_MODEL,
max_tokens: config.maxTokens || DEFAULT_MAX_TOKENS,
system: systemPrompt,
messages: chatMessages.map(m => ({
role: m.role as "user" | "assistant",
content: m.content
}))
};
if (tools.length > 0) {
(streamParams as any).tools = tools;
}
const stream = this.client.messages.stream(streamParams);
for await (const event of stream) {
// Handle different event types
if (event.type === "content_block_start") {
const block = event.content_block;
if (block.type === "tool_use") {
yield {
type: "tool_use",
toolName: block.name,
toolInput: {} // Input comes in deltas
};
}
} else if (event.type === "content_block_delta") {
const delta = event.delta;
if (delta.type === "text_delta") {
yield { type: "text", content: delta.text };
} else if (delta.type === "input_json_delta") {
// Tool input is being streamed - we could accumulate it
// For now, we already emitted tool_use at start
}
} else if (event.type === "content_block_stop") {
// Content block finished
// For server-side tools, results come in subsequent blocks
}
// Handle server-side tool results (for web_search)
// These appear as special content blocks in the response
if (event.type === "message_delta") {
// Check for citations in stop_reason or other metadata
}
}
// Get the final message to extract any citations
const finalMessage = await stream.finalMessage();
for (const block of finalMessage.content) {
if (block.type === "text") {
// Check for citations in the text block
// Anthropic returns citations as part of the content
if ("citations" in block && Array.isArray((block as any).citations)) {
for (const citation of (block as any).citations) {
yield {
type: "citation",
url: citation.url || citation.source,
title: citation.title
};
}
}
}
}
yield { type: "done" };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
yield { type: "error", error: message };
}
}
}

View File

@@ -0,0 +1,43 @@
/**
* LLM Provider types for chat integration.
* Provider-agnostic interfaces to support multiple LLM backends.
*/
export interface LlmMessage {
role: "user" | "assistant" | "system";
content: string;
}
/**
* Stream chunk types for real-time updates.
*/
export type LlmStreamChunk =
| { type: "text"; content: string }
| { type: "tool_use"; toolName: string; toolInput: Record<string, unknown> }
| { type: "tool_result"; toolName: string; result: string }
| { type: "citation"; url: string; title?: string }
| { type: "error"; error: string }
| { type: "done" };
export interface LlmProviderConfig {
provider?: string;
model?: string;
maxTokens?: number;
temperature?: number;
systemPrompt?: string;
/** Enable web search tool */
enableWebSearch?: boolean;
}
export interface LlmProvider {
name: string;
/**
* Stream a chat completion response.
* Yields chunks as they arrive from the LLM.
*/
streamCompletion(
messages: LlmMessage[],
config: LlmProviderConfig
): AsyncIterable<LlmStreamChunk>;
}

View File

@@ -15,7 +15,8 @@ const noteTypes = [
{ type: "doc", defaultMime: "" },
{ type: "contentWidget", defaultMime: "" },
{ type: "mindMap", defaultMime: "application/json" },
{ type: "spreadsheet", defaultMime: "application/json" }
{ type: "spreadsheet", defaultMime: "application/json" },
{ type: "llmChat", defaultMime: "application/json" }
];
function getDefaultMimeForNoteType(typeName: string) {

View File

@@ -14,7 +14,7 @@
"preact": "10.29.0",
"preact-iso": "2.11.1",
"preact-render-to-string": "6.6.6",
"react-i18next": "16.6.6"
"react-i18next": "17.0.0"
},
"devDependencies": {
"@preact/preset-vite": "2.10.5",
@@ -22,7 +22,7 @@
"eslint-config-preact": "2.0.0",
"typescript": "5.9.3",
"user-agent-data-types": "0.4.3",
"vite": "8.0.2",
"vite": "8.0.3",
"vitest": "4.1.2"
},
"eslintConfig": {

View File

@@ -64,7 +64,7 @@
"eslint-config-prettier": "10.1.8",
"eslint-plugin-playwright": "2.10.1",
"eslint-plugin-simple-import-sort": "12.1.1",
"happy-dom": "20.8.8",
"happy-dom": "20.8.9",
"http-server": "14.1.1",
"jiti": "2.6.1",
"js-yaml": "4.1.1",
@@ -76,7 +76,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.2",
"upath": "2.0.1",
"vite": "8.0.2",
"vite": "8.0.3",
"vite-plugin-dts": "4.5.4",
"vitest": "4.1.2"
},

View File

@@ -21,7 +21,8 @@ export const NOTE_TYPE_ICONS = {
doc: "bx bxs-file-doc",
contentWidget: "bx bxs-widget",
mindMap: "bx bx-sitemap",
spreadsheet: "bx bx-table"
spreadsheet: "bx bx-table",
llmChat: "bx bx-message-square-dots"
};
const FILE_MIME_MAPPINGS = {

View File

@@ -122,7 +122,8 @@ export const ALLOWED_NOTE_TYPES = [
"webView",
"code",
"mindMap",
"spreadsheet"
"spreadsheet",
"llmChat"
] as const;
export type NoteType = (typeof ALLOWED_NOTE_TYPES)[number];

View File

@@ -25,7 +25,7 @@
"license": "Apache-2.0",
"dependencies": {
"fuse.js": "7.1.0",
"katex": "0.16.43",
"katex": "0.16.44",
"mermaid": "11.13.0"
},
"devDependencies": {

568
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff