From fb7fc4bf0c25b1b0cb46b255b8e689b57794db7c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 28 Mar 2026 20:39:09 +0200 Subject: [PATCH 001/100] feat(llm): basic chat interface --- apps/client/src/entities/fnote.ts | 2 +- apps/client/src/services/llm_chat.ts | 87 +++++++++ apps/client/src/services/note_types.ts | 1 + .../src/translations/en/translation.json | 7 + apps/client/src/widgets/note_types.tsx | 8 +- .../type_widgets/llm_chat/ChatMessage.tsx | 29 +++ .../widgets/type_widgets/llm_chat/LlmChat.css | 131 +++++++++++++ .../widgets/type_widgets/llm_chat/LlmChat.tsx | 177 ++++++++++++++++++ apps/server/package.json | 1 + apps/server/src/routes/api/llm_chat.ts | 54 ++++++ apps/server/src/routes/routes.ts | 4 + apps/server/src/services/llm/index.ts | 26 +++ .../src/services/llm/providers/anthropic.ts | 49 +++++ apps/server/src/services/llm/types.ts | 36 ++++ apps/server/src/services/note_types.ts | 3 +- packages/commons/src/lib/notes.ts | 3 +- packages/commons/src/lib/rows.ts | 3 +- pnpm-lock.yaml | 104 +++++++--- 18 files changed, 693 insertions(+), 32 deletions(-) create mode 100644 apps/client/src/services/llm_chat.ts create mode 100644 apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx create mode 100644 apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css create mode 100644 apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx create mode 100644 apps/server/src/routes/api/llm_chat.ts create mode 100644 apps/server/src/services/llm/index.ts create mode 100644 apps/server/src/services/llm/providers/anthropic.ts create mode 100644 apps/server/src/services/llm/types.ts diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 4082671b87..12fd311bec 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -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; diff --git a/apps/client/src/services/llm_chat.ts b/apps/client/src/services/llm_chat.ts new file mode 100644 index 0000000000..759ed74773 --- /dev/null +++ b/apps/client/src/services/llm_chat.ts @@ -0,0 +1,87 @@ +import server from "./server.js"; + +export interface ChatMessage { + role: "user" | "assistant" | "system"; + content: string; +} + +export interface ChatConfig { + provider?: string; + model?: string; + systemPrompt?: string; +} + +export interface StreamCallbacks { + onChunk: (text: string) => 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 { + 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 "error": + callbacks.onError(data.error); + break; + case "done": + callbacks.onDone(); + break; + } + } catch (e) { + // Ignore JSON parse errors for partial data + } + } + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/apps/client/src/services/note_types.ts b/apps/client/src/services/note_types.ts index 0047439c82..84d4997d0f 100644 --- a/apps/client/src/services/note_types.ts +++ b/apps/client/src/services/note_types.ts @@ -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" }, diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 27891a02ab..caca3397fe 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -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,12 @@ "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." + }, "shared_switch": { "shared": "Shared", "toggle-on-title": "Share the note", diff --git a/apps/client/src/widgets/note_types.tsx b/apps/client/src/widgets/note_types.tsx index b80d4d545e..15023fbcf0 100644 --- a/apps/client/src/widgets/note_types.tsx +++ b/apps/client/src/widgets/note_types.tsx @@ -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 | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole"; +export type ExtendedNoteType = Exclude | "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 = { 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 } }; diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx new file mode 100644 index 0000000000..337bc4188b --- /dev/null +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -0,0 +1,29 @@ +import "./LlmChat.css"; + +interface StoredMessage { + id: string; + role: "user" | "assistant" | "system"; + content: string; + createdAt: string; +} + +interface Props { + message: StoredMessage; + isStreaming?: boolean; +} + +export default function ChatMessage({ message, isStreaming }: Props) { + const roleLabel = message.role === "user" ? "You" : "Assistant"; + + return ( +
+
+ {roleLabel} +
+
+ {message.content} + {isStreaming && } +
+
+ ); +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css new file mode 100644 index 0000000000..ce9436f74f --- /dev/null +++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css @@ -0,0 +1,131 @@ +.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%; +} + +.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 { + white-space: pre-wrap; + word-wrap: break-word; + line-height: 1.5; +} + +.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; } +} + +.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); +} + +.llm-chat-input-form { + display: flex; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--main-border-color); + 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; +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx new file mode 100644 index 0000000000..3e20e32a55 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { t } from "../../../services/i18n.js"; +import { streamChatCompletion, type ChatMessage as ChatMessageData } 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; +} + +interface LlmChatContent { + version: 1; + messages: StoredMessage[]; +} + +const EMPTY_CONTENT: LlmChatContent = { version: 1, messages: [] }; + +export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingContent, setStreamingContent] = useState(""); + const [error, setError] = useState(null); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, streamingContent, scrollToBottom]); + + const spacedUpdate = useEditorSpacedUpdate({ + note, + noteType: "llmChat", + noteContext, + getData: () => { + const content: LlmChatContent = { version: 1, messages }; + return { content: JSON.stringify(content) }; + }, + onContentChange: (content) => { + if (!content) { + setMessages([]); + return; + } + try { + const parsed: LlmChatContent = JSON.parse(content); + setMessages(parsed.messages || []); + } catch (e) { + console.error("Failed to parse LLM chat content:", e); + setMessages([]); + } + } + }); + + const handleSubmit = useCallback(async (e: Event) => { + e.preventDefault(); + if (!input.trim() || isStreaming) return; + + setError(null); + + 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 apiMessages: ChatMessageData[] = newMessages.map(m => ({ + role: m.role, + content: m.content + })); + + await streamChatCompletion( + apiMessages, + {}, + { + onChunk: (text) => { + assistantContent += text; + setStreamingContent(assistantContent); + }, + onError: (errorMsg) => { + console.error("Chat error:", errorMsg); + setError(errorMsg); + setIsStreaming(false); + }, + onDone: () => { + if (assistantContent) { + const assistantMessage: StoredMessage = { + id: crypto.randomUUID(), + role: "assistant", + content: assistantContent, + createdAt: new Date().toISOString() + }; + setMessages(prev => [...prev, assistantMessage]); + } + setStreamingContent(""); + setIsStreaming(false); + spacedUpdate.scheduleUpdate(); + } + } + ); + }, [input, isStreaming, messages, spacedUpdate]); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }, [handleSubmit]); + + return ( +
+
+ {messages.length === 0 && !isStreaming && ( +
+ {t("llm_chat.empty_state")} +
+ )} + {messages.map(msg => ( + + ))} + {isStreaming && streamingContent && ( + + )} + {error && ( +
+ {error} +
+ )} +
+
+
+