diff --git a/CLAUDE.md b/CLAUDE.md index c4375259b7..a818b18929 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,6 +155,12 @@ Trilium provides powerful user scripting capabilities: - Add migration scripts in `apps/server/src/migrations/` - Update schema in `apps/server/src/assets/db/schema.sql` +### Server-Side Static Assets +- Static assets (templates, SQL, translations, etc.) go in `apps/server/src/assets/` +- Access them at runtime via `RESOURCE_DIR` from `apps/server/src/services/resource_dir.ts` (e.g. `path.join(RESOURCE_DIR, "llm", "skills", "file.md")`) +- **Do not use `import.meta.url`/`fileURLToPath`** to resolve file paths — the server is bundled into CJS for production, so `import.meta.url` will not point to the source directory +- **Do not use `__dirname` with relative paths** from source files — after bundling, `__dirname` points to the bundle output, not the original source tree + ## Build System Notes - Uses pnpm for monorepo management - Vite for fast development builds diff --git a/apps/client/src/services/llm_chat.ts b/apps/client/src/services/llm_chat.ts index 13f282fe02..e4263aa896 100644 --- a/apps/client/src/services/llm_chat.ts +++ b/apps/client/src/services/llm_chat.ts @@ -3,10 +3,10 @@ import type { LlmChatConfig, LlmCitation, LlmMessage, LlmModelInfo,LlmUsage } fr import server from "./server.js"; /** - * Fetch available models for a provider. + * Fetch available models from all configured providers. */ -export async function getAvailableModels(provider: string = "anthropic"): Promise { - const response = await server.get<{ models?: LlmModelInfo[] }>(`llm-chat/models?provider=${encodeURIComponent(provider)}`); +export async function getAvailableModels(): Promise { + const response = await server.get<{ models?: LlmModelInfo[] }>("llm-chat/models"); return response.models ?? []; } diff --git a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts index eb5033cc73..ddca7deb98 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts +++ b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts @@ -239,8 +239,10 @@ export function useLlmChat( .join("") })); + const selectedModelProvider = availableModels.find(m => m.id === selectedModel)?.provider; const streamOptions: Parameters[1] = { model: selectedModel || undefined, + provider: selectedModelProvider, enableWebSearch, enableNoteTools, contextNoteId, diff --git a/apps/client/src/widgets/type_widgets/options/llm/AddProviderModal.tsx b/apps/client/src/widgets/type_widgets/options/llm/AddProviderModal.tsx index 6e6c455979..4538cde3b8 100644 --- a/apps/client/src/widgets/type_widgets/options/llm/AddProviderModal.tsx +++ b/apps/client/src/widgets/type_widgets/options/llm/AddProviderModal.tsx @@ -19,7 +19,9 @@ export interface ProviderType { } export const PROVIDER_TYPES: ProviderType[] = [ - { id: "anthropic", name: "Anthropic" } + { id: "anthropic", name: "Anthropic" }, + { id: "openai", name: "OpenAI" }, + { id: "google", name: "Google Gemini" } ]; interface AddProviderModalProps { diff --git a/apps/server/package.json b/apps/server/package.json index 817cb5cd27..cb7f728fd7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -30,8 +30,10 @@ "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": { - "@ai-sdk/anthropic": "^2.0.0", - "ai": "^5.0.0", + "@ai-sdk/anthropic": "3.0.64", + "@ai-sdk/google": "3.0.54", + "@ai-sdk/openai": "3.0.49", + "ai": "6.0.142", "better-sqlite3": "12.8.0", "html-to-text": "9.0.5", "node-html-parser": "7.1.0", diff --git a/apps/server/src/assets/llm/skills/backend_scripting.md b/apps/server/src/assets/llm/skills/backend_scripting.md new file mode 100644 index 0000000000..59ec934ece --- /dev/null +++ b/apps/server/src/assets/llm/skills/backend_scripting.md @@ -0,0 +1,156 @@ +# Trilium Backend Scripting + +Backend scripts run in Node.js on the server. They have direct access to notes in memory and can interact with the system (files, processes). + +## Creating a backend script + +1. Create a Code note with language "JS backend". +2. The script can be run manually (Execute button) or triggered automatically. + +## Script API (`api` global) + +### Note retrieval +- `api.getNote(noteId)` - get note by ID +- `api.searchForNotes(query, searchParams)` - search notes (returns array) +- `api.searchForNote(query)` - search notes (returns first match) +- `api.getNotesWithLabel(name, value?)` - find notes by label +- `api.getNoteWithLabel(name, value?)` - find first note by label +- `api.getBranch(branchId)` - get branch by ID +- `api.getAttribute(attributeId)` - get attribute by ID + +### Note creation +- `api.createTextNote(parentNoteId, title, content)` - create text note +- `api.createDataNote(parentNoteId, title, content)` - create JSON note +- `api.createNewNote({ parentNoteId, title, content, type })` - create note with full options + +### Branch management +- `api.ensureNoteIsPresentInParent(noteId, parentNoteId, prefix?)` - create or reuse branch +- `api.ensureNoteIsAbsentFromParent(noteId, parentNoteId)` - remove branch if exists +- `api.toggleNoteInParent(present, noteId, parentNoteId, prefix?)` - toggle branch + +### Calendar/date notes +- `api.getTodayNote()` - get/create today's day note +- `api.getDayNote(date)` - get/create day note (YYYY-MM-DD) +- `api.getWeekNote(date)` - get/create week note +- `api.getMonthNote(date)` - get/create month note (YYYY-MM) +- `api.getYearNote(year)` - get/create year note (YYYY) + +### Utilities +- `api.log(message)` - log to Trilium logs and UI +- `api.randomString(length)` - generate random string +- `api.escapeHtml(string)` / `api.unescapeHtml(string)` +- `api.getInstanceName()` - get instance name +- `api.getAppInfo()` - get application info + +### Libraries +- `api.axios` - HTTP client +- `api.dayjs` - date manipulation +- `api.xml2js` - XML parser +- `api.cheerio` - HTML/XML parser + +### Advanced +- `api.transactional(func)` - wrap code in a database transaction +- `api.sql` - direct SQL access +- `api.sortNotes(parentNoteId, sortConfig)` - sort child notes +- `api.runOnFrontend(script, params)` - execute code on all connected frontends +- `api.backupNow(backupName)` - create a backup +- `api.exportSubtreeToZipFile(noteId, format, zipFilePath)` - export subtree (format: "markdown" or "html") +- `api.duplicateSubtree(origNoteId, newParentNoteId)` - clone note and children + +## BNote object + +Available on notes returned from API methods (`api.getNote()`, `api.originEntity`, etc.). + +### Content +- `note.getContent()` / `note.setContent(content)` +- `note.getJsonContent()` / `note.setJsonContent(obj)` +- `note.getJsonContentSafely()` - returns null on parse error + +### Properties +- `note.noteId`, `note.title`, `note.type`, `note.mime` +- `note.dateCreated`, `note.dateModified` +- `note.isProtected`, `note.isArchived` + +### Hierarchy +- `note.getParentNotes()` / `note.getChildNotes()` +- `note.getParentBranches()` / `note.getChildBranches()` +- `note.hasChildren()`, `note.getAncestors()` +- `note.getSubtreeNoteIds()` - all descendant IDs +- `note.hasAncestor(ancestorNoteId)` + +### Attributes (including inherited) +- `note.getLabels(name?)` / `note.getLabelValue(name)` +- `note.getRelations(name?)` / `note.getRelation(name)` +- `note.hasLabel(name, value?)` / `note.hasRelation(name, value?)` + +### Attribute modification +- `note.setLabel(name, value?)` / `note.removeLabel(name, value?)` +- `note.setRelation(name, targetNoteId)` / `note.removeRelation(name, value?)` +- `note.addLabel(name, value?, isInheritable?)` / `note.addRelation(name, targetNoteId, isInheritable?)` +- `note.toggleLabel(enabled, name, value?)` + +### Operations +- `note.save()` - persist changes +- `note.deleteNote()` - soft delete +- `note.cloneTo(parentNoteId)` - clone to another parent + +### Type checks +- `note.isJson()`, `note.isJavaScript()`, `note.isHtml()`, `note.isImage()` +- `note.hasStringContent()` - true if not binary + +## Events and triggers + +### Global events (via `#run` label on the script note) +- `#run=backendStartup` - run when server starts +- `#run=hourly` - run once per hour (use `#runAtHour=N` to specify which hours) +- `#run=daily` - run once per day + +### Entity events (via relation from the entity to the script note) +These are defined as relations. `api.originEntity` contains the entity that triggered the event. + +| Relation | Trigger | originEntity | +|---|---|---| +| `~runOnNoteCreation` | note created | BNote | +| `~runOnChildNoteCreation` | child note created under this note | BNote (child) | +| `~runOnNoteTitleChange` | note title changed | BNote | +| `~runOnNoteContentChange` | note content changed | BNote | +| `~runOnNoteChange` | note metadata changed (not content) | BNote | +| `~runOnNoteDeletion` | note deleted | BNote | +| `~runOnBranchCreation` | branch created (clone/move) | BBranch | +| `~runOnBranchChange` | branch updated | BBranch | +| `~runOnBranchDeletion` | branch deleted | BBranch | +| `~runOnAttributeCreation` | attribute created on this note | BAttribute | +| `~runOnAttributeChange` | attribute changed/deleted on this note | BAttribute | + +Relations can be inheritable — when set, they apply to all descendant notes. + +## Example: auto-color notes by category + +```javascript +// Attach via ~runOnAttributeChange relation +const attr = api.originEntity; +if (attr.name !== "mycategory") return; +const note = api.getNote(attr.noteId); +if (attr.value === "Health") { + note.setLabel("color", "green"); +} else { + note.removeLabel("color"); +} +``` + +## Example: create a daily summary + +```javascript +// Attach #run=daily label +const today = api.getTodayNote(); +const tasks = api.searchForNotes('#task #!completed'); +let summary = "## Open Tasks\n"; +for (const task of tasks) { + summary += `- ${task.title}\n`; +} +api.createTextNote(today.noteId, "Daily Summary", summary); +``` + +## Module system + +Child notes of a script act as modules. Export with `module.exports = ...` and import via function parameters matching the child note title, or use `require('noteName')`. diff --git a/apps/server/src/assets/llm/skills/frontend_scripting.md b/apps/server/src/assets/llm/skills/frontend_scripting.md new file mode 100644 index 0000000000..493b4446f8 --- /dev/null +++ b/apps/server/src/assets/llm/skills/frontend_scripting.md @@ -0,0 +1,240 @@ +# Trilium Frontend Scripting + +Frontend scripts run in the browser. They can manipulate the UI, navigate notes, show dialogs, and create custom widgets. + +IMPORTANT: Always prefer Preact JSX widgets over legacy jQuery widgets. Use JSX code notes with `import`/`export` syntax. + +CRITICAL: In JSX notes, always use top-level `import` statements (e.g. `import { useState } from "trilium:preact"`). NEVER use dynamic `await import()` for Preact imports — this will break hooks and components. Dynamic imports are not needed because JSX notes natively support ES module `import`/`export` syntax. + +## Creating a frontend script + +1. Create a Code note with language "JSX" (preferred) or "JS frontend" (legacy only). +2. Add `#widget` label for widgets, or `#run=frontendStartup` for auto-run scripts. +3. For mobile, use `#run=mobileStartup` instead. + +## Script types + +| Type | Language | Required attribute | +|---|---|---| +| Custom widget | JSX (preferred) | `#widget` | +| Regular script | JS frontend | `#run=frontendStartup` (optional) | +| Render note | JSX | None (used via `~renderNote` relation) | + +## Custom widgets (Preact JSX) — preferred + +### Basic widget + +```jsx +import { defineWidget } from "trilium:preact"; +import { useState } from "trilium:preact"; + +export default defineWidget({ + parent: "center-pane", + position: 10, + render: () => { + const [count, setCount] = useState(0); + return ( +
+ +
+ ); + } +}); +``` + +### Note context aware widget (reacts to active note) + +```jsx +import { defineWidget, useNoteContext, useNoteProperty } from "trilium:preact"; + +export default defineWidget({ + parent: "note-detail-pane", + position: 10, + render: () => { + const { note } = useNoteContext(); + const title = useNoteProperty(note, "title"); + return Current note: {title}; + } +}); +``` + +### Right panel widget (sidebar) + +```jsx +import { defineWidget, RightPanelWidget, useState, useEffect } from "trilium:preact"; + +export default defineWidget({ + parent: "right-pane", + position: 1, + render() { + const [time, setTime] = useState(); + useEffect(() => { + const interval = setInterval(() => { + setTime(new Date().toLocaleString()); + }, 1000); + return () => clearInterval(interval); + }); + return ( + +

The time is: {time}

+
+ ); + } +}); +``` + +### Widget locations (`parent` values) + +| Value | Description | Notes | +|---|---|---| +| `left-pane` | Alongside the note tree | | +| `center-pane` | Content area, spanning all splits | | +| `note-detail-pane` | Inside a note, split-aware | Use `useNoteContext()` hook | +| `right-pane` | Right sidebar section | Wrap in `` | + +### Preact imports + +```jsx +// API methods +import { showMessage, showError, getNote, searchForNotes, activateNote, + runOnBackend, getActiveContextNote } from "trilium:api"; + +// Hooks and components +import { defineWidget, defineLauncherWidget, + useState, useEffect, useCallback, useMemo, useRef, + useNoteContext, useActiveNoteContext, useNoteProperty, + RightPanelWidget } from "trilium:preact"; + +// Built-in UI components +import { ActionButton, Button, LinkButton, Modal, + NoteAutocomplete, FormTextBox, FormToggle, FormCheckbox, + FormDropdownList, FormGroup, FormText, FormTextArea, + Icon, LoadingSpinner, Slider, Collapsible } from "trilium:preact"; +``` + +### Custom hooks + +- `useNoteContext()` - returns `{ note }` for the current note context (use in `note-detail-pane`) +- `useActiveNoteContext()` - returns `{ note, noteId }` for the active note (works from any widget location) +- `useNoteProperty(note, propName)` - reactively watches a note property (e.g. "title", "type") + +### Render notes (JSX) + +For rendering custom content inside a note: +1. Create a "render note" (type: Render Note) where you want the content to appear. +2. Create a JSX code note **as a child** of the render note, exporting a default component. +3. On the render note, add a `~renderNote` relation pointing to the child JSX note. + +IMPORTANT: Always create the JSX code note as a child of the render note, not as a sibling or at the root. This keeps them organized together. + +```jsx +export default function MyRenderNote() { + return ( + <> +

