mirror of
https://github.com/zadam/trilium.git
synced 2026-05-07 02:36:07 +02:00
Feature/llm tools (#9241)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<LlmModelInfo[]> {
|
||||
const response = await server.get<{ models?: LlmModelInfo[] }>(`llm-chat/models?provider=${encodeURIComponent(provider)}`);
|
||||
export async function getAvailableModels(): Promise<LlmModelInfo[]> {
|
||||
const response = await server.get<{ models?: LlmModelInfo[] }>("llm-chat/models");
|
||||
return response.models ?? [];
|
||||
}
|
||||
|
||||
|
||||
@@ -239,8 +239,10 @@ export function useLlmChat(
|
||||
.join("")
|
||||
}));
|
||||
|
||||
const selectedModelProvider = availableModels.find(m => m.id === selectedModel)?.provider;
|
||||
const streamOptions: Parameters<typeof streamChatCompletion>[1] = {
|
||||
model: selectedModel || undefined,
|
||||
provider: selectedModelProvider,
|
||||
enableWebSearch,
|
||||
enableNoteTools,
|
||||
contextNoteId,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
156
apps/server/src/assets/llm/skills/backend_scripting.md
Normal file
156
apps/server/src/assets/llm/skills/backend_scripting.md
Normal file
@@ -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')`.
|
||||
240
apps/server/src/assets/llm/skills/frontend_scripting.md
Normal file
240
apps/server/src/assets/llm/skills/frontend_scripting.md
Normal file
@@ -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 (
|
||||
<div>
|
||||
<button onClick={() => setCount(c => c + 1)}>
|
||||
Clicked {count} times
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 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 <span>Current note: {title}</span>;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<RightPanelWidget id="my-clock" title="Clock">
|
||||
<p>The time is: {time}</p>
|
||||
</RightPanelWidget>
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 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 `<RightPanelWidget>` |
|
||||
|
||||
### 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 (
|
||||
<>
|
||||
<h1>Custom rendered content</h1>
|
||||
<p>This appears inside the note.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 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 = $("<div>");
|
||||
this.$widget.append($("<button>Click me</button>")
|
||||
.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.
|
||||
50
apps/server/src/assets/llm/skills/search_syntax.md
Normal file
50
apps/server/src/assets/llm/skills/search_syntax.md
Normal file
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, (apiKey: string) => 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<string>();
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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<ModelInfo, "costMultiplier">[] = [
|
||||
const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([
|
||||
// ===== Current Models =====
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
@@ -49,7 +35,7 @@ const BASE_MODELS: Omit<ModelInfo, "costMultiplier">[] = [
|
||||
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<ModelInfo, "costMultiplier">[] = [
|
||||
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<ModelInfo, "costMultiplier">[] = [
|
||||
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<string, ModelPricing> = 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<typeof streamText>[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<string> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
188
apps/server/src/services/llm/providers/base_provider.ts
Normal file
188
apps/server/src/services/llm/providers/base_provider.ts
Normal file
@@ -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<ModelInfo, "costMultiplier">[]): {
|
||||
models: ModelInfo[];
|
||||
pricing: Record<string, ModelPricing>;
|
||||
} {
|
||||
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<string, ModelPricing>;
|
||||
|
||||
/** 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<typeof streamText>[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<string> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
102
apps/server/src/services/llm/providers/google.ts
Normal file
102
apps/server/src/services/llm/providers/google.ts
Normal file
@@ -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<typeof streamText>[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);
|
||||
}
|
||||
}
|
||||
84
apps/server/src/services/llm/providers/openai.ts
Normal file
84
apps/server/src/services/llm/providers/openai.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
78
apps/server/src/services/llm/skills/index.ts
Normal file
78
apps/server/src/services/llm/skills/index.ts
Normal file
@@ -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<string | null> {
|
||||
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
|
||||
};
|
||||
102
apps/server/src/services/llm/tools/hierarchy_tools.ts
Normal file
102
apps/server/src/services/llm/tools/hierarchy_tools.ts
Normal file
@@ -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
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
112
pnpm-lock.yaml
generated
112
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user