From 124d456c600836083034d01a39a974b2710294f4 Mon Sep 17 00:00:00 2001 From: perfectra1n Date: Sun, 22 Mar 2026 09:14:33 -0700 Subject: [PATCH 001/144] feat(db): add missing sqlite indices to help with performance --- apps/server/src/assets/db/schema.sql | 7 +++++++ apps/server/src/migrations/migrations.ts | 20 ++++++++++++++++++++ apps/server/src/services/app_info.ts | 2 +- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/server/src/assets/db/schema.sql b/apps/server/src/assets/db/schema.sql index 07d924a915..f7ee0a4a08 100644 --- a/apps/server/src/assets/db/schema.sql +++ b/apps/server/src/assets/db/schema.sql @@ -146,6 +146,13 @@ CREATE INDEX IDX_notes_blobId on notes (blobId); CREATE INDEX IDX_revisions_blobId on revisions (blobId); CREATE INDEX IDX_attachments_blobId on attachments (blobId); +CREATE INDEX IDX_entity_changes_isSynced_id ON entity_changes (isSynced, id); +CREATE INDEX IDX_entity_changes_isErased_entityName ON entity_changes (isErased, entityName); +CREATE INDEX IDX_notes_isDeleted_utcDateModified ON notes (isDeleted, utcDateModified); +CREATE INDEX IDX_branches_isDeleted_utcDateModified ON branches (isDeleted, utcDateModified); +CREATE INDEX IDX_attributes_isDeleted_utcDateModified ON attributes (isDeleted, utcDateModified); +CREATE INDEX IDX_attachments_isDeleted_utcDateModified ON attachments (isDeleted, utcDateModified); +CREATE INDEX IDX_attachments_utcDateScheduledForErasureSince ON attachments (utcDateScheduledForErasureSince); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, diff --git a/apps/server/src/migrations/migrations.ts b/apps/server/src/migrations/migrations.ts index 7aca1f802b..778fb88beb 100644 --- a/apps/server/src/migrations/migrations.ts +++ b/apps/server/src/migrations/migrations.ts @@ -6,6 +6,26 @@ // Migrations should be kept in descending order, so the latest migration is first. const MIGRATIONS: (SqlMigration | JsMigration)[] = [ + // Add missing database indices for query performance + { + version: 235, + sql: /*sql*/` + CREATE INDEX IF NOT EXISTS IDX_entity_changes_isSynced_id + ON entity_changes (isSynced, id); + CREATE INDEX IF NOT EXISTS IDX_entity_changes_isErased_entityName + ON entity_changes (isErased, entityName); + CREATE INDEX IF NOT EXISTS IDX_notes_isDeleted_utcDateModified + ON notes (isDeleted, utcDateModified); + CREATE INDEX IF NOT EXISTS IDX_branches_isDeleted_utcDateModified + ON branches (isDeleted, utcDateModified); + CREATE INDEX IF NOT EXISTS IDX_attributes_isDeleted_utcDateModified + ON attributes (isDeleted, utcDateModified); + CREATE INDEX IF NOT EXISTS IDX_attachments_isDeleted_utcDateModified + ON attachments (isDeleted, utcDateModified); + CREATE INDEX IF NOT EXISTS IDX_attachments_utcDateScheduledForErasureSince + ON attachments (utcDateScheduledForErasureSince); + ` + }, // Migrate aiChat notes to code notes since LLM integration has been removed { version: 234, diff --git a/apps/server/src/services/app_info.ts b/apps/server/src/services/app_info.ts index 59a9b83ef8..6e933f1502 100644 --- a/apps/server/src/services/app_info.ts +++ b/apps/server/src/services/app_info.ts @@ -5,7 +5,7 @@ import packageJson from "../../package.json" with { type: "json" }; import build from "./build.js"; import dataDir from "./data_dir.js"; -const APP_DB_VERSION = 234; +const APP_DB_VERSION = 235; const SYNC_VERSION = 37; const CLIPPER_PROTOCOL_VERSION = "1.0"; From 81f02209ea82b2bf8d2601048d2a32ef4670633e Mon Sep 17 00:00:00 2001 From: perfectra1n Date: Sun, 22 Mar 2026 09:22:55 -0700 Subject: [PATCH 002/144] feat(db): update index and fix suggestion from gemini --- apps/server/src/assets/db/schema.sql | 2 +- apps/server/src/migrations/migrations.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/assets/db/schema.sql b/apps/server/src/assets/db/schema.sql index f7ee0a4a08..cb7c066d15 100644 --- a/apps/server/src/assets/db/schema.sql +++ b/apps/server/src/assets/db/schema.sql @@ -79,7 +79,7 @@ CREATE UNIQUE INDEX `IDX_entityChanges_entityName_entityId` ON "entity_changes" `entityId` ); CREATE INDEX `IDX_branches_noteId_parentNoteId` ON `branches` (`noteId`,`parentNoteId`); -CREATE INDEX IDX_branches_parentNoteId ON branches (parentNoteId); +CREATE INDEX IDX_branches_parentNoteId_isDeleted_notePosition ON branches (parentNoteId, isDeleted, notePosition); CREATE INDEX `IDX_notes_title` ON `notes` (`title`); CREATE INDEX `IDX_notes_type` ON `notes` (`type`); CREATE INDEX `IDX_notes_dateCreated` ON `notes` (`dateCreated`); diff --git a/apps/server/src/migrations/migrations.ts b/apps/server/src/migrations/migrations.ts index 778fb88beb..ae88e9a3a6 100644 --- a/apps/server/src/migrations/migrations.ts +++ b/apps/server/src/migrations/migrations.ts @@ -22,8 +22,9 @@ const MIGRATIONS: (SqlMigration | JsMigration)[] = [ ON attributes (isDeleted, utcDateModified); CREATE INDEX IF NOT EXISTS IDX_attachments_isDeleted_utcDateModified ON attachments (isDeleted, utcDateModified); - CREATE INDEX IF NOT EXISTS IDX_attachments_utcDateScheduledForErasureSince - ON attachments (utcDateScheduledForErasureSince); + DROP INDEX IF EXISTS IDX_branches_parentNoteId; + CREATE INDEX IF NOT EXISTS IDX_branches_parentNoteId_isDeleted_notePosition + ON branches (parentNoteId, isDeleted, notePosition); ` }, // Migrate aiChat notes to code notes since LLM integration has been removed From 22e2e2339e9f48e911bb97c5d32f282ba4831df4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 01:13:17 +0000 Subject: [PATCH 003/144] chore(deps): update dependency @ckeditor/ckeditor5-dev-build-tools to v55.3.0 --- packages/ckeditor5-admonition/package.json | 2 +- packages/ckeditor5-footnotes/package.json | 2 +- .../ckeditor5-keyboard-marker/package.json | 2 +- packages/ckeditor5-math/package.json | 2 +- packages/ckeditor5-mermaid/package.json | 2 +- pnpm-lock.yaml | 34 +++++++------------ 6 files changed, 18 insertions(+), 26 deletions(-) diff --git a/packages/ckeditor5-admonition/package.json b/packages/ckeditor5-admonition/package.json index 57ab953242..ca97ec585f 100644 --- a/packages/ckeditor5-admonition/package.json +++ b/packages/ckeditor5-admonition/package.json @@ -21,7 +21,7 @@ "ckeditor5-metadata.json" ], "devDependencies": { - "@ckeditor/ckeditor5-dev-build-tools": "55.2.0", + "@ckeditor/ckeditor5-dev-build-tools": "55.3.0", "@ckeditor/ckeditor5-inspector": ">=4.1.0", "@ckeditor/ckeditor5-package-tools": "5.1.0", "@typescript-eslint/eslint-plugin": "8.57.2", diff --git a/packages/ckeditor5-footnotes/package.json b/packages/ckeditor5-footnotes/package.json index 1c8027d401..bb249b6ac9 100644 --- a/packages/ckeditor5-footnotes/package.json +++ b/packages/ckeditor5-footnotes/package.json @@ -22,7 +22,7 @@ "ckeditor5-metadata.json" ], "devDependencies": { - "@ckeditor/ckeditor5-dev-build-tools": "55.2.0", + "@ckeditor/ckeditor5-dev-build-tools": "55.3.0", "@ckeditor/ckeditor5-inspector": ">=4.1.0", "@ckeditor/ckeditor5-package-tools": "5.1.0", "@typescript-eslint/eslint-plugin": "8.57.2", diff --git a/packages/ckeditor5-keyboard-marker/package.json b/packages/ckeditor5-keyboard-marker/package.json index 05b190e476..2ab291f9ca 100644 --- a/packages/ckeditor5-keyboard-marker/package.json +++ b/packages/ckeditor5-keyboard-marker/package.json @@ -24,7 +24,7 @@ "ckeditor5-metadata.json" ], "devDependencies": { - "@ckeditor/ckeditor5-dev-build-tools": "55.2.0", + "@ckeditor/ckeditor5-dev-build-tools": "55.3.0", "@ckeditor/ckeditor5-inspector": ">=4.1.0", "@ckeditor/ckeditor5-package-tools": "5.1.0", "@typescript-eslint/eslint-plugin": "8.57.2", diff --git a/packages/ckeditor5-math/package.json b/packages/ckeditor5-math/package.json index df72ff48e5..11c993a9fe 100644 --- a/packages/ckeditor5-math/package.json +++ b/packages/ckeditor5-math/package.json @@ -24,7 +24,7 @@ "ckeditor5-metadata.json" ], "devDependencies": { - "@ckeditor/ckeditor5-dev-build-tools": "55.2.0", + "@ckeditor/ckeditor5-dev-build-tools": "55.3.0", "@ckeditor/ckeditor5-inspector": ">=4.1.0", "@ckeditor/ckeditor5-package-tools": "5.1.0", "@typescript-eslint/eslint-plugin": "8.57.2", diff --git a/packages/ckeditor5-mermaid/package.json b/packages/ckeditor5-mermaid/package.json index 6d638c5a50..78fa783fb3 100644 --- a/packages/ckeditor5-mermaid/package.json +++ b/packages/ckeditor5-mermaid/package.json @@ -24,7 +24,7 @@ "ckeditor5-metadata.json" ], "devDependencies": { - "@ckeditor/ckeditor5-dev-build-tools": "55.2.0", + "@ckeditor/ckeditor5-dev-build-tools": "55.3.0", "@ckeditor/ckeditor5-inspector": ">=4.1.0", "@ckeditor/ckeditor5-package-tools": "5.1.0", "@typescript-eslint/eslint-plugin": "8.57.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94765660f2..1f998eae91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -947,8 +947,8 @@ importers: packages/ckeditor5-admonition: devDependencies: '@ckeditor/ckeditor5-dev-build-tools': - 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) + 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) '@ckeditor/ckeditor5-inspector': specifier: '>=4.1.0' version: 5.0.0 @@ -1007,8 +1007,8 @@ importers: packages/ckeditor5-footnotes: devDependencies: '@ckeditor/ckeditor5-dev-build-tools': - 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) + 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) '@ckeditor/ckeditor5-inspector': specifier: '>=4.1.0' version: 5.0.0 @@ -1067,8 +1067,8 @@ importers: packages/ckeditor5-keyboard-marker: devDependencies: '@ckeditor/ckeditor5-dev-build-tools': - 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) + 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) '@ckeditor/ckeditor5-inspector': specifier: '>=4.1.0' version: 5.0.0 @@ -1134,8 +1134,8 @@ importers: version: 0.109.0 devDependencies: '@ckeditor/ckeditor5-dev-build-tools': - 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) + 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) '@ckeditor/ckeditor5-inspector': specifier: '>=4.1.0' version: 5.0.0 @@ -1201,8 +1201,8 @@ importers: version: 4.17.23 devDependencies: '@ckeditor/ckeditor5-dev-build-tools': - 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) + 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) '@ckeditor/ckeditor5-inspector': specifier: '>=4.1.0' version: 5.0.0 @@ -1972,8 +1972,8 @@ packages: '@ckeditor/ckeditor5-core@47.6.1': resolution: {integrity: sha512-6dtnquhjymLkNhdC9T6gk/Mf2bDnHSTZrhkByaXC96CbmQDriCgfcaAVY6pQgDNxBQ6fZrev0TnKBLfTItrMsg==} - '@ckeditor/ckeditor5-dev-build-tools@55.2.0': - resolution: {integrity: sha512-pUa3GqCOEb7m5xhbUPV6gKLIgsX/TI3MXT51u0wa+A822ZFVbaXoGd2LissPkuK9WcGfmgU1gT8TzcyFTCTYig==} + '@ckeditor/ckeditor5-dev-build-tools@55.3.0': + resolution: {integrity: sha512-87WlVerNpgc0xlnnPTKX+1Z/LrTWeueaOQK/XWns/AKJDoGbwUyQo6rhlRsEvDGKdKXOdHXgQijxgh9Yo1I9KQ==} engines: {node: '>=24.11.0', npm: '>=5.7.1'} hasBin: true @@ -16920,8 +16920,6 @@ snapshots: '@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: @@ -16990,7 +16988,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@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)': + '@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)': dependencies: '@rollup/plugin-commonjs': 28.0.9(rollup@4.52.0) '@rollup/plugin-json': 6.1.0(rollup@4.52.0) @@ -17159,8 +17157,6 @@ snapshots: '@ckeditor/ckeditor5-table': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-emoji@47.6.1': dependencies: @@ -17344,8 +17340,6 @@ 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': {} @@ -17647,8 +17641,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-restricted-editing@47.6.1': dependencies: From 0d94c20debefadbdeffb7f10fc381a4dced866fd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 01:17:16 +0000 Subject: [PATCH 004/144] fix(deps): update dependency @zumer/snapdom to v2.7.0 --- apps/client/package.json | 2 +- pnpm-lock.yaml | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index d0ce312982..c680fb128d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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.6.0", + "@zumer/snapdom": "2.7.0", "autocomplete.js": "0.38.1", "bootstrap": "5.3.8", "boxicons": "2.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94765660f2..869409e768 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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.6.0 - version: 2.6.0 + specifier: 2.7.0 + version: 2.7.0 autocomplete.js: specifier: 0.38.1 version: 0.38.1 @@ -7207,8 +7207,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.6.0': - resolution: {integrity: sha512-JpPPkuMzozRVX6KArgCiMgLpgVW82kWgyoFk5DWGKE5msWGEshXEUdQHLLEyZRO7qioI1pI+yaBJz81tEP9gPg==} + '@zumer/snapdom@2.7.0': + resolution: {integrity: sha512-ZiELKzDszeFOazPQ/ExXzgtdoW9jADVjDjInr5XDAlVdCx0RbNsFiG7RLyM48XnA7EyCA9yTvmXSc3ElDrTRqA==} abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} @@ -16920,8 +16920,6 @@ snapshots: '@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: @@ -17159,8 +17157,6 @@ snapshots: '@ckeditor/ckeditor5-table': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-emoji@47.6.1': dependencies: @@ -17344,8 +17340,6 @@ 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': {} @@ -17647,8 +17641,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-restricted-editing@47.6.1': dependencies: @@ -24374,7 +24366,7 @@ snapshots: '@zip.js/zip.js@2.8.11': {} - '@zumer/snapdom@2.6.0': {} + '@zumer/snapdom@2.7.0': {} abab@2.0.6: {} From fb7fc4bf0c25b1b0cb46b255b8e689b57794db7c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 28 Mar 2026 20:39:09 +0200 Subject: [PATCH 005/144] 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} +
+ )} +
+
+
+