mirror of
https://github.com/zadam/trilium.git
synced 2026-03-31 17:20:24 +02:00
Compare commits
10 Commits
renovate/t
...
feature/ll
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d169809bd | ||
|
|
2929d64fa0 | ||
|
|
20311d31f6 | ||
|
|
c13b68ef42 | ||
|
|
8eff623b67 | ||
|
|
f4b9207379 | ||
|
|
90930e19e7 | ||
|
|
8c0dacd6d7 | ||
|
|
c617bea45a | ||
|
|
bac25c9173 |
@@ -104,7 +104,7 @@ export interface SavedData {
|
||||
|
||||
export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, onContentChange, dataSaved, updateInterval }: {
|
||||
noteType: NoteType;
|
||||
note: FNote,
|
||||
note: FNote | null | undefined,
|
||||
noteContext: NoteContext | null | undefined,
|
||||
getData: () => Promise<SavedData | undefined> | SavedData | undefined,
|
||||
onContentChange: (newContent: string) => void,
|
||||
@@ -118,8 +118,8 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on
|
||||
return async () => {
|
||||
const data = await getData();
|
||||
|
||||
// for read only notes
|
||||
if (data === undefined || note.type !== noteType) return;
|
||||
// for read only notes, or if note is not yet available (e.g. lazy creation)
|
||||
if (data === undefined || !note || note.type !== noteType) return;
|
||||
|
||||
protected_session_holder.touchProtectedSessionIfNecessary(note);
|
||||
|
||||
@@ -138,7 +138,7 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on
|
||||
|
||||
// React to note/blob changes.
|
||||
useEffect(() => {
|
||||
if (!blob) return;
|
||||
if (!blob || !note) return;
|
||||
noteSavedDataStore.set(note.noteId, blob.content);
|
||||
spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content));
|
||||
}, [ blob ]);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { formatDateTime } from "../../utils/formatters";
|
||||
import ActionButton from "../react/ActionButton.js";
|
||||
import Dropdown from "../react/Dropdown.js";
|
||||
import { FormListItem } from "../react/FormList.js";
|
||||
import { useActiveNoteContext, useNote, useNoteProperty } from "../react/hooks.js";
|
||||
import { useActiveNoteContext, useEditorSpacedUpdate, useNote, useNoteProperty } from "../react/hooks.js";
|
||||
import NoItems from "../react/NoItems.js";
|
||||
import ChatInputBar from "../type_widgets/llm_chat/ChatInputBar.js";
|
||||
import ChatMessage from "../type_widgets/llm_chat/ChatMessage.js";
|
||||
@@ -25,9 +25,8 @@ import RightPanelWidget from "./RightPanelWidget.js";
|
||||
*/
|
||||
export default function SidebarChat() {
|
||||
const [chatNoteId, setChatNoteId] = useState<string | null>(null);
|
||||
const [shouldSave, setShouldSave] = useState(false);
|
||||
const [recentChats, setRecentChats] = useState<RecentLlmChat[]>([]);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const spacedUpdateRef = useRef<{ scheduleUpdate: () => void }>(null);
|
||||
const historyDropdownRef = useRef<BootstrapDropdown | null>(null);
|
||||
|
||||
// Get the current active note context
|
||||
@@ -40,10 +39,40 @@ export default function SidebarChat() {
|
||||
// Use shared chat hook with sidebar-specific options
|
||||
const chat = useLlmChat(
|
||||
// onMessagesChange - trigger save
|
||||
() => setShouldSave(true),
|
||||
() => spacedUpdateRef.current?.scheduleUpdate(),
|
||||
{ defaultEnableNoteTools: true, supportsExtendedThinking: true }
|
||||
);
|
||||
|
||||
// Ref to access chat methods in callbacks without triggering re-runs
|
||||
const chatRef = useRef(chat);
|
||||
chatRef.current = chat;
|
||||
|
||||
// Persistence via useEditorSpacedUpdate (same mechanism as the LlmChat type widget).
|
||||
// When chatNote is null (before lazy creation), saves are no-ops.
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note: chatNote,
|
||||
noteType: "llmChat",
|
||||
noteContext: null,
|
||||
getData: () => {
|
||||
const content = chatRef.current.getContent();
|
||||
return { content: JSON.stringify(content) };
|
||||
},
|
||||
onContentChange: (content) => {
|
||||
if (!content) {
|
||||
chatRef.current.clearMessages();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed: LlmChatContent = JSON.parse(content);
|
||||
chatRef.current.loadFromContent(parsed);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse LLM chat content:", e);
|
||||
chatRef.current.clearMessages();
|
||||
}
|
||||
}
|
||||
});
|
||||
spacedUpdateRef.current = spacedUpdate;
|
||||
|
||||
// Update chat context when active note changes
|
||||
useEffect(() => {
|
||||
chat.setContextNoteId(activeNoteId ?? undefined);
|
||||
@@ -54,42 +83,6 @@ export default function SidebarChat() {
|
||||
chat.setChatNoteId(chatNoteId ?? undefined);
|
||||
}, [chatNoteId, chat.setChatNoteId]);
|
||||
|
||||
// Ref to access chat methods in effects without triggering re-runs
|
||||
const chatRef = useRef(chat);
|
||||
chatRef.current = chat;
|
||||
|
||||
// Handle debounced save when shouldSave is triggered
|
||||
useEffect(() => {
|
||||
if (!shouldSave || !chatNoteId) {
|
||||
setShouldSave(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShouldSave(false);
|
||||
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
const content = chat.getContent();
|
||||
try {
|
||||
await server.put(`notes/${chatNoteId}/data`, {
|
||||
content: JSON.stringify(content)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to save chat:", err);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, [shouldSave, chatNoteId, chat]);
|
||||
|
||||
// Load the most recent chat on mount (runs once)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -102,16 +95,6 @@ export default function SidebarChat() {
|
||||
|
||||
if (existingChat) {
|
||||
setChatNoteId(existingChat.noteId);
|
||||
// Load content inline to avoid dependency issues
|
||||
try {
|
||||
const blob = await server.get<{ content: string }>(`notes/${existingChat.noteId}/blob`);
|
||||
if (!cancelled && blob?.content) {
|
||||
const parsed: LlmChatContent = JSON.parse(blob.content);
|
||||
chatRef.current.loadFromContent(parsed);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load chat content:", err);
|
||||
}
|
||||
} else {
|
||||
// No existing chat - will create on first message
|
||||
setChatNoteId(null);
|
||||
@@ -170,31 +153,38 @@ export default function SidebarChat() {
|
||||
}, [handleSubmit]);
|
||||
|
||||
const handleNewChat = useCallback(async () => {
|
||||
// Save any pending changes before switching
|
||||
await spacedUpdate.updateNowIfNecessary();
|
||||
|
||||
try {
|
||||
const note = await dateNoteService.createLlmChat();
|
||||
if (note) {
|
||||
setChatNoteId(note.noteId);
|
||||
chat.clearMessages();
|
||||
chatRef.current.clearMessages();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create new chat:", err);
|
||||
}
|
||||
}, [chat]);
|
||||
}, [spacedUpdate]);
|
||||
|
||||
const handleSaveChat = useCallback(async () => {
|
||||
if (!chatNoteId) return;
|
||||
|
||||
// Save any pending changes before moving the chat
|
||||
await spacedUpdate.updateNowIfNecessary();
|
||||
|
||||
try {
|
||||
await server.post("special-notes/save-llm-chat", { llmChatNoteId: chatNoteId });
|
||||
// Create a new empty chat after saving
|
||||
const note = await dateNoteService.createLlmChat();
|
||||
if (note) {
|
||||
setChatNoteId(note.noteId);
|
||||
chat.clearMessages();
|
||||
chatRef.current.clearMessages();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to save chat to permanent location:", err);
|
||||
}
|
||||
}, [chatNoteId, chat]);
|
||||
}, [chatNoteId, spacedUpdate]);
|
||||
|
||||
const loadRecentChats = useCallback(async () => {
|
||||
try {
|
||||
@@ -210,17 +200,11 @@ export default function SidebarChat() {
|
||||
|
||||
if (noteId === chatNoteId) return;
|
||||
|
||||
try {
|
||||
const blob = await server.get<{ content: string }>(`notes/${noteId}/blob`);
|
||||
if (blob?.content) {
|
||||
const parsed: LlmChatContent = JSON.parse(blob.content);
|
||||
setChatNoteId(noteId);
|
||||
chat.loadFromContent(parsed);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load selected chat:", err);
|
||||
}
|
||||
}, [chatNoteId, chat]);
|
||||
// Save any pending changes before switching
|
||||
await spacedUpdate.updateNowIfNecessary();
|
||||
|
||||
setChatNoteId(noteId);
|
||||
}, [chatNoteId, spacedUpdate]);
|
||||
|
||||
return (
|
||||
<RightPanelWidget
|
||||
|
||||
@@ -3,7 +3,8 @@ import { generateText, streamText, stepCountIs, type CoreMessage, type ToolSet }
|
||||
import type { LlmMessage } from "@triliumnext/commons";
|
||||
|
||||
import becca from "../../../becca/becca.js";
|
||||
import { noteTools, attributeTools, currentNoteTools } from "../tools/index.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_MODEL = "claude-sonnet-4-6";
|
||||
@@ -135,6 +136,14 @@ export class AnthropicProvider implements LlmProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Add skills hint so the LLM knows to load skills before complex operations
|
||||
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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -206,6 +215,8 @@ export class AnthropicProvider implements LlmProvider {
|
||||
if (config.enableNoteTools) {
|
||||
Object.assign(tools, noteTools);
|
||||
Object.assign(tools, attributeTools);
|
||||
Object.assign(tools, hierarchyTools);
|
||||
Object.assign(tools, skillTools);
|
||||
}
|
||||
|
||||
if (Object.keys(tools).length > 0) {
|
||||
|
||||
156
apps/server/src/services/llm/skills/backend_scripting.md
Normal file
156
apps/server/src/services/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/services/llm/skills/frontend_scripting.md
Normal file
240
apps/server/src/services/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.
|
||||
76
apps/server/src/services/llm/skills/index.ts
Normal file
76
apps/server/src/services/llm/skills/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 { tool } from "ai";
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { z } from "zod";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
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"
|
||||
}
|
||||
];
|
||||
|
||||
function loadSkillContent(name: string): string | null {
|
||||
const skill = SKILLS.find((s) => s.name === name);
|
||||
if (!skill) {
|
||||
return null;
|
||||
}
|
||||
return readFileSync(join(__dirname, 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 = 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
|
||||
};
|
||||
50
apps/server/src/services/llm/skills/search_syntax.md
Normal file
50
apps/server/src/services/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
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user