Custom rendered content

+

This appears inside the note.

+ + ); +} +``` + +## Script API + +In JSX, use `import { method } from "trilium:api"`. In JS frontend, use the `api` global. + +### Navigation & tabs +- `activateNote(notePath)` - navigate to a note +- `activateNewNote(notePath)` - navigate and wait for sync +- `openTabWithNote(notePath, activate?)` - open in new tab +- `openSplitWithNote(notePath, activate?)` - open in new split +- `getActiveContextNote()` - get currently active note +- `getActiveContextNotePath()` - get path of active note +- `setHoistedNoteId(noteId)` - hoist/unhoist note + +### Note access & search +- `getNote(noteId)` - get note by ID +- `getNotes(noteIds)` - bulk fetch notes +- `searchForNotes(searchString)` - search with full query syntax +- `searchForNote(searchString)` - search returning first result + +### Calendar/date notes +- `getTodayNote()` - get/create today's note +- `getDayNote(date)` / `getWeekNote(date)` / `getMonthNote(month)` / `getYearNote(year)` + +### Editor access +- `getActiveContextTextEditor()` - get CKEditor instance +- `getActiveContextCodeEditor()` - get CodeMirror instance +- `addTextToActiveContextEditor(text)` - insert text into active editor + +### Dialogs & notifications +- `showMessage(msg)` - info toast +- `showError(msg)` - error toast +- `showConfirmDialog(msg)` - confirm dialog (returns boolean) +- `showPromptDialog(msg)` - prompt dialog (returns user input) + +### Backend integration +- `runOnBackend(func, params)` - execute a function on the backend + +### UI interaction +- `triggerCommand(name, data)` - trigger a command +- `bindGlobalShortcut(shortcut, handler, namespace?)` - add keyboard shortcut + +### Utilities +- `formatDateISO(date)` - format as YYYY-MM-DD +- `randomString(length)` - generate random string +- `dayjs` - day.js library +- `log(message)` - log to script log pane + +## FNote object + +Available via `getNote()`, `getActiveContextNote()`, `useNoteContext()`, etc. + +### Properties +- `note.noteId`, `note.title`, `note.type`, `note.mime` +- `note.isProtected`, `note.isArchived` + +### Content +- `note.getContent()` - get note content +- `note.getJsonContent()` - parse content as JSON + +### Hierarchy +- `note.getParentNotes()` / `note.getChildNotes()` +- `note.hasChildren()`, `note.getSubtreeNoteIds()` + +### Attributes +- `note.getAttributes(type?, name?)` - all attributes (including inherited) +- `note.getOwnedAttributes(type?, name?)` - only owned attributes +- `note.hasAttribute(type, name)` - check for attribute + +## Legacy jQuery widgets (avoid if possible) + +Only use legacy widgets if you specifically need jQuery or cannot use JSX. + +```javascript +// Language: JS frontend, Label: #widget +class MyWidget extends api.BasicWidget { + get position() { return 1; } + get parentWidget() { return "center-pane"; } + + doRender() { + this.$widget = $("
"); + this.$widget.append($("") + .on("click", () => api.showMessage("Hello!"))); + return this.$widget; + } +} +module.exports = new MyWidget(); +``` + +Key differences from Preact: +- Use `api.` global instead of imports +- `get parentWidget()` instead of `parent` field +- `module.exports = new MyWidget()` (instance) for most widgets +- `module.exports = MyWidget` (class, no `new`) for `note-detail-pane` +- Right pane: extend `api.RightPanelWidget`, override `doRenderBody()` instead of `doRender()` + +## Module system + +For JSX, use `import`/`export` syntax between notes. For JS frontend, use `module.exports` and function parameters matching child note titles. diff --git a/apps/server/src/assets/llm/skills/search_syntax.md b/apps/server/src/assets/llm/skills/search_syntax.md new file mode 100644 index 0000000000..a43b6b25d7 --- /dev/null +++ b/apps/server/src/assets/llm/skills/search_syntax.md @@ -0,0 +1,50 @@ +# Trilium Search Syntax + +## Full-text search +- `rings tolkien` — notes containing both words +- `"The Lord of the Rings"` — exact phrase match + +## Label filters +- `#book` — notes with the "book" label +- `#!book` — notes WITHOUT the "book" label +- `#publicationYear = 1954` — exact value +- `#genre *=* fan` — contains substring +- `#title =* The` — starts with +- `#title *= Rings` — ends with +- `#publicationYear >= 1950` — numeric comparison (>, >=, <, <=) +- `#dateNote >= TODAY-30` — date keywords: NOW+-seconds, TODAY+-days, MONTH+-months, YEAR+-years +- `#phone %= '\d{3}-\d{4}'` — regex match +- `#title ~= trilim` — fuzzy exact match (tolerates typos, min 3 chars) +- `#content ~* progra` — fuzzy contains match + +## Relation filters +- `~author` — notes with an "author" relation +- `~author.title *=* Tolkien` — relation target's title contains "Tolkien" +- `~author.relations.son.title = 'Christopher Tolkien'` — deep relation traversal + +## Note properties +Access via `note.` prefix: noteId, title, type, mime, text, content, rawContent, dateCreated, dateModified, isProtected, isArchived, parentCount, childrenCount, attributeCount, labelCount, relationCount, contentSize, revisionCount. +- `note.type = code AND note.mime = 'application/json'` +- `note.content *=* searchTerm` + +## Hierarchy +- `note.parents.title = 'Books'` — parent named "Books" +- `note.ancestors.title = 'Books'` — any ancestor named "Books" +- `note.children.title = 'sub-note'` — child named "sub-note" + +## Boolean logic +- AND: `#book AND #fantasy` (implicit between adjacent expressions) +- OR: `#book OR #author` +- NOT: `not(note.ancestors.title = 'Tolkien')` +- Parentheses: `(#genre = "fantasy" AND #year >= 1950) OR #award` + +## Combining full-text and attributes +- `towers #book` — full-text "towers" AND has #book label +- `tolkien #book or #author` — full-text with OR on labels + +## Ordering and limiting +- `#author=Tolkien orderBy #publicationDate desc, note.title limit 10` + +## Escaping +- `\#hash` — literal # in full-text +- Three quote types: single, double, backtick diff --git a/apps/server/src/routes/api/llm_chat.ts b/apps/server/src/routes/api/llm_chat.ts index 8302687e13..dd5bf149c8 100644 --- a/apps/server/src/routes/api/llm_chat.ts +++ b/apps/server/src/routes/api/llm_chat.ts @@ -2,7 +2,7 @@ import type { LlmMessage } from "@triliumnext/commons"; import type { Request, Response } from "express"; import { generateChatTitle } from "../../services/llm/chat_title.js"; -import { getProviderByType, hasConfiguredProviders, type LlmProviderConfig } from "../../services/llm/index.js"; +import { getAllModels, getProviderByType, hasConfiguredProviders, type LlmProviderConfig } from "../../services/llm/index.js"; import { streamToChunks } from "../../services/llm/stream.js"; import log from "../../services/log.js"; import { safeExtractMessageAndStackFromError } from "../../services/utils.js"; @@ -88,19 +88,14 @@ async function streamChat(req: Request, res: Response) { } /** - * Get available models for a provider. + * Get available models from all configured providers. */ -function getModels(req: Request, _res: Response) { - const providerType = req.query.provider as string || "anthropic"; - - // Return empty array when no providers configured - client handles this gracefully +function getModels(_req: Request, _res: Response) { if (!hasConfiguredProviders()) { return { models: [] }; } - const llmProvider = getProviderByType(providerType); - const models = llmProvider.getAvailableModels(); - return { models }; + return { models: getAllModels() }; } export default { diff --git a/apps/server/src/services/llm/index.ts b/apps/server/src/services/llm/index.ts index 4d04acb485..ebf0a06639 100644 --- a/apps/server/src/services/llm/index.ts +++ b/apps/server/src/services/llm/index.ts @@ -1,5 +1,7 @@ -import type { LlmProvider } from "./types.js"; +import type { LlmProvider, ModelInfo } from "./types.js"; import { AnthropicProvider } from "./providers/anthropic.js"; +import { GoogleProvider } from "./providers/google.js"; +import { OpenAiProvider } from "./providers/openai.js"; import optionService from "../options.js"; import log from "../log.js"; @@ -16,7 +18,9 @@ export interface LlmProviderSetup { /** Factory functions for creating provider instances */ const providerFactories: Record LlmProvider> = { - anthropic: (apiKey) => new AnthropicProvider(apiKey) + anthropic: (apiKey) => new AnthropicProvider(apiKey), + openai: (apiKey) => new OpenAiProvider(apiKey), + google: (apiKey) => new GoogleProvider(apiKey) }; /** Cache of instantiated providers by their config ID */ @@ -95,6 +99,35 @@ export function hasConfiguredProviders(): boolean { return getConfiguredProviders().length > 0; } +/** + * Get all models from all configured providers, tagged with their provider type. + */ +export function getAllModels(): ModelInfo[] { + const configs = getConfiguredProviders(); + const seenProviderTypes = new Set(); + const allModels: ModelInfo[] = []; + + for (const config of configs) { + // Only include models once per provider type (not per config instance) + if (seenProviderTypes.has(config.provider)) { + continue; + } + seenProviderTypes.add(config.provider); + + try { + const provider = getProvider(config.id); + const models = provider.getAvailableModels(); + for (const model of models) { + allModels.push({ ...model, provider: config.provider }); + } + } catch (e) { + log.error(`Failed to get models from provider ${config.provider}: ${e}`); + } + } + + return allModels; +} + /** * Clear the provider cache. Call this when provider configurations change. */ diff --git a/apps/server/src/services/llm/providers/anthropic.ts b/apps/server/src/services/llm/providers/anthropic.ts index e8fc71cce1..aef87f8258 100644 --- a/apps/server/src/services/llm/providers/anthropic.ts +++ b/apps/server/src/services/llm/providers/anthropic.ts @@ -1,29 +1,15 @@ import { createAnthropic, type AnthropicProvider as AnthropicSDKProvider } from "@ai-sdk/anthropic"; -import { generateText, streamText, stepCountIs, type CoreMessage, type ToolSet } from "ai"; +import { stepCountIs, streamText, type ModelMessage, type ToolSet } from "ai"; import type { LlmMessage } from "@triliumnext/commons"; -import becca from "../../../becca/becca.js"; -import { noteTools, attributeTools, currentNoteTools } from "../tools/index.js"; -import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js"; - -const DEFAULT_MODEL = "claude-sonnet-4-6"; -const DEFAULT_MAX_TOKENS = 8096; -const TITLE_MODEL = "claude-haiku-4-5-20251001"; -const TITLE_MAX_TOKENS = 30; - -/** - * Calculate effective cost for comparison (weighted average: 1 input + 3 output). - * Output is weighted more heavily as it's typically the dominant cost factor. - */ -function effectiveCost(pricing: ModelPricing): number { - return (pricing.input + 3 * pricing.output) / 4; -} +import type { LlmProviderConfig, StreamResult } from "../types.js"; +import { BaseProvider, buildModelList } from "./base_provider.js"; /** * Available Anthropic models with pricing (USD per million tokens). * Source: https://docs.anthropic.com/en/docs/about-claude/models */ -const BASE_MODELS: Omit[] = [ +const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([ // ===== Current Models ===== { id: "claude-sonnet-4-6", @@ -49,7 +35,7 @@ const BASE_MODELS: Omit[] = [ id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5", pricing: { input: 3, output: 15 }, - contextWindow: 200000, // 1M available with beta header + contextWindow: 200000, isLegacy: true }, { @@ -70,7 +56,7 @@ const BASE_MODELS: Omit[] = [ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4.0", pricing: { input: 3, output: 15 }, - contextWindow: 200000, // 1M available with beta header + contextWindow: 200000, isLegacy: true }, { @@ -80,69 +66,42 @@ const BASE_MODELS: Omit[] = [ contextWindow: 200000, isLegacy: true } -]; +]); -// Use default model (Sonnet) as baseline for cost multiplier -const baselineModel = BASE_MODELS.find(m => m.isDefault) || BASE_MODELS[0]; -const baselineCost = effectiveCost(baselineModel.pricing); - -// Build models with cost multipliers -const AVAILABLE_MODELS: ModelInfo[] = BASE_MODELS.map(m => ({ - ...m, - costMultiplier: Math.round((effectiveCost(m.pricing) / baselineCost) * 10) / 10 -})); - -// Build pricing lookup from available models -const MODEL_PRICING: Record = Object.fromEntries( - AVAILABLE_MODELS.map(m => [m.id, m.pricing]) -); - -/** - * Build a lightweight context hint about the current note (title + type only, no content). - * The full content is available via the get_current_note tool. - */ -function buildNoteHint(noteId: string): string | null { - const note = becca.getNote(noteId); - if (!note) { - return null; - } - - return `The user is currently viewing a ${note.type} note titled "${note.title}". Use the get_current_note tool to read its content if needed.`; -} - -export class AnthropicProvider implements LlmProvider { +export class AnthropicProvider extends BaseProvider { name = "anthropic"; + protected defaultModel = "claude-sonnet-4-6"; + protected titleModel = "claude-haiku-4-5-20251001"; + protected availableModels = AVAILABLE_MODELS; + protected modelPricing = MODEL_PRICING; + private anthropic: AnthropicSDKProvider; constructor(apiKey: string) { + super(); if (!apiKey) { throw new Error("API key is required for Anthropic provider"); } this.anthropic = createAnthropic({ apiKey }); } - chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult { - let systemPrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content; - const chatMessages = messages.filter(m => m.role !== "system"); + protected createModel(modelId: string) { + return this.anthropic(modelId); + } - // Add a lightweight hint about the current note (content available via tool) - if (config.contextNoteId) { - const noteHint = buildNoteHint(config.contextNoteId); - if (noteHint) { - systemPrompt = systemPrompt - ? `${systemPrompt}\n\n${noteHint}` - : noteHint; - } - } + protected override addWebSearchTool(tools: ToolSet): void { + tools.web_search = this.anthropic.tools.webSearch_20250305({ + maxUses: 5 + }); + } - // Convert to AI SDK message format with cache control breakpoints. - // The system prompt and conversation history (all but the last user message) - // are stable across turns, so we mark them for caching to reduce costs. + /** + * Override buildMessages to add Anthropic-specific cache control breakpoints. + */ + protected override buildMessages(chatMessages: LlmMessage[], systemPrompt: string | undefined): ModelMessage[] { const CACHE_CONTROL = { anthropic: { cacheControl: { type: "ephemeral" as const } } }; + const coreMessages: ModelMessage[] = []; - const coreMessages: CoreMessage[] = []; - - // System prompt as a cacheable message if (systemPrompt) { coreMessages.push({ role: "system", @@ -151,94 +110,59 @@ export class AnthropicProvider implements LlmProvider { }); } - // Conversation messages for (let i = 0; i < chatMessages.length; i++) { const m = chatMessages[i]; const isLastBeforeNewTurn = i === chatMessages.length - 2; + // Anthropic rejects empty text content blocks. Replace empty + // content (e.g. tool-only assistant turns) with a placeholder + // to preserve conversation flow. + const content = m.content || "(tool use)"; coreMessages.push({ role: m.role as "user" | "assistant", - content: m.content, - // Cache breakpoint on the second-to-last message: - // everything up to here is identical across consecutive turns. + content, ...(isLastBeforeNewTurn && { providerOptions: CACHE_CONTROL }) }); } - const model = this.anthropic(config.model || DEFAULT_MODEL); + return coreMessages; + } + + /** + * Override chat to add Anthropic-specific extended thinking support. + */ + override chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult { + if (!config.enableExtendedThinking) { + return super.chat(messages, config); + } + + const systemPrompt = this.buildSystemPrompt(messages, config); + const chatMessages = messages.filter(m => m.role !== "system"); + const coreMessages = this.buildMessages(chatMessages, systemPrompt); + + const thinkingBudget = config.thinkingBudget || 10000; + const maxTokens = Math.max(config.maxTokens || 8096, thinkingBudget + 4000); - // Build options for streamText const streamOptions: Parameters[0] = { - model, + model: this.createModel(config.model || this.defaultModel), messages: coreMessages, - maxOutputTokens: config.maxTokens || DEFAULT_MAX_TOKENS - }; - - // Enable extended thinking for deeper reasoning - if (config.enableExtendedThinking) { - const thinkingBudget = config.thinkingBudget || 10000; - streamOptions.providerOptions = { + maxOutputTokens: maxTokens, + providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: thinkingBudget } } - }; - streamOptions.maxOutputTokens = Math.max( - streamOptions.maxOutputTokens || DEFAULT_MAX_TOKENS, - thinkingBudget + 4000 - ); - } - - // Build tools object - const tools: ToolSet = {}; - - if (config.enableWebSearch) { - tools.web_search = this.anthropic.tools.webSearch_20250305({ - maxUses: 5 - }); - } - - if (config.contextNoteId) { - Object.assign(tools, currentNoteTools(config.contextNoteId)); - } - - if (config.enableNoteTools) { - Object.assign(tools, noteTools); - Object.assign(tools, attributeTools); - } + } + }; + const tools = this.buildTools(config); if (Object.keys(tools).length > 0) { streamOptions.tools = tools; - // Allow multiple tool use cycles before final response streamOptions.stopWhen = stepCountIs(5); - // Let model decide when to use tools vs respond with text streamOptions.toolChoice = "auto"; } return streamText(streamOptions); } - - getModelPricing(model: string): ModelPricing | undefined { - return MODEL_PRICING[model]; - } - - getAvailableModels(): ModelInfo[] { - return AVAILABLE_MODELS; - } - - async generateTitle(firstMessage: string): Promise { - const { text } = await generateText({ - model: this.anthropic(TITLE_MODEL), - maxOutputTokens: TITLE_MAX_TOKENS, - messages: [ - { - role: "user", - content: `Summarize the following message as a very short chat title (max 6 words). Reply with ONLY the title, no quotes or punctuation at the end.\n\nMessage: ${firstMessage}` - } - ] - }); - - return text.trim(); - } } diff --git a/apps/server/src/services/llm/providers/base_provider.ts b/apps/server/src/services/llm/providers/base_provider.ts new file mode 100644 index 0000000000..fda95856c3 --- /dev/null +++ b/apps/server/src/services/llm/providers/base_provider.ts @@ -0,0 +1,188 @@ +/** + * Base class for LLM providers. Handles shared logic for system prompt building, + * tool assembly, model pricing, and title generation. + */ + +import { generateText, streamText, stepCountIs, type ModelMessage, type ToolSet } from "ai"; +import type { LanguageModel } from "ai"; +import type { LlmMessage } from "@triliumnext/commons"; + +import becca from "../../../becca/becca.js"; +import { getSkillsSummary } from "../skills/index.js"; +import { noteTools, attributeTools, hierarchyTools, skillTools, currentNoteTools } from "../tools/index.js"; +import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js"; + +const DEFAULT_MAX_TOKENS = 8096; +const TITLE_MAX_TOKENS = 30; + +/** + * Calculate effective cost for comparison (weighted average: 1 input + 3 output). + * Output is weighted more heavily as it's typically the dominant cost factor. + */ +function effectiveCost(pricing: ModelPricing): number { + return (pricing.input + 3 * pricing.output) / 4; +} + +/** + * Build a lightweight context hint about the current note (title + type only, no content). + */ +function buildNoteHint(noteId: string): string | null { + const note = becca.getNote(noteId); + if (!note) { + return null; + } + + return `The user is currently viewing a ${note.type} note titled "${note.title}". Use the get_current_note tool to read its content if needed.`; +} + +/** + * Build the model list with cost multipliers from a base model definition array. + */ +export function buildModelList(baseModels: Omit[]): { + models: ModelInfo[]; + pricing: Record; +} { + const baselineModel = baseModels.find(m => m.isDefault) || baseModels[0]; + const baselineCost = effectiveCost(baselineModel.pricing); + + const models = baseModels.map(m => ({ + ...m, + costMultiplier: Math.round((effectiveCost(m.pricing) / baselineCost) * 10) / 10 + })); + + const pricing = Object.fromEntries( + models.map(m => [m.id, m.pricing]) + ); + + return { models, pricing }; +} + +export abstract class BaseProvider implements LlmProvider { + abstract name: string; + + protected abstract defaultModel: string; + protected abstract titleModel: string; + protected abstract availableModels: ModelInfo[]; + protected abstract modelPricing: Record; + + /** Create a language model instance for the given model ID. */ + protected abstract createModel(modelId: string): LanguageModel; + + /** + * Build the system prompt with note hints and skills summary. + */ + protected buildSystemPrompt(messages: LlmMessage[], config: LlmProviderConfig): string | undefined { + let systemPrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content; + + if (config.contextNoteId) { + const noteHint = buildNoteHint(config.contextNoteId); + if (noteHint) { + systemPrompt = systemPrompt + ? `${systemPrompt}\n\n${noteHint}` + : noteHint; + } + } + + if (config.enableNoteTools) { + const skillsHint = `You have access to skills that provide specialized instructions. Load a skill with the load_skill tool before performing complex operations.\n\nAvailable skills:\n${getSkillsSummary()}`; + systemPrompt = systemPrompt + ? `${systemPrompt}\n\n${skillsHint}` + : skillsHint; + } + + return systemPrompt; + } + + /** + * Build the ModelMessage array from LlmMessages (no provider-specific options). + */ + protected buildMessages(chatMessages: LlmMessage[], systemPrompt: string | undefined): ModelMessage[] { + const coreMessages: ModelMessage[] = []; + + if (systemPrompt) { + coreMessages.push({ role: "system", content: systemPrompt }); + } + + for (const m of chatMessages) { + coreMessages.push({ + role: m.role as "user" | "assistant", + content: m.content + }); + } + + return coreMessages; + } + + /** + * Add provider-specific web search tool. Override in subclasses that support it. + */ + protected addWebSearchTool(_tools: ToolSet): void {} + + /** + * Build the tool set based on config. + */ + protected buildTools(config: LlmProviderConfig): ToolSet { + const tools: ToolSet = {}; + + if (config.enableWebSearch) { + this.addWebSearchTool(tools); + } + + if (config.contextNoteId) { + Object.assign(tools, currentNoteTools(config.contextNoteId)); + } + + if (config.enableNoteTools) { + Object.assign(tools, noteTools); + Object.assign(tools, attributeTools); + Object.assign(tools, hierarchyTools); + Object.assign(tools, skillTools); + } + + return tools; + } + + chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult { + const systemPrompt = this.buildSystemPrompt(messages, config); + const chatMessages = messages.filter(m => m.role !== "system"); + const coreMessages = this.buildMessages(chatMessages, systemPrompt); + + const streamOptions: Parameters[0] = { + model: this.createModel(config.model || this.defaultModel), + messages: coreMessages, + maxOutputTokens: config.maxTokens || DEFAULT_MAX_TOKENS + }; + + const tools = this.buildTools(config); + if (Object.keys(tools).length > 0) { + streamOptions.tools = tools; + streamOptions.stopWhen = stepCountIs(5); + streamOptions.toolChoice = "auto"; + } + + return streamText(streamOptions); + } + + getModelPricing(model: string): ModelPricing | undefined { + return this.modelPricing[model]; + } + + getAvailableModels(): ModelInfo[] { + return this.availableModels; + } + + async generateTitle(firstMessage: string): Promise { + const { text } = await generateText({ + model: this.createModel(this.titleModel), + maxOutputTokens: TITLE_MAX_TOKENS, + messages: [ + { + role: "user", + content: `Summarize the following message as a very short chat title (max 6 words). Reply with ONLY the title, no quotes or punctuation at the end.\n\nMessage: ${firstMessage}` + } + ] + }); + + return text.trim(); + } +} diff --git a/apps/server/src/services/llm/providers/google.ts b/apps/server/src/services/llm/providers/google.ts new file mode 100644 index 0000000000..e33b1bccca --- /dev/null +++ b/apps/server/src/services/llm/providers/google.ts @@ -0,0 +1,102 @@ +import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google"; +import { streamText, stepCountIs, type ToolSet } from "ai"; +import type { LlmMessage } from "@triliumnext/commons"; + +import type { LlmProviderConfig, StreamResult } from "../types.js"; +import { BaseProvider, buildModelList } from "./base_provider.js"; + +/** + * Available Google Gemini models with pricing (USD per million tokens). + * Source: https://ai.google.dev/gemini-api/docs/pricing + */ +const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([ + // ===== Current Models ===== + { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + pricing: { input: 1.25, output: 10 }, + contextWindow: 1048576 + }, + { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + pricing: { input: 0.3, output: 2.5 }, + contextWindow: 1048576, + isDefault: true + }, + { + id: "gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash-Lite", + pricing: { input: 0.1, output: 0.4 }, + contextWindow: 1048576 + }, + { + id: "gemini-2.0-flash", + name: "Gemini 2.0 Flash", + pricing: { input: 0.1, output: 0.4 }, + contextWindow: 1048576, + isLegacy: true + } +]); + +export class GoogleProvider extends BaseProvider { + name = "google"; + protected defaultModel = "gemini-2.5-flash"; + protected titleModel = "gemini-2.5-flash-lite"; + protected availableModels = AVAILABLE_MODELS; + protected modelPricing = MODEL_PRICING; + + private google: GoogleGenerativeAIProvider; + + constructor(apiKey: string) { + super(); + if (!apiKey) { + throw new Error("API key is required for Google provider"); + } + this.google = createGoogleGenerativeAI({ apiKey }); + } + + protected createModel(modelId: string) { + return this.google(modelId); + } + + protected override addWebSearchTool(tools: ToolSet): void { + tools.google_search = this.google.tools.googleSearch({}); + } + + /** + * Override chat to add Google-specific extended thinking support. + * Gemini 2.5 uses thinkingBudget, Gemini 3.x uses thinkingLevel. + */ + override chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult { + if (!config.enableExtendedThinking) { + return super.chat(messages, config); + } + + const systemPrompt = this.buildSystemPrompt(messages, config); + const chatMessages = messages.filter(m => m.role !== "system"); + const coreMessages = this.buildMessages(chatMessages, systemPrompt); + + const streamOptions: Parameters[0] = { + model: this.createModel(config.model || this.defaultModel), + messages: coreMessages, + maxOutputTokens: config.maxTokens || 8096, + providerOptions: { + google: { + thinkingConfig: { + thinkingBudget: config.thinkingBudget || 10000 + } + } + } + }; + + const tools = this.buildTools(config); + if (Object.keys(tools).length > 0) { + streamOptions.tools = tools; + streamOptions.stopWhen = stepCountIs(5); + streamOptions.toolChoice = "auto"; + } + + return streamText(streamOptions); + } +} diff --git a/apps/server/src/services/llm/providers/openai.ts b/apps/server/src/services/llm/providers/openai.ts new file mode 100644 index 0000000000..759d31c7e7 --- /dev/null +++ b/apps/server/src/services/llm/providers/openai.ts @@ -0,0 +1,84 @@ +import { createOpenAI, type OpenAIProvider as OpenAISDKProvider } from "@ai-sdk/openai"; +import type { ToolSet } from "ai"; + +import { BaseProvider, buildModelList } from "./base_provider.js"; + +/** + * Available OpenAI models with pricing (USD per million tokens). + * Source: https://platform.openai.com/docs/pricing + */ +const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([ + // ===== Current Models ===== + { + id: "gpt-4.1", + name: "GPT-4.1", + pricing: { input: 2, output: 8 }, + contextWindow: 1047576, + isDefault: true + }, + { + id: "gpt-4.1-mini", + name: "GPT-4.1 Mini", + pricing: { input: 0.4, output: 1.6 }, + contextWindow: 1047576 + }, + { + id: "gpt-4.1-nano", + name: "GPT-4.1 Nano", + pricing: { input: 0.1, output: 0.4 }, + contextWindow: 1047576 + }, + { + id: "o3", + name: "o3", + pricing: { input: 2, output: 8 }, + contextWindow: 200000 + }, + { + id: "o4-mini", + name: "o4-mini", + pricing: { input: 1.1, output: 4.4 }, + contextWindow: 200000 + }, + // ===== Legacy Models ===== + { + id: "gpt-4o", + name: "GPT-4o", + pricing: { input: 2.5, output: 10 }, + contextWindow: 128000, + isLegacy: true + }, + { + id: "gpt-4o-mini", + name: "GPT-4o Mini", + pricing: { input: 0.15, output: 0.6 }, + contextWindow: 128000, + isLegacy: true + } +]); + +export class OpenAiProvider extends BaseProvider { + name = "openai"; + protected defaultModel = "gpt-4.1"; + protected titleModel = "gpt-4.1-mini"; + protected availableModels = AVAILABLE_MODELS; + protected modelPricing = MODEL_PRICING; + + private openai: OpenAISDKProvider; + + constructor(apiKey: string) { + super(); + if (!apiKey) { + throw new Error("API key is required for OpenAI provider"); + } + this.openai = createOpenAI({ apiKey }); + } + + protected createModel(modelId: string) { + return this.openai(modelId); + } + + protected override addWebSearchTool(tools: ToolSet): void { + tools.web_search = this.openai.tools.webSearch(); + } +} diff --git a/apps/server/src/services/llm/skills/index.ts b/apps/server/src/services/llm/skills/index.ts new file mode 100644 index 0000000000..614820a5fa --- /dev/null +++ b/apps/server/src/services/llm/skills/index.ts @@ -0,0 +1,78 @@ +/** + * LLM skills — on-demand instruction sets that an LLM can load when it needs + * specialized knowledge (e.g. search syntax). Only names and descriptions are + * included in the system prompt; full content is fetched via the load_skill tool. + */ + +import { readFile } from "fs/promises"; +import { join } from "path"; + +import { tool } from "ai"; +import { z } from "zod"; + +import resourceDir from "../../resource_dir.js"; + +const SKILLS_DIR = join(resourceDir.RESOURCE_DIR, "llm", "skills"); + +interface SkillDefinition { + name: string; + description: string; + file: string; +} + +const SKILLS: SkillDefinition[] = [ + { + name: "search_syntax", + description: "Trilium search query syntax reference — labels, relations, note properties, boolean logic, ordering, and more.", + file: "search_syntax.md" + }, + { + name: "backend_scripting", + description: "Backend (Node.js) scripting API — creating notes, handling events, accessing entities, database operations, and automation.", + file: "backend_scripting.md" + }, + { + name: "frontend_scripting", + description: "Frontend (browser) scripting API — UI widgets, navigation, dialogs, editor access, Preact/JSX components, and keyboard shortcuts.", + file: "frontend_scripting.md" + } +]; + +async function loadSkillContent(name: string): Promise { + const skill = SKILLS.find((s) => s.name === name); + if (!skill) { + return null; + } + return readFile(join(SKILLS_DIR, skill.file), "utf-8"); +} + +/** + * Returns a summary of available skills for inclusion in the system prompt. + */ +export function getSkillsSummary(): string { + return SKILLS + .map((s) => `- **${s.name}**: ${s.description}`) + .join("\n"); +} + +/** + * The load_skill tool — lets the LLM fetch full instructions on demand. + */ +export const loadSkill = tool({ + description: "Load a skill to get specialized instructions. Available skills:\n" + + SKILLS.map((s) => `- ${s.name}: ${s.description}`).join("\n"), + inputSchema: z.object({ + name: z.string().describe("The skill name to load") + }), + execute: async ({ name }) => { + const content = await loadSkillContent(name); + if (!content) { + return { error: `Unknown skill: '${name}'. Available: ${SKILLS.map((s) => s.name).join(", ")}` }; + } + return { skill: name, instructions: content }; + } +}); + +export const skillTools = { + load_skill: loadSkill +}; diff --git a/apps/server/src/services/llm/tools/hierarchy_tools.ts b/apps/server/src/services/llm/tools/hierarchy_tools.ts new file mode 100644 index 0000000000..9bfe60afe2 --- /dev/null +++ b/apps/server/src/services/llm/tools/hierarchy_tools.ts @@ -0,0 +1,102 @@ +/** + * LLM tools for navigating the note hierarchy (tree structure, branches). + */ + +import { tool } from "ai"; +import { z } from "zod"; + +import becca from "../../../becca/becca.js"; +import type BNote from "../../../becca/entities/bnote.js"; + +/** + * Get the child notes of a given note. + */ +export const getChildNotes = tool({ + description: "Get the immediate child notes of a note. Returns each child's ID, title, type, and whether it has children of its own. Use noteId 'root' to list top-level notes.", + inputSchema: z.object({ + noteId: z.string().describe("The ID of the parent note (use 'root' for top-level)") + }), + execute: async ({ noteId }) => { + const note = becca.getNote(noteId); + if (!note) { + return { error: "Note not found" }; + } + + return note.getChildNotes().map((child) => ({ + noteId: child.noteId, + title: child.getTitleOrProtected(), + type: child.type, + childCount: child.getChildNotes().length + })); + } +}); + +//#region Subtree tool implementation +const MAX_DEPTH = 5; +const MAX_CHILDREN_PER_LEVEL = 10; + +interface SubtreeNode { + noteId: string; + title: string; + type: string; + children?: SubtreeNode[] | string; +} + +function buildSubtree(note: BNote, depth: number, maxDepth: number): SubtreeNode { + const node: SubtreeNode = { + noteId: note.noteId, + title: note.getTitleOrProtected(), + type: note.type + }; + + if (depth >= maxDepth) { + const childCount = note.getChildNotes().length; + if (childCount > 0) { + node.children = `${childCount} children not shown (depth limit reached)`; + } + return node; + } + + const children = note.getChildNotes(); + if (children.length === 0) { + return node; + } + + const shown = children.slice(0, MAX_CHILDREN_PER_LEVEL); + node.children = shown.map((child) => buildSubtree(child, depth + 1, maxDepth)); + + if (children.length > MAX_CHILDREN_PER_LEVEL) { + node.children.push({ + noteId: "", + title: `... and ${children.length - MAX_CHILDREN_PER_LEVEL} more`, + type: "truncated" + }); + } + + return node; +} + +/** + * Get a subtree of notes up to a specified depth. + */ +export const getSubtree = tool({ + description: "Get a nested subtree of notes starting from a given note, traversing multiple levels deep. Useful for understanding the structure of a section of the note tree. Each level shows up to 10 children.", + inputSchema: z.object({ + noteId: z.string().describe("The ID of the root note for the subtree (use 'root' for the entire tree)"), + depth: z.number().min(1).max(MAX_DEPTH).optional().describe(`How many levels deep to traverse (1-${MAX_DEPTH}). Defaults to 2.`) + }), + execute: async ({ noteId, depth = 2 }) => { + const note = becca.getNote(noteId); + if (!note) { + return { error: "Note not found" }; + } + + return buildSubtree(note, 0, depth); + } +}); +//#endregion + +export const hierarchyTools = { + get_child_notes: getChildNotes, + get_subtree: getSubtree +}; diff --git a/apps/server/src/services/llm/tools/index.ts b/apps/server/src/services/llm/tools/index.ts index dc2257ef1b..615067674b 100644 --- a/apps/server/src/services/llm/tools/index.ts +++ b/apps/server/src/services/llm/tools/index.ts @@ -5,3 +5,5 @@ export { noteTools, currentNoteTools } from "./note_tools.js"; export { attributeTools } from "./attribute_tools.js"; +export { hierarchyTools } from "./hierarchy_tools.js"; +export { skillTools } from "../skills/index.js"; diff --git a/apps/server/src/services/llm/tools/note_tools.ts b/apps/server/src/services/llm/tools/note_tools.ts index 90eb475e16..7e65c7ebde 100644 --- a/apps/server/src/services/llm/tools/note_tools.ts +++ b/apps/server/src/services/llm/tools/note_tools.ts @@ -43,15 +43,33 @@ function setNoteContentFromLlm(note: { type: string; title: string; setContent: * Search for notes in the knowledge base. */ export const searchNotes = tool({ - description: "Search for notes in the user's knowledge base. Returns note metadata including title, type, and IDs.", + description: [ + "Search for notes in the user's knowledge base using Trilium search syntax.", + "For complex queries (boolean logic, relations, regex, ordering), load the 'search_syntax' skill first via load_skill.", + "Common patterns:", + "- Full-text: 'rings tolkien' (notes containing both words)", + "- By label: '#book', '#status = done', '#year >= 2000'", + "- By type: 'note.type = code'", + "- By relation: '~author', '~author.title *= Tolkien'", + "- Combined: 'tolkien #book' (full-text + label filter)", + "- Negation: '#!archived' (notes WITHOUT label)" + ].join(" "), inputSchema: z.object({ - query: z.string().describe("Search query (supports Trilium search syntax)") + query: z.string().describe("Search query in Trilium search syntax"), + fastSearch: z.boolean().optional().describe("If true, skip content search (only titles and attributes). Faster for large databases."), + includeArchivedNotes: z.boolean().optional().describe("If true, include archived notes in results."), + ancestorNoteId: z.string().optional().describe("Limit search to a subtree rooted at this note ID."), + limit: z.number().optional().describe("Maximum number of results to return. Defaults to 10.") }), - execute: async ({ query }) => { - const searchContext = new SearchContext({}); + execute: async ({ query, fastSearch, includeArchivedNotes, ancestorNoteId, limit = 10 }) => { + const searchContext = new SearchContext({ + fastSearch, + includeArchivedNotes, + ancestorNoteId + }); const results = searchService.findResultsWithQuery(query, searchContext); - return results.slice(0, 10).map(sr => { + return results.slice(0, limit).map(sr => { const note = becca.notes[sr.noteId]; if (!note) return null; return { @@ -168,14 +186,27 @@ export const appendToNote = tool({ * Create a new note. */ export const createNote = tool({ - description: "Create a new note in the user's knowledge base. Returns the created note's ID and title.", + description: [ + "Create a new note in the user's knowledge base. Returns the created note's ID and title.", + "Set type to 'text' for rich text notes (content in Markdown) or 'code' for code notes (must also set mime).", + "Common mime values for code notes:", + "'application/javascript;env=frontend' (JS frontend),", + "'application/javascript;env=backend' (JS backend),", + "'text/jsx' (Preact JSX, preferred for frontend widgets),", + "'text/css', 'text/html', 'application/json', 'text/x-python', 'text/x-sh'." + ].join(" "), inputSchema: z.object({ - parentNoteId: z.string().describe("The ID of the parent note where the new note will be created. Use 'root' for top-level notes."), + parentNoteId: z.string().describe("The ID of the parent note. Use 'root' for top-level notes."), title: z.string().describe("The title of the new note"), content: z.string().describe("The content of the note (Markdown for text notes, plain text for code notes)"), - type: z.enum(["text", "code"]).optional().describe("The type of note to create. Defaults to 'text'.") + type: z.enum(["text", "code"]).describe("The type of note to create."), + mime: z.string().optional().describe("MIME type, REQUIRED for code notes (e.g. 'application/javascript;env=backend', 'text/jsx'). Ignored for text notes.") }), - execute: async ({ parentNoteId, title, content, type = "text" }) => { + execute: async ({ parentNoteId, title, content, type, mime }) => { + if (type === "code" && !mime) { + return { error: "mime is required when creating code notes" }; + } + const parentNote = becca.getNote(parentNoteId); if (!parentNote) { return { error: "Parent note not found" }; @@ -193,7 +224,8 @@ export const createNote = tool({ parentNoteId, title, content: htmlContent, - type + type, + ...(mime ? { mime } : {}) }); return { diff --git a/apps/server/src/services/llm/types.ts b/apps/server/src/services/llm/types.ts index 0e4ae71b32..3924d8601c 100644 --- a/apps/server/src/services/llm/types.ts +++ b/apps/server/src/services/llm/types.ts @@ -38,6 +38,8 @@ export interface ModelInfo { id: string; /** Human-readable name (e.g., "Claude Sonnet 4") */ name: string; + /** Provider type that owns this model (e.g., "anthropic", "openai") */ + provider?: string; /** Pricing per million tokens */ pricing: ModelPricing; /** Whether this is the default model */ diff --git a/packages/commons/src/lib/llm_api.ts b/packages/commons/src/lib/llm_api.ts index 5f6525bcc6..7554d9d40c 100644 --- a/packages/commons/src/lib/llm_api.ts +++ b/packages/commons/src/lib/llm_api.ts @@ -63,6 +63,8 @@ export interface LlmModelInfo { id: string; /** Human-readable name (e.g., "Claude Sonnet 4") */ name: string; + /** Provider type that owns this model (e.g., "anthropic", "openai") */ + provider?: string; /** Pricing per million tokens */ pricing: LlmModelPricing; /** Whether this is the default model */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b54e4f56f..b74a7161ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -557,11 +557,17 @@ importers: apps/server: dependencies: '@ai-sdk/anthropic': - specifier: ^2.0.0 - version: 2.0.71(zod@4.3.6) + specifier: 3.0.64 + version: 3.0.64(zod@4.3.6) + '@ai-sdk/google': + specifier: 3.0.54 + version: 3.0.54(zod@4.3.6) + '@ai-sdk/openai': + specifier: 3.0.49 + version: 3.0.49(zod@4.3.6) ai: - specifier: ^5.0.0 - version: 5.0.161(zod@4.3.6) + specifier: 6.0.142 + version: 6.0.142(zod@4.3.6) better-sqlite3: specifier: 12.8.0 version: 12.8.0 @@ -1536,26 +1542,38 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} - '@ai-sdk/anthropic@2.0.71': - resolution: {integrity: sha512-JXTtAwlyxGzzRtpiAXk/O93aOTgdfoVX28EoUuRNVqZRgtkoniLQTtqeb8uZ4oXljNJlXzaJLNasS/U90w/wjw==} + '@ai-sdk/anthropic@3.0.64': + resolution: {integrity: sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@2.0.65': - resolution: {integrity: sha512-yaWzvQQWgAzV0m3eidfpRub1+PggDOr2hLnSOI+L2ZispyJ/7EoSzhjKzNCADj6PHnnPaOMH933Xhl1Z/NSxJw==} + '@ai-sdk/gateway@3.0.84': + resolution: {integrity: sha512-RnUw6UNvkaw9MEaJU9cIjA+WBP+ZR5+M/9nfbfJHcGKtTbcWXijJuYKx9nYRnm+qU+iiakb0XvQA/vvho6lTsw==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@3.0.22': - resolution: {integrity: sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw==} + '@ai-sdk/google@3.0.54': + resolution: {integrity: sha512-EgYYdA2LpHZefLDU/FIpmeTlL5Hi4WKQZY3nACMh0wVhrS1fAvlfrdwnD1G4ISCOKWMWrMcRZX9ubs3NM/KHfA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@2.0.1': - resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==} + '@ai-sdk/openai@3.0.49': + resolution: {integrity: sha512-U2f0pCyNn/jQH3wjgxr8o9VvCkuDFTtXbIhbFFtgXqCzMbed6rBnvzQcAMEK0/Pa44byL9zfcvCOFOflvkRA8w==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.21': + resolution: {integrity: sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} '@aklinker1/rollup-plugin-visualizer@5.12.0': @@ -7366,8 +7384,8 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} - ai@5.0.161: - resolution: {integrity: sha512-CVANs7auUNEi/hRhdJDKcPYaCLWXveIfmoiekNSRel3i8WUieB6iEncDS5smcubWsx7hGtTgXxNRTg0YG0ljtA==} + ai@6.0.142: + resolution: {integrity: sha512-ZoxAsnTL/dFg5WdcwC8QNhKVlLtqwwT3I7p/4i8IJJP+6ZwqF1ljuwMsAsPYYvppZ+RzUxjxxFGb1cbEhNH3dg==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -16043,27 +16061,39 @@ snapshots: '@adobe/css-tools@4.4.4': {} - '@ai-sdk/anthropic@2.0.71(zod@4.3.6)': + '@ai-sdk/anthropic@3.0.64(zod@4.3.6)': dependencies: - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.22(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.21(zod@4.3.6) zod: 4.3.6 - '@ai-sdk/gateway@2.0.65(zod@4.3.6)': + '@ai-sdk/gateway@3.0.84(zod@4.3.6)': dependencies: - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.22(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.21(zod@4.3.6) '@vercel/oidc': 3.1.0 zod: 4.3.6 - '@ai-sdk/provider-utils@3.0.22(zod@4.3.6)': + '@ai-sdk/google@3.0.54(zod@4.3.6)': dependencies: - '@ai-sdk/provider': 2.0.1 + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.21(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/openai@3.0.49(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.21(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/provider-utils@4.0.21(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 zod: 4.3.6 - '@ai-sdk/provider@2.0.1': + '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 @@ -17014,6 +17044,8 @@ 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: @@ -17031,6 +17063,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-collaboration-core@47.6.1': dependencies: @@ -17259,8 +17293,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: @@ -17396,6 +17428,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-highlight@47.6.1': dependencies: @@ -17405,6 +17439,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-horizontal-line@47.6.1': dependencies: @@ -17425,6 +17461,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 '@ckeditor/ckeditor5-widget': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-html-support@47.6.1': dependencies: @@ -17459,6 +17497,8 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-import-word@47.6.1': dependencies: @@ -17482,6 +17522,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-inspector@5.0.0': {} @@ -17492,6 +17534,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-line-height@47.6.1': dependencies: @@ -17516,6 +17560,8 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-list-multi-level@47.6.1': dependencies: @@ -17540,6 +17586,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-markdown-gfm@47.6.1': dependencies: @@ -17577,6 +17625,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 '@ckeditor/ckeditor5-widget': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-mention@47.6.1(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)': dependencies: @@ -17606,8 +17656,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: @@ -24558,11 +24606,11 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ai@5.0.161(zod@4.3.6): + ai@6.0.142(zod@4.3.6): dependencies: - '@ai-sdk/gateway': 2.0.65(zod@4.3.6) - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.22(zod@4.3.6) + '@ai-sdk/gateway': 3.0.84(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.21(zod@4.3.6) '@opentelemetry/api': 1.9.0 zod: 4.3.6 @@ -25405,6 +25453,8 @@ snapshots: ckeditor5-collaboration@47.6.1: dependencies: '@ckeditor/ckeditor5-collaboration-core': 47.6.1 + transitivePeerDependencies: + - supports-color ckeditor5-premium-features@47.6.1(bufferutil@4.0.9)(ckeditor5@47.6.1)(utf-8-validate@6.0.5): dependencies: