Compare commits

..

7 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
26 changed files with 1183 additions and 58 deletions

View File

@@ -43,7 +43,7 @@
"@univerjs/preset-sheets-note": "0.18.0",
"@univerjs/preset-sheets-sort": "0.18.0",
"@univerjs/presets": "0.18.0",
"@zumer/snapdom": "2.7.0",
"@zumer/snapdom": "2.6.0",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",

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

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

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

@@ -21,7 +21,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",

View File

@@ -22,7 +22,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.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];

140
pnpm-lock.yaml generated
View File

@@ -267,8 +267,8 @@ importers:
specifier: 0.18.0
version: 0.18.0(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@zumer/snapdom':
specifier: 2.7.0
version: 2.7.0
specifier: 2.6.0
version: 2.6.0
autocomplete.js:
specifier: 0.38.1
version: 0.38.1
@@ -552,6 +552,9 @@ importers:
apps/server:
dependencies:
'@anthropic-ai/sdk':
specifier: ^0.39.0
version: 0.39.0(encoding@0.1.13)
better-sqlite3:
specifier: 12.8.0
version: 12.8.0
@@ -947,8 +950,8 @@ importers:
packages/ckeditor5-admonition:
devDependencies:
'@ckeditor/ckeditor5-dev-build-tools':
specifier: 55.3.0
version: 55.3.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)
specifier: 55.2.0
version: 55.2.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)
'@ckeditor/ckeditor5-inspector':
specifier: '>=4.1.0'
version: 5.0.0
@@ -1007,8 +1010,8 @@ importers:
packages/ckeditor5-footnotes:
devDependencies:
'@ckeditor/ckeditor5-dev-build-tools':
specifier: 55.3.0
version: 55.3.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)
specifier: 55.2.0
version: 55.2.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)
'@ckeditor/ckeditor5-inspector':
specifier: '>=4.1.0'
version: 5.0.0
@@ -1067,8 +1070,8 @@ importers:
packages/ckeditor5-keyboard-marker:
devDependencies:
'@ckeditor/ckeditor5-dev-build-tools':
specifier: 55.3.0
version: 55.3.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)
specifier: 55.2.0
version: 55.2.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)
'@ckeditor/ckeditor5-inspector':
specifier: '>=4.1.0'
version: 5.0.0
@@ -1134,8 +1137,8 @@ importers:
version: 0.109.0
devDependencies:
'@ckeditor/ckeditor5-dev-build-tools':
specifier: 55.3.0
version: 55.3.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)
specifier: 55.2.0
version: 55.2.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)
'@ckeditor/ckeditor5-inspector':
specifier: '>=4.1.0'
version: 5.0.0
@@ -1201,8 +1204,8 @@ importers:
version: 4.17.23
devDependencies:
'@ckeditor/ckeditor5-dev-build-tools':
specifier: 55.3.0
version: 55.3.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)
specifier: 55.2.0
version: 55.2.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)
'@ckeditor/ckeditor5-inspector':
specifier: '>=4.1.0'
version: 5.0.0
@@ -1543,6 +1546,9 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@anthropic-ai/sdk@0.39.0':
resolution: {integrity: sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==}
'@apidevtools/json-schema-ref-parser@9.1.2':
resolution: {integrity: sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==}
@@ -1972,8 +1978,8 @@ packages:
'@ckeditor/ckeditor5-core@47.6.1':
resolution: {integrity: sha512-6dtnquhjymLkNhdC9T6gk/Mf2bDnHSTZrhkByaXC96CbmQDriCgfcaAVY6pQgDNxBQ6fZrev0TnKBLfTItrMsg==}
'@ckeditor/ckeditor5-dev-build-tools@55.3.0':
resolution: {integrity: sha512-87WlVerNpgc0xlnnPTKX+1Z/LrTWeueaOQK/XWns/AKJDoGbwUyQo6rhlRsEvDGKdKXOdHXgQijxgh9Yo1I9KQ==}
'@ckeditor/ckeditor5-dev-build-tools@55.2.0':
resolution: {integrity: sha512-pUa3GqCOEb7m5xhbUPV6gKLIgsX/TI3MXT51u0wa+A822ZFVbaXoGd2LissPkuK9WcGfmgU1gT8TzcyFTCTYig==}
engines: {node: '>=24.11.0', npm: '>=5.7.1'}
hasBin: true
@@ -6173,12 +6179,18 @@ packages:
'@types/mute-stream@0.0.4':
resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==}
'@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
'@types/node-forge@1.3.14':
resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==}
'@types/node@16.9.1':
resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@20.19.25':
resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==}
@@ -7207,8 +7219,8 @@ packages:
resolution: {integrity: sha512-0fztsk/0ryJ+2PPr9EyXS5/Co7OK8q3zY/xOoozEWaUsL5x+C0cyZ4YyMuUffOO2Dx/rAdq4JMPqW0VUtm+vzA==}
engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'}
'@zumer/snapdom@2.7.0':
resolution: {integrity: sha512-ZiELKzDszeFOazPQ/ExXzgtdoW9jADVjDjInr5XDAlVdCx0RbNsFiG7RLyM48XnA7EyCA9yTvmXSc3ElDrTRqA==}
'@zumer/snapdom@2.6.0':
resolution: {integrity: sha512-JpPPkuMzozRVX6KArgCiMgLpgVW82kWgyoFk5DWGKE5msWGEshXEUdQHLLEyZRO7qioI1pI+yaBJz81tEP9gPg==}
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
@@ -9729,6 +9741,9 @@ packages:
foreach@2.0.6:
resolution: {integrity: sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
form-data-encoder@4.1.0:
resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==}
engines: {node: '>= 18'}
@@ -9741,6 +9756,10 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
formdata-node@6.0.3:
resolution: {integrity: sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==}
engines: {node: '>= 18'}
@@ -15025,6 +15044,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -15530,6 +15552,10 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
webdriver@9.27.0:
resolution: {integrity: sha512-w07ThZND48SIr0b4S7eFougYUyclmoUwdmju8yXvEJiXYjDjeYUpl8wZrYPEYRBylxpSx+sBHfEUBrPQkcTTRQ==}
engines: {node: '>=18.20.0'}
@@ -15996,6 +16022,18 @@ snapshots:
package-manager-detector: 1.3.0
tinyexec: 1.0.4
'@anthropic-ai/sdk@0.39.0(encoding@0.1.13)':
dependencies:
'@types/node': 18.19.130
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0(encoding@0.1.13)
transitivePeerDependencies:
- encoding
'@apidevtools/json-schema-ref-parser@9.1.2':
dependencies:
'@jsdevtools/ono': 7.1.3
@@ -16781,6 +16819,8 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.6.1
'@ckeditor/ckeditor5-upload': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-ai@47.6.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)':
dependencies:
@@ -16922,12 +16962,16 @@ 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:
'@ckeditor/ckeditor5-core': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-code-block@47.6.1(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)':
dependencies:
@@ -16996,7 +17040,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-dev-build-tools@55.3.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)':
'@ckeditor/ckeditor5-dev-build-tools@55.2.0(@swc/helpers@0.5.17)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)':
dependencies:
'@rollup/plugin-commonjs': 28.0.9(rollup@4.52.0)
'@rollup/plugin-json': 6.1.0(rollup@4.52.0)
@@ -17125,6 +17169,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-decoupled@47.6.1':
dependencies:
@@ -17134,6 +17180,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-inline@47.6.1':
dependencies:
@@ -17143,6 +17191,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-multi-root@47.6.1':
dependencies:
@@ -17190,8 +17240,6 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.6.1
'@ckeditor/ckeditor5-engine': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-essentials@47.6.1':
dependencies:
@@ -17223,8 +17271,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-export-word@47.6.1':
dependencies:
@@ -17249,6 +17295,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-font@47.6.1':
dependencies:
@@ -17324,6 +17372,8 @@ 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:
@@ -17350,6 +17400,8 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-icons@47.6.1': {}
@@ -17381,8 +17433,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-indent@47.6.1':
dependencies:
@@ -17508,8 +17558,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-merge-fields@47.6.1':
dependencies:
@@ -17522,8 +17570,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-minimap@47.6.1':
dependencies:
@@ -17532,8 +17578,6 @@ 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:
@@ -17586,8 +17630,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-pagination@47.6.1':
dependencies:
@@ -17695,8 +17737,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-slash-command@47.6.1':
dependencies:
@@ -17709,8 +17749,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-source-editing-enhanced@47.6.1':
dependencies:
@@ -17758,8 +17796,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-table@47.6.1':
dependencies:
@@ -17772,8 +17808,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-template@47.6.1':
dependencies:
@@ -17883,8 +17917,6 @@ snapshots:
'@ckeditor/ckeditor5-engine': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-widget@47.6.1':
dependencies:
@@ -17904,8 +17936,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@codemirror/autocomplete@6.18.6':
dependencies:
@@ -20234,7 +20264,7 @@ snapshots:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.3
picomatch: 4.0.4
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
@@ -22272,12 +22302,21 @@ snapshots:
dependencies:
'@types/node': 24.12.0
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 24.12.0
form-data: 4.0.5
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 24.12.0
'@types/node@16.9.1': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@20.19.25':
dependencies:
undici-types: 6.21.0
@@ -24376,7 +24415,7 @@ snapshots:
'@zip.js/zip.js@2.8.11': {}
'@zumer/snapdom@2.7.0': {}
'@zumer/snapdom@2.6.0': {}
abab@2.0.6: {}
@@ -27603,6 +27642,8 @@ snapshots:
foreach@2.0.6: {}
form-data-encoder@1.7.2: {}
form-data-encoder@4.1.0: {}
form-data@4.0.5:
@@ -27615,6 +27656,11 @@ snapshots:
format@0.2.2: {}
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
formdata-node@6.0.3: {}
formdata-polyfill@4.0.10:
@@ -33861,6 +33907,8 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
undici-types@5.26.5: {}
undici-types@6.21.0: {}
undici-types@7.16.0: {}
@@ -34375,6 +34423,8 @@ snapshots:
web-streams-polyfill@3.3.3: {}
web-streams-polyfill@4.0.0-beta.3: {}
webdriver@9.27.0(bufferutil@4.0.9)(utf-8-validate@6.0.5):
dependencies:
'@types/node': 20.19.25