Merge remote-tracking branch 'origin/main' into standalone

This commit is contained in:
Elian Doran
2026-03-31 22:43:39 +03:00
95 changed files with 7134 additions and 1536 deletions

View File

@@ -152,14 +152,28 @@ SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/servi
- Schema: `apps/server/src/assets/db/schema.sql`
- Migrations: `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
### Attribute Inheritance
### Internationalization
- Translation files in `apps/client/src/translations/`
- Supported languages: English, German, Spanish, French, Romanian, Chinese
- **Only add new translation keys to `en/translation.json`** — translations for other languages are managed via Weblate and will be contributed by the community
- Third-party components (e.g., mind-map context menu) should use i18next `t()` for their labels, with the English strings added to `en/translation.json` under a dedicated namespace (e.g., `"mind-map"`)
Three inheritance mechanisms:
1. **Standard**: `note.getInheritableAttributes()` walks parent tree
2. **Child prefix**: `child:label` on parent copies to children
3. **Template relation**: `#template=noteNoteId` includes template's inheritable attributes
### Attribute Inheritance
Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited.
### Client-Side API Restrictions
- **Do not use `crypto.randomUUID()`** or other Web Crypto APIs that require secure contexts - Trilium can run over HTTP, not just HTTPS
- Use `randomString()` from `apps/client/src/services/utils.ts` for generating IDs instead
### Shared Types Policy
- Types shared between client and server belong in `@triliumnext/commons` (`packages/commons/src/lib/`)
- Import shared types directly from `@triliumnext/commons` - do not re-export them from app-specific modules
- Keep app-specific types (e.g., `LlmProvider` for server, `StreamCallbacks` for client) in their respective apps
## Important Patterns
@@ -198,3 +212,16 @@ Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited.
- `apps/client/src/services/froca.ts` — Frontend cache
- `apps/server/src/routes/routes.ts` — API route registration
- `packages/trilium-core/src/services/sql/sql.ts` — Database abstraction
### 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
- ESBuild for production optimization
- pnpm workspaces for dependency management
- Docker support with multi-stage builds

View File

@@ -20,7 +20,7 @@
"@triliumnext/server": "workspace:*"
},
"devDependencies": {
"@redocly/cli": "2.25.2",
"@redocly/cli": "2.25.3",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",

View File

@@ -35,21 +35,22 @@
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@univerjs/preset-sheets-conditional-formatting": "0.18.0",
"@univerjs/preset-sheets-core": "0.18.0",
"@univerjs/preset-sheets-data-validation": "0.18.0",
"@univerjs/preset-sheets-filter": "0.18.0",
"@univerjs/preset-sheets-find-replace": "0.18.0",
"@univerjs/preset-sheets-note": "0.18.0",
"@univerjs/preset-sheets-sort": "0.18.0",
"@univerjs/presets": "0.18.0",
"@zumer/snapdom": "2.6.0",
"@univerjs/preset-sheets-conditional-formatting": "0.19.0",
"@univerjs/preset-sheets-core": "0.19.0",
"@univerjs/preset-sheets-data-validation": "0.19.0",
"@univerjs/preset-sheets-filter": "0.19.0",
"@univerjs/preset-sheets-find-replace": "0.19.0",
"@univerjs/preset-sheets-note": "0.19.0",
"@univerjs/preset-sheets-sort": "0.19.0",
"@univerjs/presets": "0.19.0",
"@zumer/snapdom": "2.7.0",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
"clsx": "2.1.1",
"color": "5.0.3",
"debounce": "3.0.0",
"dompurify": "3.3.3",
"draggabilly": "3.0.0",
"force-graph": "1.51.2",
"globals": "17.4.0",
@@ -64,11 +65,11 @@
"mark.js": "8.11.1",
"marked": "17.0.5",
"mermaid": "11.13.0",
"mind-elixir": "5.9.3",
"mind-elixir": "5.10.0",
"normalize.css": "8.0.1",
"panzoom": "9.4.4",
"preact": "10.29.0",
"react-i18next": "17.0.0",
"react-i18next": "17.0.1",
"react-window": "2.2.7",
"reveal.js": "6.0.0",
"rrule": "2.8.1",

View File

@@ -508,7 +508,7 @@ type EventMappings = {
contentSafeMarginChanged: {
top: number;
noteContext: NoteContext;
}
};
};
export type EventListener<T extends EventNames> = {

View File

@@ -18,7 +18,7 @@ const RELATION = "relation";
* end user. Those types should be used only for checking against, they are
* not for direct use.
*/
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet";
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet" | "llmChat";
export interface NotePathRecord {
isArchived: boolean;

View File

@@ -84,6 +84,55 @@ async function createSearchNote(opts = {}) {
return await froca.getNote(note.noteId);
}
async function createLlmChat() {
const note = await server.post<FNoteRow>("special-notes/llm-chat");
await ws.waitForMaxKnownEntityChangeId();
return await froca.getNote(note.noteId);
}
/**
* Gets the most recently modified LLM chat.
* Returns null if no chat exists.
*/
async function getMostRecentLlmChat() {
const note = await server.get<FNoteRow | null>("special-notes/most-recent-llm-chat");
if (!note) {
return null;
}
await ws.waitForMaxKnownEntityChangeId();
return await froca.getNote(note.noteId);
}
/**
* Gets the most recent LLM chat, or creates a new one if none exists.
* Used by sidebar chat for persistent conversations across page refreshes.
*/
async function getOrCreateLlmChat() {
const note = await server.get<FNoteRow>("special-notes/get-or-create-llm-chat");
await ws.waitForMaxKnownEntityChangeId();
return await froca.getNote(note.noteId);
}
export interface RecentLlmChat {
noteId: string;
title: string;
dateModified: string;
}
/**
* Gets a list of recent LLM chats for the history popup.
*/
async function getRecentLlmChats(limit: number = 10): Promise<RecentLlmChat[]> {
return await server.get<RecentLlmChat[]>(`special-notes/recent-llm-chats?limit=${limit}`);
}
export default {
getInboxNote,
getTodayNote,
@@ -94,5 +143,9 @@ export default {
getMonthNote,
getYearNote,
createSqlConsole,
createSearchNote
createSearchNote,
createLlmChat,
getMostRecentLlmChat,
getOrCreateLlmChat,
getRecentLlmChats
};

View File

@@ -13,6 +13,11 @@ export const experimentalFeatures = [
id: "new-layout",
name: t("experimental_features.new_layout_name"),
description: t("experimental_features.new_layout_description"),
},
{
id: "llm",
name: t("experimental_features.llm_name"),
description: t("experimental_features.llm_description"),
}
] as const satisfies ExperimentalFeature[];

View File

@@ -19,7 +19,8 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
search: null,
text: null,
webView: null,
spreadsheet: null
spreadsheet: null,
llmChat: null
};
export const byBookType: Record<ViewTypeOptions, string | null> = {

View File

@@ -0,0 +1,110 @@
import type { LlmChatConfig, LlmCitation, LlmMessage, LlmModelInfo,LlmUsage } from "@triliumnext/commons";
import server from "./server.js";
/**
* Fetch available models from all configured providers.
*/
export async function getAvailableModels(): Promise<LlmModelInfo[]> {
const response = await server.get<{ models?: LlmModelInfo[] }>("llm-chat/models");
return response.models ?? [];
}
export interface StreamCallbacks {
onChunk: (text: string) => void;
onThinking?: (text: string) => void;
onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
onToolResult?: (toolName: string, result: string, isError?: boolean) => void;
onCitation?: (citation: LlmCitation) => void;
onUsage?: (usage: LlmUsage) => void;
onError: (error: string) => void;
onDone: () => void;
}
/**
* Stream a chat completion from the LLM API using Server-Sent Events.
*/
export async function streamChatCompletion(
messages: LlmMessage[],
config: LlmChatConfig,
callbacks: StreamCallbacks
): Promise<void> {
const headers = await server.getHeaders();
const response = await fetch(`${window.glob.baseApiUrl}llm-chat/stream`, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json"
} as HeadersInit,
body: JSON.stringify({ messages, config })
});
if (!response.ok) {
callbacks.onError(`HTTP ${response.status}: ${response.statusText}`);
return;
}
const reader = response.body?.getReader();
if (!reader) {
callbacks.onError("No response body");
return;
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const data = JSON.parse(line.slice(6));
switch (data.type) {
case "text":
callbacks.onChunk(data.content);
break;
case "thinking":
callbacks.onThinking?.(data.content);
break;
case "tool_use":
callbacks.onToolUse?.(data.toolName, data.toolInput);
break;
case "tool_result":
callbacks.onToolResult?.(data.toolName, data.result, data.isError);
break;
case "citation":
if (data.citation) {
callbacks.onCitation?.(data.citation);
}
break;
case "usage":
if (data.usage) {
callbacks.onUsage?.(data.usage);
}
break;
case "error":
callbacks.onError(data.error);
break;
case "done":
callbacks.onDone();
break;
}
} catch (e) {
console.error("Failed to parse SSE data line:", line, e);
}
}
}
}
} finally {
reader.releaseLock();
}
}

View File

@@ -1,6 +1,7 @@
import type { NoteType } from "../entities/fnote.js";
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js";
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
import { isExperimentalFeatureEnabled } from "./experimental_features.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import server from "./server.js";
@@ -41,6 +42,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
{ type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), icon: "bxs-network-chart" },
// Misc note types
{ type: "llmChat", mime: "application/json", title: t("note_types.llm-chat"), icon: "bx-message-square-dots", isBeta: true },
{ type: "render", mime: "", title: t("note_types.render-note"), icon: "bx-extension" },
{ type: "search", title: t("note_types.saved-search"), icon: "bx-file-find", static: true },
{ type: "webView", mime: "", title: t("note_types.web-view"), icon: "bx-globe-alt" },
@@ -92,6 +94,7 @@ async function getNoteTypeItems(command?: TreeCommandNames) {
function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandNames>[] {
return NOTE_TYPES
.filter((nt) => !nt.reserved && nt.type !== "book")
.filter((nt) => nt.type !== "llmChat" || isExperimentalFeatureEnabled("llm"))
.map((nt) => {
const menuItem: MenuCommandItem<TreeCommandNames> = {
title: nt.title,

View File

@@ -928,6 +928,7 @@ export default {
parseDate,
formatDateISO,
formatDateTime,
formatTime,
formatTimeInterval,
formatSize,
localNowDateTime,

View File

@@ -1750,10 +1750,13 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
justify-content: space-between;
align-items: baseline;
font-weight: bold;
text-transform: uppercase;
color: var(--muted-text-color) !important;
}
#right-pane .card-header-title {
text-transform: uppercase;
}
#right-pane .card-header-buttons {
display: flex;
transform: scale(0.9);

View File

@@ -1157,7 +1157,9 @@
"title": "Experimental Options",
"disclaimer": "These options are experimental and may cause instability. Use with caution.",
"new_layout_name": "New Layout",
"new_layout_description": "Try out the new layout for a more modern look and improved usability. Subject to heavy change in the upcoming releases."
"new_layout_description": "Try out the new layout for a more modern look and improved usability. Subject to heavy change in the upcoming releases.",
"llm_name": "AI / LLM Chat",
"llm_description": "Enable the AI chat sidebar and LLM chat notes powered by large language models."
},
"fonts": {
"theme_defined": "Theme defined",
@@ -1599,6 +1601,7 @@
"geo-map": "Geo Map",
"beta-feature": "Beta",
"ai-chat": "AI Chat",
"llm-chat": "AI Chat",
"task-list": "Task List",
"new-feature": "New",
"collections": "Collections",
@@ -1610,6 +1613,49 @@
"toggle-on-hint": "Note is not protected, click to make it protected",
"toggle-off-hint": "Note is protected, click to make it unprotected"
},
"llm_chat": {
"placeholder": "Type a message...",
"send": "Send",
"sending": "Sending...",
"empty_state": "Start a conversation by typing a message below.",
"searching_web": "Searching the web...",
"web_search": "Web search",
"note_tools": "Note access",
"sources": "Sources",
"extended_thinking": "Extended thinking",
"legacy_models": "Legacy models",
"thinking": "Thinking...",
"thought_process": "Thought process",
"tool_calls": "{{count}} tool call(s)",
"input": "Input",
"result": "Result",
"error": "Error",
"tool_error": "failed",
"total_tokens": "{{total}} tokens",
"tokens_detail": "{{prompt}} prompt + {{completion}} completion",
"tokens_used": "{{prompt}} prompt + {{completion}} completion = {{total}} tokens",
"tokens_used_with_cost": "{{prompt}} prompt + {{completion}} completion = {{total}} tokens (~${{cost}})",
"tokens_used_with_model": "{{model}}: {{prompt}} prompt + {{completion}} completion = {{total}} tokens",
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} prompt + {{completion}} completion = {{total}} tokens (~${{cost}})",
"tokens": "tokens",
"context_used": "{{percentage}}% used",
"note_context_enabled": "Click to disable note context: {{title}}",
"note_context_disabled": "Click to include current note in context",
"no_provider_message": "No AI provider configured. Add one to start chatting.",
"add_provider": "Add AI Provider",
"role_user": "You",
"role_assistant": "Assistant"
},
"sidebar_chat": {
"title": "AI Chat",
"launcher_title": "Open AI Chat",
"new_chat": "Start new chat",
"save_chat": "Save chat to notes",
"empty_state": "Start a conversation",
"history": "Chat history",
"recent_chats": "Recent chats",
"no_chats": "No previous chats"
},
"shared_switch": {
"shared": "Shared",
"toggle-on-title": "Share the note",
@@ -2281,5 +2327,35 @@
"language": "Language",
"continue": "Continue",
"your-ip-addresses": "Addresses for this device"
},
"mind-map": {
"addChild": "Add child",
"addParent": "Add parent",
"addSibling": "Add sibling",
"removeNode": "Remove node",
"focus": "Focus Mode",
"cancelFocus": "Cancel Focus Mode",
"moveUp": "Move up",
"moveDown": "Move down",
"link": "Link",
"linkBidirectional": "Bidirectional Link",
"clickTips": "Please click the target node",
"summary": "Summary"
},
"llm": {
"settings_title": "AI / LLM",
"settings_description": "Configure AI and Large Language Model integrations.",
"add_provider": "Add Provider",
"add_provider_title": "Add AI Provider",
"configured_providers": "Configured Providers",
"no_providers_configured": "No providers configured yet.",
"provider_name": "Name",
"provider_type": "Provider",
"actions": "Actions",
"delete_provider": "Delete",
"delete_provider_confirmation": "Are you sure you want to delete the provider \"{{name}}\"?",
"api_key": "API Key",
"api_key_placeholder": "Enter your API key",
"cancel": "Cancel"
}
}

View File

@@ -28,7 +28,10 @@
},
"widget-render-error": {
"title": "Rendu impossible d'un widget React custom"
}
},
"widget-missing-parent": "Le widget personnalisé ne possède pas la propriété obligatoire '{{property}}'.\n\nSi ce script est destiné à être exécuté sans élément dinterface utilisateur, utilisez plutôt '#run=frontendStartup'.",
"open-script-note": "Ouvrir la note du script",
"scripting-error": "Erreur de script personnalisée: {{title}}"
},
"add_link": {
"add_link": "Ajouter un lien",
@@ -443,7 +446,8 @@
"and_more": "... et {{count}} plus.",
"print_landscape": "Lors de l'exportation en PDF, change l'orientation de la page en paysage au lieu de portrait.",
"print_page_size": "Lors de l'exportation en PDF, change la taille de la page. Valeurs supportées : <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Couleur"
"color_type": "Couleur",
"textarea": "Texte multiligne"
},
"attribute_editor": {
"help_text_body1": "Pour ajouter un label, tapez simplement par ex. <code>#rock</code>, ou si vous souhaitez également ajouter une valeur, tapez par ex. <code>#année = 2020</code>",
@@ -659,7 +663,8 @@
"show-cheatsheet": "Afficher l'aide rapide",
"toggle-zen-mode": "Zen Mode",
"new-version-available": "Nouvelle mise à jour disponible",
"download-update": "Obtenir la version {{latestVersion}}"
"download-update": "Obtenir la version {{latestVersion}}",
"search_notes": "Rechercher notes"
},
"zen_mode": {
"button_exit": "Sortir du Zen mode"
@@ -703,7 +708,8 @@
"advanced": "Avancé",
"export_as_image": "Exporter en tant qu'image",
"export_as_image_png": "PNG",
"export_as_image_svg": "SVG (vectoriel)"
"export_as_image_svg": "SVG (vectoriel)",
"note_map": "Note Carte"
},
"onclick_button": {
"no_click_handler": "Le widget bouton '{{componentId}}' n'a pas de gestionnaire de clic défini"
@@ -741,7 +747,7 @@
"button_title": "Exporter le diagramme au format SVG"
},
"relation_map_buttons": {
"create_child_note_title": "Créer une nouvelle note enfant et l'ajouter à cette carte de relation",
"create_child_note_title": "Créer une note enfant et l'ajouter à la carte",
"reset_pan_zoom_title": "Réinitialiser le panoramique et le zoom aux coordonnées et à la position initiales",
"zoom_in_title": "Zoomer",
"zoom_out_title": "Zoom arrière"
@@ -757,7 +763,9 @@
"delete_this_note": "Supprimer cette note",
"error_cannot_get_branch_id": "Impossible d'obtenir branchId pour notePath '{{notePath}}'",
"error_unrecognized_command": "Commande non reconnue {{command}}",
"note_revisions": "Révision de la note"
"note_revisions": "Révision de la note",
"backlinks": "Rétro-liens",
"content_language_switcher": "Langue du contenu: {{language}}"
},
"note_icon": {
"change_note_icon": "Changer l'icône de note",
@@ -766,7 +774,12 @@
"filter": "Filtre",
"filter-none": "Toutes les icônes",
"filter-default": "Icônes par défaut",
"icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}"
"icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}",
"no_results": "Aucune icône trouvée.",
"search_placeholder_one": "Rechercher {{number}} icônes dans {{count}} packs",
"search_placeholder_many": "Rechercher {{number}} icônes dans {{count}} packs",
"search_placeholder_other": "Rechercher les icônes {{number}} dans les paquets {{count}}",
"search_placeholder_filtered": "Rechercher {{number}} icônes dans {{name}}"
},
"basic_properties": {
"note_type": "Type de note",
@@ -793,7 +806,8 @@
"expand_tooltip": "Développe les éléments enfants directs de cette collection (à un niveau). Pour plus d'options, appuyez sur la flèche à droite.",
"expand_first_level": "Développer les enfants directs",
"expand_nth_level": "Développer sur {{depth}} niveaux",
"expand_all_levels": "Développer tous les niveaux"
"expand_all_levels": "Développer tous les niveaux",
"hide_child_notes": "Masquer les notes enfants dans larborescence"
},
"edited_notes": {
"no_edited_notes_found": "Aucune note modifiée ce jour-là...",
@@ -806,7 +820,7 @@
"file_type": "Type de fichier",
"file_size": "Taille du fichier",
"download": "Télécharger",
"open": "Ouvrir",
"open": "Ouvrir dans une nouvelle fenêtre",
"upload_new_revision": "Téléverser une nouvelle version",
"upload_success": "Une nouvelle version de fichier a été téléversée.",
"upload_failed": "Le téléversement d'une nouvelle version de fichier a échoué.",
@@ -826,7 +840,8 @@
},
"inherited_attribute_list": {
"title": "Attributs hérités",
"no_inherited_attributes": "Aucun attribut hérité."
"no_inherited_attributes": "Aucun attribut hérité.",
"none": "aucun"
},
"note_info_widget": {
"note_id": "Identifiant de la note",
@@ -903,7 +918,8 @@
"unknown_search_option": "Option de recherche inconnue {{searchOptionName}}",
"search_note_saved": "La note de recherche a été enregistrée dans {{- notePathTitle}}",
"actions_executed": "Les actions ont été exécutées.",
"view_options": "Afficher les options:"
"view_options": "Afficher les options:",
"option": "option"
},
"similar_notes": {
"title": "Notes similaires",
@@ -997,7 +1013,7 @@
"no_attachments": "Cette note ne contient aucune pièce jointe."
},
"book": {
"no_children_help": "Cette note de type Livre n'a aucune note enfant, donc il n'y a rien à afficher. Consultez le <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> pour plus de détails.",
"no_children_help": "Cette collection ne contient pas de notes enfants, il n'y a donc rien à afficher.",
"drag_locked_title": "Edition verrouillée",
"drag_locked_message": "Le glisser-déposer n'est pas autorisé car l'édition de cette collection est verrouillé."
},
@@ -1367,7 +1383,8 @@
"description": "Description",
"reload_app": "Recharger l'application pour appliquer les modifications",
"set_all_to_default": "Réinitialiser aux valeurs par défaut",
"confirm_reset": "Voulez-vous vraiment réinitialiser tous les raccourcis clavier par défaut ?"
"confirm_reset": "Voulez-vous vraiment réinitialiser tous les raccourcis clavier par défaut ?",
"no_results": "Aucun raccourci correspondant à '{{filter}}'"
},
"spellcheck": {
"title": "Vérification orthographique",
@@ -1402,7 +1419,7 @@
"will_be_deleted_in": "Cette pièce jointe sera automatiquement supprimée dans {{time}}",
"will_be_deleted_soon": "Cette pièce jointe sera bientôt supprimée automatiquement",
"deletion_reason": ", car la pièce jointe n'est pas liée dans le contenu de la note. Pour empêcher la suppression, ajoutez à nouveau le lien de la pièce jointe dans le contenu d'une note ou convertissez la pièce jointe en note.",
"role_and_size": "Rôle : {{role}}, Taille : {{size}}",
"role_and_size": "Rôle : {{role}}, Taille : {{size}}, MIME: {{- mimeType}}",
"link_copied": "Lien de pièce jointe copié dans le presse-papiers.",
"unrecognized_role": "Rôle de pièce jointe « {{role}} » non reconnu."
},
@@ -1453,10 +1470,13 @@
"import-into-note": "Importer dans la note",
"apply-bulk-actions": "Appliquer des Actions groupées",
"converted-to-attachments": "Les notes {{count}} ont été converties en pièces jointes.",
"convert-to-attachment-confirm": "Êtes-vous sûr de vouloir convertir les notes sélectionnées en pièces jointes de leurs notes parentes ?",
"convert-to-attachment-confirm": "Êtes-vous sûr de vouloir convertir les notes sélectionnées en pièces jointes de leurs notes parentales? Cette opération s'applique uniquement aux notes d'image, les autres notes seront ignorées.",
"archive": "Archive",
"unarchive": "Désarchiver",
"open-in-popup": "Modification rapide"
"open-in-popup": "Modification rapide",
"open-in-a-new-window": "Ouvrir dans une nouvelle fenêtre",
"hide-subtree": "Masquer le sous-arbre",
"show-subtree": "Afficher le sous-arbre"
},
"shared_info": {
"shared_publicly": "Cette note est partagée publiquement sur {{- link}}.",
@@ -1485,7 +1505,10 @@
"task-list": "Liste de tâches",
"book": "Collection",
"new-feature": "Nouveau",
"collections": "Collections"
"collections": "Collections",
"ai-chat": "Chat IA",
"llm-chat": "Chat AI",
"spreadsheet": "Feuille de calcul"
},
"protect_note": {
"toggle-on": "Protéger la note",
@@ -1834,7 +1857,7 @@
"book_properties_config": {
"hide-weekends": "Masquer les week-ends",
"display-week-numbers": "Afficher les numéros de semaine",
"map-style": "Style de carte :",
"map-style": "Style de carte",
"max-nesting-depth": "Profondeur d'imbrication maximale :",
"raster": "Trame",
"vector_light": "Vecteur (clair)",
@@ -1973,7 +1996,9 @@
"title": "Options expérimentales",
"disclaimer": "Ces options sont expérimentales et peuvent provoquer une instabilité. Utilisez avec prudence.",
"new_layout_name": "Nouvelle mise en page",
"new_layout_description": "Essayez la nouvelle mise en page pour un look plus moderne et un usage améliorée. Sous réserve de changements importants dans les prochaines versions."
"new_layout_description": "Essayez la nouvelle mise en page pour un look plus moderne et un usage améliorée. Sous réserve de changements importants dans les prochaines versions.",
"llm_name": "AI / LLM Chat",
"llm_description": "Activer la barre de chat AI et les notes de chat LLM alimentées par de grands modèles de langage."
},
"read-only-info": {
"read-only-note": "Vous consultez actuellement une note en lecture seule.",
@@ -1982,5 +2007,57 @@
},
"calendar_view": {
"delete_note": "Effacer la note..."
},
"media": {
"play": "Lire (Espace)",
"pause": "Pause (Espace)",
"back-10s": "Retour arrière 10s (flèche gauche)",
"forward-30s": "Avance 30s",
"mute": "Silence (M)",
"unmute": "Réactiver le son (M)",
"playback-speed": "Vitesse de lecture",
"loop": "Boucle",
"disable-loop": "Désactiver la boucle",
"rotate": "Rotation",
"picture-in-picture": "Image dans l'image",
"exit-picture-in-picture": "Sortir de Image dans l'image",
"fullscreen": "Plein-écran (F)",
"exit-fullscreen": "Sortir du mode plein-écran",
"unsupported-format": "L'aperçu multimédia n'est pas disponible pour ce format de fichier:\n{{mime}}",
"zoom-to-fit": "Zoom pour remplir",
"zoom-reset": "Annuler zoom pour remplir"
},
"render": {
"setup_title": "Afficher du HTML personnalisé ou Preact JSX dans cette note",
"setup_create_sample_preact": "Créer un exemple de note avec Preact",
"setup_create_sample_html": "Créer un exemple de note avec HTML",
"setup_sample_created": "Un exemple de note a été créé en tant que note enfant.",
"disabled_description": "Ces notes de rendu proviennent d'une source externe. Pour vous protéger de contenu malveillant, elle n'est pas activée par défaut. Assurez-vous de faire confiance à la source avant de lactiver.",
"disabled_button_enable": "Activer la note de rendu"
},
"web_view_setup": {
"title": "Créez la vue de la page Web directement dans Trilium",
"url_placeholder": "Entrez ou collez l'adresse du site Web, par exemple https://triliumnotes.org",
"create_button": "Créer une vue Web",
"invalid_url_title": "Adresse invalide",
"invalid_url_message": "Insérer une adresse Web valide, par exemple https://triliumnotes.org.",
"disabled_description": "Cette vue Web a été importée à partir d'une source externe. Pour vous protéger du phishing ou du contenu malveillant, elle ne se charge pas automatiquement. Vous pouvez l'activer si vous faites confiance à la source.",
"disabled_button_enable": "Activer la vue Web"
},
"llm_chat": {
"placeholder": "Tapez un message...",
"send": "Envoyer",
"sending": "Envoi...",
"empty_state": "Démarrez une conversation en tapant un message ci-dessous.",
"searching_web": "Recherche sur le Web...",
"web_search": "Recherche sur le Web",
"note_tools": "Accès aux notes",
"sources": "Sources",
"extended_thinking": "Réflexion étendue",
"legacy_models": "Modèles hérités",
"thinking": "Réflexion...",
"thought_process": "Processus de réflexion",
"tool_calls": "{{count}} appel(s) d'outil",
"input": "Entrée"
}
}

View File

@@ -1718,7 +1718,8 @@
"new-feature": "Nuovo",
"collections": "Collezioni",
"ai-chat": "Chat con IA",
"spreadsheet": "Foglio di calcolo"
"spreadsheet": "Foglio di calcolo",
"llm-chat": "Chat con IA"
},
"protect_note": {
"toggle-on": "Proteggi la nota",
@@ -2051,7 +2052,9 @@
"title": "Opzioni sperimentali",
"disclaimer": "Queste opzioni sono sperimentali e potrebbero causare instabilità. Usare con cautela.",
"new_layout_name": "Nuovo layout",
"new_layout_description": "Prova il nuovo layout per un look più moderno e una maggiore usabilità. Soggetto a modifiche significative nelle prossime versioni."
"new_layout_description": "Prova il nuovo layout per un look più moderno e una maggiore usabilità. Soggetto a modifiche significative nelle prossime versioni.",
"llm_name": "Chat con IA / LLM",
"llm_description": "Attiva la barra laterale della chat con IA e le note della chat LLM basate su modelli linguistici di grandi dimensioni."
},
"server": {
"unknown_http_error_title": "Errore di comunicazione con il server",
@@ -2245,5 +2248,64 @@
"sample_xy": "XY",
"sample_venn": "Venn",
"sample_ishikawa": "Ishikawa"
},
"llm_chat": {
"placeholder": "Scrivi un messaggio...",
"send": "Invia",
"sending": "Invio in corso...",
"empty_state": "Inizia una conversazione scrivendo un messaggio qui sotto.",
"searching_web": "Ricerca sul web...",
"web_search": "Ricerca sul web",
"note_tools": "Nota di accesso",
"sources": "Fonti",
"extended_thinking": "Riflessioni approfondite",
"legacy_models": "Modelli precedenti",
"thinking": "Sto riflettendo...",
"thought_process": "Processo mentale",
"tool_calls": "{{count}} chiamata/e di funzione",
"input": "Dati in ingresso",
"result": "Risultato",
"error": "Errore",
"tool_error": "fallito",
"total_tokens": "{{total}} gettoni",
"tokens_detail": "{{prompt}} prompt + {{completion}} completamento",
"tokens_used": "{{prompt}} prompt + {{completion}} completamento = {{total}} token",
"tokens_used_with_cost": "{{prompt}} prompt + {{completion}} completamento = {{total}} token (~${{cost}})",
"tokens_used_with_model": "{{model}}: {{prompt}} prompt + {{completion}} completamento = {{total}} token",
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} prompt + {{completion}} completamento = {{total}} token (~${{cost}})",
"tokens": "tokens",
"context_used": "{{percentage}}% utilizzato",
"note_context_enabled": "Clicca qui per disattivare il contesto della nota: {{title}}",
"note_context_disabled": "Clicca per includere la nota corrente nel contesto",
"no_provider_message": "Non è stato configurato alcun fornitore di IA. Aggiungine uno per iniziare a chattare.",
"add_provider": "Aggiungi un fornitore di IA",
"role_user": "Tu",
"role_assistant": "Assistente"
},
"sidebar_chat": {
"title": "Chat AI",
"launcher_title": "Apri Chat AI",
"new_chat": "Inizia una nuova chat",
"save_chat": "Salva la chat negli appunti",
"empty_state": "Avvia una conversazione",
"history": "Cronologia delle chat",
"recent_chats": "Conversazioni recenti",
"no_chats": "Nessuna conversazione precedente"
},
"llm": {
"settings_title": "AI / LLM",
"settings_description": "Configurare le integrazioni con l'intelligenza artificiale e i modelli linguistici di grandi dimensioni.",
"add_provider": "Aggiungi fornitore",
"add_provider_title": "Aggiungi un fornitore di IA",
"configured_providers": "Fornitori configurati",
"no_providers_configured": "Non sono stati ancora configurati fornitori.",
"provider_name": "Nome",
"provider_type": "Fornitore",
"actions": "Azioni",
"delete_provider": "Elimina",
"delete_provider_confirmation": "Sei sicuro di voler eliminare il provider \"{{name}}\"?",
"api_key": "Chiave API",
"api_key_placeholder": "Inserisci la tua chiave API",
"cancel": "Annulla"
}
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useLayoutEffect, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import froca from "../../services/froca";
import { isDesktop, isMobile } from "../../services/utils";
import TabSwitcher from "../mobile_widgets/TabSwitcher";
@@ -12,6 +13,7 @@ import HistoryNavigationButton from "./HistoryNavigation";
import { LaunchBarContext } from "./launch_bar_widgets";
import { CommandButton, CustomWidget, NoteLauncher, QuickSearchLauncherWidget, ScriptLauncher, TodayLauncher } from "./LauncherDefinitions";
import ProtectedSessionStatusWidget from "./ProtectedSessionStatusWidget";
import SidebarChatButton from "./SidebarChatButton";
import SpacerWidget from "./SpacerWidget";
import SyncStatus from "./SyncStatus";
@@ -98,6 +100,8 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
return <QuickSearchLauncherWidget />;
case "mobileTabSwitcher":
return <TabSwitcher />;
case "sidebarChat":
return isExperimentalFeatureEnabled("llm") ? <SidebarChatButton /> : undefined;
default:
console.warn(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
}

View File

@@ -0,0 +1,24 @@
import { useCallback } from "preact/hooks";
import appContext from "../../components/app_context";
import { t } from "../../services/i18n";
import { LaunchBarActionButton } from "./launch_bar_widgets";
/**
* Launcher button to open the sidebar (which contains the chat).
* The chat widget is always visible in the sidebar for non-chat notes.
*/
export default function SidebarChatButton() {
const handleClick = useCallback(() => {
// Open right pane if hidden, or toggle it if visible
appContext.triggerEvent("toggleRightPane", {});
}, []);
return (
<LaunchBarActionButton
icon="bx bx-message-square-dots"
text={t("sidebar_chat.launcher_title")}
onClick={handleClick}
/>
);
}

View File

@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import attributes from "../../services/attributes";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import froca from "../../services/froca";
import { t } from "../../services/i18n";
import { NOTE_TYPES, NoteTypeMapping } from "../../services/note_types";
@@ -28,6 +29,7 @@ export default function NoteTypeSwitcher() {
const restNoteTypes: NoteTypeMapping[] = [];
for (const noteType of NOTE_TYPES) {
if (noteType.reserved || noteType.static || noteType.type === "book") continue;
if (noteType.type === "llmChat" && !isExperimentalFeatureEnabled("llm")) continue;
if (SWITCHER_PINNED_NOTE_TYPES.has(noteType.type)) {
pinnedNoteTypes.push(noteType);
} else {

View File

@@ -12,7 +12,7 @@ import { TypeWidgetProps } from "./type_widgets/type_widget";
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
* for protected session or attachment information.
*/
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole";
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined);
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
@@ -147,5 +147,11 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
className: "note-detail-spreadsheet",
printable: true,
isFullHeight: true
},
llmChat: {
view: () => import("./type_widgets/llm_chat/LlmChat"),
className: "note-detail-llm-chat",
printable: true,
isFullHeight: true
}
};

View File

@@ -5,16 +5,27 @@ interface FormDropdownList<T> extends Omit<DropdownProps, "children"> {
values: T[];
keyProperty: keyof T;
titleProperty: keyof T;
/** Property to show as a small suffix next to the title */
titleSuffixProperty?: keyof T;
descriptionProperty?: keyof T;
currentValue: string;
onChange(newValue: string): void;
}
export default function FormDropdownList<T>({ values, keyProperty, titleProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList<T>) {
export default function FormDropdownList<T>({ values, keyProperty, titleProperty, titleSuffixProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList<T>) {
const currentValueData = values.find(value => value[keyProperty] === currentValue);
const renderTitle = (item: T) => {
const title = item[titleProperty] as string;
const suffix = titleSuffixProperty ? item[titleSuffixProperty] as string : null;
if (suffix) {
return <>{title} <small>{suffix}</small></>;
}
return title;
};
return (
<Dropdown text={currentValueData?.[titleProperty] ?? ""} {...restProps}>
<Dropdown text={currentValueData ? renderTitle(currentValueData) : ""} {...restProps}>
{values.map(item => (
<FormListItem
onClick={() => onChange(item[keyProperty] as string)}
@@ -22,9 +33,9 @@ export default function FormDropdownList<T>({ values, keyProperty, titleProperty
description={descriptionProperty && item[descriptionProperty] as string}
selected={currentValue === item[keyProperty]}
>
{item[titleProperty] as string}
{renderTitle(item)}
</FormListItem>
))}
</Dropdown>
)
}
}

View File

@@ -1,3 +1,4 @@
import DOMPurify from "dompurify";
import type { CSSProperties, HTMLProps, RefObject } from "preact/compat";
type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
@@ -14,16 +15,16 @@ export default function RawHtml({containerRef, ...props}: RawHtmlProps & { conta
}
export function RawHtmlBlock({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject<HTMLDivElement>}) {
return <div ref={containerRef} {...getProps(props)} />
return <div ref={containerRef} {...getProps(props)} />;
}
function getProps({ className, html, style, onClick }: RawHtmlProps) {
return {
className: className,
className,
dangerouslySetInnerHTML: getHtml(html ?? ""),
style,
onClick
}
};
}
export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
@@ -39,3 +40,19 @@ export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
__html: html as string
};
}
/**
* Renders HTML content sanitized via DOMPurify to prevent XSS.
* Use this instead of {@link RawHtml} when the HTML originates from
* untrusted sources (e.g. LLM responses, user-generated markdown).
*/
export function SanitizedHtml({ className, html, style }: { className?: string; html: string; style?: CSSProperties }) {
return (
<div
className={className}
style={style}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
/>
);
}

View File

@@ -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 ]);

View File

@@ -7,6 +7,7 @@ import branches from "../../services/branches";
import dialog from "../../services/dialog";
import { getAvailableLocales, t } from "../../services/i18n";
import mime_types from "../../services/mime_types";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import { NOTE_TYPES } from "../../services/note_types";
import protected_session from "../../services/protected_session";
import server from "../../services/server";
@@ -72,7 +73,7 @@ export function NoteTypeDropdownContent({ currentNoteType, currentNoteMime, note
noCodeNotes?: boolean;
}) {
const mimeTypes = useMimeTypes();
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static), []);
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static && (nt.type !== "llmChat" || isExperimentalFeatureEnabled("llm"))), []);
const changeNoteType = useCallback(async (type: NoteType, mime?: string) => {
if (!note || (type === currentNoteType && mime === currentNoteMime)) {
return;

View File

@@ -85,7 +85,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
);
const isElectron = getIsElectron();
const isMac = getIsMac();
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet"].includes(noteType);
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet", "llmChat"].includes(noteType);
const isSearchOrBook = ["search", "book"].includes(noteType);
const isHelpPage = note.noteId.startsWith("_help");
const [syncServerHost] = useTriliumOption("syncServerHost");

View File

@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import appContext from "../../components/app_context";
import { WidgetsByParent } from "../../services/bundle";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import { t } from "../../services/i18n";
import options from "../../services/options";
import { DEFAULT_GUTTER_SIZE } from "../../services/resizer";
@@ -19,6 +20,7 @@ import PdfAttachments from "./pdf/PdfAttachments";
import PdfLayers from "./pdf/PdfLayers";
import PdfPages from "./pdf/PdfPages";
import RightPanelWidget from "./RightPanelWidget";
import SidebarChat from "./SidebarChat";
import TableOfContents from "./TableOfContents";
const MIN_WIDTH_PERCENT = 5;
@@ -91,6 +93,11 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent) {
el: <HighlightsList />,
enabled: noteType === "text" && highlightsList.length > 0,
},
{
el: <SidebarChat />,
enabled: noteType !== "llmChat" && isExperimentalFeatureEnabled("llm"),
position: 1000
},
...widgetsByParent.getLegacyWidgets("right-pane").map((widget) => ({
el: <CustomLegacyWidget key={widget._noteId} originalWidget={widget as LegacyRightPanelWidget} />,
enabled: true,

View File

@@ -51,7 +51,7 @@ export default function RightPanelWidget({ id, title, buttons, children, contain
>
<ActionButton icon="bx bx-chevron-down" text="" />
<div class="card-header-title">{title}</div>
<div class="card-header-buttons">
<div class="card-header-buttons" onClick={e => e.stopPropagation()}>
{buttons}
{contextMenuItems && (
<ActionButton

View File

@@ -0,0 +1,113 @@
/* Sidebar Chat Widget Styles */
.sidebar-chat-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0; /* Allow shrinking in flex context */
overflow: hidden; /* Contain children within available space */
}
.sidebar-chat-container .llm-chat-input-form {
flex-shrink: 0; /* Keep input bar from shrinking */
.llm-chat-input {
font-size: 0.9em;
padding: 0.5em;
}
}
.sidebar-chat-messages {
flex: 1;
min-height: 0; /* Allow flex shrinking for scroll containment */
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Reuse llm-chat-message styles but make them more compact */
.sidebar-chat-messages .llm-chat-message-wrapper {
margin-top: 0;
max-width: 100%;
}
.sidebar-chat-messages .llm-chat-message {
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
}
.sidebar-chat-messages .llm-chat-message-role {
font-size: 0.75rem;
}
.sidebar-chat-messages .llm-chat-tool-activity {
font-size: 0.85rem;
padding: 0.375rem 0.75rem;
margin-bottom: 0;
max-width: 100%;
}
/* Make the sidebar chat widget grow to fill available space when expanded */
#right-pane .widget.grow:not(.collapsed) {
flex: 1;
flex-shrink: 1; /* Override flex-shrink: 0 from main styles */
min-height: 0;
display: flex;
flex-direction: column;
}
#right-pane .widget.grow:not(.collapsed) .body-wrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden; /* Override overflow: auto from main styles */
}
#right-pane .widget.grow:not(.collapsed) .card-body {
flex: 1;
min-height: 0;
overflow: hidden; /* Override overflow: auto - let child handle scrolling */
display: flex;
flex-direction: column;
}
/* Compact markdown in sidebar */
.sidebar-chat-messages .llm-chat-markdown {
font-size: 0.9rem;
line-height: 1.5;
}
.sidebar-chat-messages .llm-chat-markdown p {
margin: 0 0 0.5em 0;
}
.sidebar-chat-messages .llm-chat-markdown pre {
padding: 0.5rem;
font-size: 0.8rem;
}
.sidebar-chat-messages .llm-chat-markdown code {
font-size: 0.85em;
}
.sidebar-chat-history-item-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.sidebar-chat-history-item-content span,
.sidebar-chat-history-item-content strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-chat-history-date {
font-size: 0.75rem;
color: var(--muted-text-color);
margin-top: 0.125rem;
}

View File

@@ -0,0 +1,319 @@
import "./SidebarChat.css";
import type { Dropdown as BootstrapDropdown } from "bootstrap";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import dateNoteService, { type RecentLlmChat } from "../../services/date_notes.js";
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
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, 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";
import type { LlmChatContent } from "../type_widgets/llm_chat/llm_chat_types.js";
import { useLlmChat } from "../type_widgets/llm_chat/useLlmChat.js";
import RightPanelWidget from "./RightPanelWidget.js";
/**
* Sidebar chat widget that appears in the right panel.
* Uses a hidden LLM chat note for persistence across all notes.
* The same chat persists when switching between notes.
*/
export default function SidebarChat() {
const [chatNoteId, setChatNoteId] = useState<string | null>(null);
const [recentChats, setRecentChats] = useState<RecentLlmChat[]>([]);
const spacedUpdateRef = useRef<{ scheduleUpdate: () => void }>(null);
const historyDropdownRef = useRef<BootstrapDropdown | null>(null);
// Get the current active note context
const { noteId: activeNoteId, note: activeNote } = useActiveNoteContext();
// Reactively watch the chat note's title (updates via WebSocket sync after auto-rename)
const chatNote = useNote(chatNoteId);
const chatTitle = useNoteProperty(chatNote, "title") || t("sidebar_chat.title");
// Use shared chat hook with sidebar-specific options
const chat = useLlmChat(
// onMessagesChange - trigger save
() => 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);
}, [activeNoteId, chat.setContextNoteId]);
// Sync chatNoteId into the hook for auto-title generation
useEffect(() => {
chat.setChatNoteId(chatNoteId ?? undefined);
}, [chatNoteId, chat.setChatNoteId]);
// Load the most recent chat on mount (runs once)
useEffect(() => {
let cancelled = false;
const loadMostRecentChat = async () => {
try {
const existingChat = await dateNoteService.getMostRecentLlmChat();
if (cancelled) return;
if (existingChat) {
setChatNoteId(existingChat.noteId);
} else {
// No existing chat - will create on first message
setChatNoteId(null);
chatRef.current.clearMessages();
}
} catch (err) {
console.error("Failed to load sidebar chat:", err);
}
};
loadMostRecentChat();
return () => {
cancelled = true;
};
}, []);
// Custom submit handler that ensures chat note exists first
const handleSubmit = useCallback(async (e: Event) => {
e.preventDefault();
if (!chat.input.trim() || chat.isStreaming) return;
// Ensure chat note exists before sending (lazy creation)
let noteId = chatNoteId;
if (!noteId) {
try {
const note = await dateNoteService.getOrCreateLlmChat();
if (note) {
setChatNoteId(note.noteId);
noteId = note.noteId;
}
} catch (err) {
console.error("Failed to create sidebar chat:", err);
return;
}
}
if (!noteId) {
console.error("Cannot send message: no chat note available");
return;
}
// Ensure the hook has the chatNoteId before submitting (state update from
// setChatNoteId above won't be visible until next render)
chat.setChatNoteId(noteId);
// Delegate to shared handler
await chat.handleSubmit(e);
}, [chatNoteId, chat]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}, [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);
chatRef.current.clearMessages();
}
} catch (err) {
console.error("Failed to create new chat:", err);
}
}, [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);
chatRef.current.clearMessages();
}
} catch (err) {
console.error("Failed to save chat to permanent location:", err);
}
}, [chatNoteId, spacedUpdate]);
const loadRecentChats = useCallback(async () => {
try {
const chats = await dateNoteService.getRecentLlmChats(10);
setRecentChats(chats);
} catch (err) {
console.error("Failed to load recent chats:", err);
}
}, []);
const handleSelectChat = useCallback(async (noteId: string) => {
historyDropdownRef.current?.hide();
if (noteId === chatNoteId) return;
// Save any pending changes before switching
await spacedUpdate.updateNowIfNecessary();
setChatNoteId(noteId);
}, [chatNoteId, spacedUpdate]);
return (
<RightPanelWidget
id="sidebar-chat"
title={chatTitle}
grow
buttons={
<>
<ActionButton
icon="bx bx-plus"
text={t("sidebar_chat.new_chat")}
onClick={handleNewChat}
/>
<Dropdown
text=""
buttonClassName="bx bx-history"
title={t("sidebar_chat.history")}
iconAction
hideToggleArrow
dropdownContainerClassName="tn-dropdown-menu-scrollable"
dropdownOptions={{ popperConfig: { strategy: "fixed" } }}
dropdownRef={historyDropdownRef}
onShown={loadRecentChats}
>
{recentChats.length === 0 ? (
<FormListItem disabled>
{t("sidebar_chat.no_chats")}
</FormListItem>
) : (
recentChats.map(chatItem => (
<FormListItem
key={chatItem.noteId}
icon="bx bx-message-square-dots"
className={chatItem.noteId === chatNoteId ? "active" : ""}
onClick={() => handleSelectChat(chatItem.noteId)}
>
<div className="sidebar-chat-history-item-content">
{chatItem.noteId === chatNoteId
? <strong>{chatItem.title}</strong>
: <span>{chatItem.title}</span>}
<span className="sidebar-chat-history-date">
{formatDateTime(new Date(chatItem.dateModified), "short", "short")}
</span>
</div>
</FormListItem>
))
)}
</Dropdown>
<ActionButton
icon="bx bx-save"
text={t("sidebar_chat.save_chat")}
onClick={handleSaveChat}
disabled={chat.messages.length === 0}
/>
</>
}
>
<div className="sidebar-chat-container">
<div className="sidebar-chat-messages">
{chat.messages.length === 0 && !chat.isStreaming && (
<NoItems
icon="bx bx-conversation"
text={t("sidebar_chat.empty_state")}
/>
)}
{chat.messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{chat.toolActivity && !chat.streamingThinking && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{chat.toolActivity}
</div>
)}
{chat.isStreaming && chat.streamingThinking && (
<ChatMessage
message={{
id: "streaming-thinking",
role: "assistant",
content: chat.streamingThinking,
createdAt: new Date().toISOString(),
type: "thinking"
}}
isStreaming
/>
)}
{chat.isStreaming && chat.streamingContent && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: chat.streamingContent,
createdAt: new Date().toISOString(),
citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined
}}
isStreaming
/>
)}
<div ref={chat.messagesEndRef} />
</div>
<ChatInputBar
chat={chat}
rows={2}
activeNoteId={activeNoteId ?? undefined}
activeNoteTitle={activeNote?.title}
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
/>
</div>
</RightPanelWidget>
);
}

View File

@@ -14,11 +14,12 @@ import SyncOptions from "./options/sync";
import OtherSettings from "./options/other";
import InternationalizationOptions from "./options/i18n";
import AdvancedSettings from "./options/advanced";
import LlmSettings from "./options/llm";
import "./ContentWidget.css";
import { t } from "../../services/i18n";
import BackendLog from "./code/BackendLog";
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced" | "_optionsLlm";
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetProps) => JSX.Element> = {
_optionsAppearance: AppearanceSettings,
@@ -35,6 +36,7 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetPro
_optionsOther: OtherSettings,
_optionsLocalization: InternationalizationOptions,
_optionsAdvanced: AdvancedSettings,
_optionsLlm: LlmSettings,
_backendLog: BackendLog
}

View File

@@ -4,9 +4,10 @@ import "./MindMap.css";
// allow node-menu plugin css to be bundled by webpack
import nodeMenu from "@mind-elixir/node-menu";
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
import { snapdom } from "@zumer/snapdom";
import { DARK_THEME, default as VanillaMindElixir, MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME } from "mind-elixir";
import { t } from "i18next";
import { DARK_THEME, default as VanillaMindElixir, MindElixirData, MindElixirInstance, Operation, THEME as LIGHT_THEME } from "mind-elixir";
import type { LangPack } from "mind-elixir/i18n";
import { HTMLAttributes, RefObject } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
@@ -25,27 +26,22 @@ interface MindElixirProps {
onChange?: () => void;
}
const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, Options["locale"] | null> = {
ar: null,
cn: "zh_CN",
de: null,
en: "en",
en_rtl: "en",
"en-GB": "en",
es: "es",
fr: "fr",
ga: null,
it: "it",
hi: null,
ja: "ja",
pt: "pt",
pl: null,
pt_br: "pt",
ro: "ro",
ru: "ru",
tw: "zh_TW",
uk: null
};
function buildMindElixirLangPack(): LangPack {
return {
addChild: t("mind-map.addChild"),
addParent: t("mind-map.addParent"),
addSibling: t("mind-map.addSibling"),
removeNode: t("mind-map.removeNode"),
focus: t("mind-map.focus"),
cancelFocus: t("mind-map.cancelFocus"),
moveUp: t("mind-map.moveUp"),
moveDown: t("mind-map.moveDown"),
link: t("mind-map.link"),
linkBidirectional: t("mind-map.linkBidirectional"),
clickTips: t("mind-map.clickTips"),
summary: t("mind-map.summary")
};
}
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
const apiRef = useRef<MindElixirInstance>(null);
@@ -161,8 +157,8 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
const mind = new VanillaMindElixir({
el: containerRef.current,
locale: LOCALE_MAPPINGS[locale as DISPLAYABLE_LOCALE_IDS] ?? undefined,
editable,
contextMenu: { locale: buildMindElixirLangPack() },
theme: defaultColorScheme.current === "dark" ? DARK_THEME : LIGHT_THEME
});

View File

@@ -0,0 +1,238 @@
import type { RefObject } from "preact";
import { useState, useCallback } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import ActionButton from "../../react/ActionButton.js";
import Button from "../../react/Button.js";
import Dropdown from "../../react/Dropdown.js";
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../../react/FormList.js";
import type { UseLlmChatReturn } from "./useLlmChat.js";
import AddProviderModal, { type LlmProviderConfig } from "../options/llm/AddProviderModal.js";
import options from "../../../services/options.js";
/** Format token count with thousands separators */
function formatTokenCount(tokens: number): string {
return tokens.toLocaleString();
}
interface ChatInputBarProps {
/** The chat hook result */
chat: UseLlmChatReturn;
/** Number of rows for the textarea (default: 3) */
rows?: number;
/** Current active note ID (for note context toggle) */
activeNoteId?: string;
/** Current active note title (for note context toggle) */
activeNoteTitle?: string;
/** Custom submit handler (overrides chat.handleSubmit) */
onSubmit?: (e: Event) => void;
/** Custom key down handler (overrides chat.handleKeyDown) */
onKeyDown?: (e: KeyboardEvent) => void;
/** Callback when web search toggle changes */
onWebSearchChange?: () => void;
/** Callback when note tools toggle changes */
onNoteToolsChange?: () => void;
/** Callback when extended thinking toggle changes */
onExtendedThinkingChange?: () => void;
/** Callback when model changes */
onModelChange?: (model: string) => void;
}
export default function ChatInputBar({
chat,
rows = 3,
activeNoteId,
activeNoteTitle,
onSubmit,
onKeyDown,
onWebSearchChange,
onNoteToolsChange,
onExtendedThinkingChange,
onModelChange
}: ChatInputBarProps) {
const [showAddProviderModal, setShowAddProviderModal] = useState(false);
const handleSubmit = onSubmit ?? chat.handleSubmit;
const handleKeyDown = onKeyDown ?? chat.handleKeyDown;
const handleWebSearchToggle = (newValue: boolean) => {
chat.setEnableWebSearch(newValue);
onWebSearchChange?.();
};
const handleNoteToolsToggle = (newValue: boolean) => {
chat.setEnableNoteTools(newValue);
onNoteToolsChange?.();
};
const handleExtendedThinkingToggle = (newValue: boolean) => {
chat.setEnableExtendedThinking(newValue);
onExtendedThinkingChange?.();
};
const handleModelSelect = (model: string) => {
chat.setSelectedModel(model);
onModelChange?.(model);
};
const handleNoteContextToggle = () => {
if (chat.contextNoteId) {
chat.setContextNoteId(undefined);
} else if (activeNoteId) {
chat.setContextNoteId(activeNoteId);
}
};
const handleAddProvider = useCallback(async (provider: LlmProviderConfig) => {
// Get current providers and add the new one
const currentProviders = options.getJson("llmProviders") || [];
const newProviders = [...currentProviders, provider];
await options.save("llmProviders", JSON.stringify(newProviders));
// Refresh models to pick up the new provider
chat.refreshModels();
}, [chat]);
const isNoteContextEnabled = !!chat.contextNoteId && !!activeNoteId;
const currentModel = chat.availableModels.find(m => m.id === chat.selectedModel);
const currentModels = chat.availableModels.filter(m => !m.isLegacy);
const legacyModels = chat.availableModels.filter(m => m.isLegacy);
const contextWindow = currentModel?.contextWindow || 200000;
const percentage = Math.min((chat.lastPromptTokens / contextWindow) * 100, 100);
const isWarning = percentage > 75;
const isCritical = percentage > 90;
const pieColor = isCritical ? "var(--danger-color, #d9534f)" : isWarning ? "var(--warning-color, #f0ad4e)" : "var(--main-selection-color, #007bff)";
// Show setup prompt if no provider is configured
if (!chat.isCheckingProvider && !chat.hasProvider) {
return (
<div className="llm-chat-no-provider">
<div className="llm-chat-no-provider-content">
<span className="bx bx-bot llm-chat-no-provider-icon" />
<p>{t("llm_chat.no_provider_message")}</p>
<Button
text={t("llm_chat.add_provider")}
icon="bx bx-plus"
onClick={() => setShowAddProviderModal(true)}
/>
</div>
<AddProviderModal
show={showAddProviderModal}
onHidden={() => setShowAddProviderModal(false)}
onSave={handleAddProvider}
/>
</div>
);
}
return (
<form className="llm-chat-input-form" onSubmit={handleSubmit}>
<textarea
ref={chat.textareaRef as RefObject<HTMLTextAreaElement>}
className="llm-chat-input"
value={chat.input}
onInput={(e) => chat.setInput((e.target as HTMLTextAreaElement).value)}
placeholder={t("llm_chat.placeholder")}
disabled={chat.isStreaming}
onKeyDown={handleKeyDown}
rows={rows}
/>
<div className="llm-chat-options">
<div className="llm-chat-model-selector">
<span className="bx bx-chip" />
<Dropdown
text={<>{currentModel?.name}</>}
disabled={chat.isStreaming}
buttonClassName="llm-chat-model-select"
>
{currentModels.map(model => (
<FormListItem
key={model.id}
onClick={() => handleModelSelect(model.id)}
checked={chat.selectedModel === model.id}
>
{model.name} <small>({model.costDescription})</small>
</FormListItem>
))}
{legacyModels.length > 0 && (
<>
<FormDropdownDivider />
<FormDropdownSubmenu
icon="bx bx-history"
title={t("llm_chat.legacy_models")}
>
{legacyModels.map(model => (
<FormListItem
key={model.id}
onClick={() => handleModelSelect(model.id)}
checked={chat.selectedModel === model.id}
>
{model.name} <small>({model.costDescription})</small>
</FormListItem>
))}
</FormDropdownSubmenu>
</>
)}
<FormDropdownDivider />
<FormListToggleableItem
icon="bx bx-globe"
title={t("llm_chat.web_search")}
currentValue={chat.enableWebSearch}
onChange={handleWebSearchToggle}
disabled={chat.isStreaming}
/>
<FormListToggleableItem
icon="bx bx-note"
title={t("llm_chat.note_tools")}
currentValue={chat.enableNoteTools}
onChange={handleNoteToolsToggle}
disabled={chat.isStreaming}
/>
<FormListToggleableItem
icon="bx bx-brain"
title={t("llm_chat.extended_thinking")}
currentValue={chat.enableExtendedThinking}
onChange={handleExtendedThinkingToggle}
disabled={chat.isStreaming}
/>
</Dropdown>
{activeNoteId && activeNoteTitle && (
<Button
text={activeNoteTitle}
icon={isNoteContextEnabled ? "bx-file" : "bx-hide"}
kind="lowProfile"
size="micro"
className={`llm-chat-note-context ${isNoteContextEnabled ? "active" : ""}`}
onClick={handleNoteContextToggle}
disabled={chat.isStreaming}
title={isNoteContextEnabled
? t("llm_chat.note_context_enabled", { title: activeNoteTitle })
: t("llm_chat.note_context_disabled")}
/>
)}
{chat.lastPromptTokens > 0 && (
<div
className="llm-chat-context-indicator"
title={`${formatTokenCount(chat.lastPromptTokens)} / ${formatTokenCount(contextWindow)} ${t("llm_chat.tokens")}`}
>
<div
className="llm-chat-context-pie"
style={{
background: `conic-gradient(${pieColor} ${percentage}%, var(--accented-background-color) ${percentage}%)`
}}
/>
<span className="llm-chat-context-text">{t("llm_chat.context_used", { percentage: percentage.toFixed(0) })}</span>
</div>
)}
</div>
<ActionButton
icon={chat.isStreaming ? "bx bx-loader-alt bx-spin" : "bx bx-send"}
text={chat.isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
onClick={handleSubmit}
disabled={chat.isStreaming || !chat.input.trim()}
className="llm-chat-send-btn"
/>
</div>
</form>
);
}

View File

@@ -0,0 +1,244 @@
import "./LlmChat.css";
import { Marked } from "marked";
import { useMemo } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import utils from "../../../services/utils.js";
import { SanitizedHtml } from "../../react/RawHtml.js";
import { type ContentBlock, getMessageText, type StoredMessage, type ToolCall } from "./llm_chat_types.js";
function shortenNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`;
return n.toString();
}
// Configure marked for safe rendering
const markedInstance = new Marked({
breaks: true, // Convert \n to <br>
gfm: true // GitHub Flavored Markdown
});
/** Parse markdown to HTML. Sanitization is handled by SanitizedHtml. */
function renderMarkdown(markdown: string): string {
return markedInstance.parse(markdown) as string;
}
interface Props {
message: StoredMessage;
isStreaming?: boolean;
}
function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
const classes = [
"llm-chat-tool-call-inline",
toolCall.isError && "llm-chat-tool-call-error"
].filter(Boolean).join(" ");
return (
<details className={classes}>
<summary className="llm-chat-tool-call-inline-summary">
<span className={toolCall.isError ? "bx bx-error-circle" : "bx bx-wrench"} />
{toolCall.toolName}
{toolCall.isError && <span className="llm-chat-tool-call-error-badge">{t("llm_chat.tool_error")}</span>}
</summary>
<div className="llm-chat-tool-call-inline-body">
<div className="llm-chat-tool-call-input">
<strong>{t("llm_chat.input")}:</strong>
<pre>{JSON.stringify(toolCall.input, null, 2)}</pre>
</div>
{toolCall.result && (
<div className={`llm-chat-tool-call-result ${toolCall.isError ? "llm-chat-tool-call-result-error" : ""}`}>
<strong>{toolCall.isError ? t("llm_chat.error") : t("llm_chat.result")}:</strong>
<pre>{(() => {
if (typeof toolCall.result === "string" && (toolCall.result.startsWith("{") || toolCall.result.startsWith("["))) {
try {
return JSON.stringify(JSON.parse(toolCall.result), null, 2);
} catch {
return toolCall.result;
}
}
return toolCall.result;
})()}</pre>
</div>
)}
</div>
</details>
);
}
function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) {
return blocks.map((block, idx) => {
if (block.type === "text") {
const html = renderMarkdown(block.content);
return (
<div key={idx}>
<SanitizedHtml className="llm-chat-markdown" html={html} />
{isStreaming && idx === blocks.length - 1 && <span className="llm-chat-cursor" />}
</div>
);
}
if (block.type === "tool_call") {
return <ToolCallCard key={idx} toolCall={block.toolCall} />;
}
return null;
});
}
export default function ChatMessage({ message, isStreaming }: Props) {
const roleLabel = message.role === "user" ? t("llm_chat.role_user") : t("llm_chat.role_assistant");
const isError = message.type === "error";
const isThinking = message.type === "thinking";
const textContent = typeof message.content === "string" ? message.content : getMessageText(message.content);
// Render markdown for assistant messages with legacy string content
const renderedContent = useMemo(() => {
if (message.role === "assistant" && !isError && !isThinking && typeof message.content === "string") {
return renderMarkdown(message.content);
}
return null;
}, [message.content, message.role, isError, isThinking]);
const messageClasses = [
"llm-chat-message",
`llm-chat-message-${message.role}`,
isError && "llm-chat-message-error",
isThinking && "llm-chat-message-thinking"
].filter(Boolean).join(" ");
// Render thinking messages in a collapsible details element
if (isThinking) {
return (
<details className={messageClasses}>
<summary className="llm-chat-thinking-summary">
<span className="bx bx-brain" />
{t("llm_chat.thought_process")}
</summary>
<div className="llm-chat-message-content llm-chat-thinking-content">
{textContent}
{isStreaming && <span className="llm-chat-cursor" />}
</div>
</details>
);
}
// Legacy tool calls (from old format stored as separate field)
const legacyToolCalls = message.toolCalls;
const hasBlockContent = Array.isArray(message.content);
return (
<div className={`llm-chat-message-wrapper llm-chat-message-wrapper-${message.role}`}>
<div className={messageClasses}>
<div className="llm-chat-message-role">
{isError ? "Error" : roleLabel}
</div>
<div className="llm-chat-message-content">
{message.role === "assistant" && !isError ? (
hasBlockContent ? (
renderContentBlocks(message.content as ContentBlock[], isStreaming)
) : (
<>
<SanitizedHtml className="llm-chat-markdown" html={renderedContent || ""} />
{isStreaming && <span className="llm-chat-cursor" />}
</>
)
) : (
textContent
)}
</div>
{legacyToolCalls && legacyToolCalls.length > 0 && (
<details className="llm-chat-tool-calls">
<summary className="llm-chat-tool-calls-summary">
<span className="bx bx-wrench" />
{t("llm_chat.tool_calls", { count: legacyToolCalls.length })}
</summary>
<div className="llm-chat-tool-calls-list">
{legacyToolCalls.map((tool) => (
<ToolCallCard key={tool.id} toolCall={tool} />
))}
</div>
</details>
)}
{message.citations && message.citations.length > 0 && (
<div className="llm-chat-citations">
<div className="llm-chat-citations-label">
<span className="bx bx-link" />
{t("llm_chat.sources")}
</div>
<ul className="llm-chat-citations-list">
{message.citations.map((citation, idx) => {
// Determine display text: title, URL hostname, or cited text
let displayText = citation.title;
if (!displayText && citation.url) {
try {
displayText = new URL(citation.url).hostname;
} catch {
displayText = citation.url;
}
}
if (!displayText) {
displayText = citation.citedText?.slice(0, 50) || `Source ${idx + 1}`;
}
return (
<li key={idx}>
{citation.url ? (
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
title={citation.citedText || citation.url}
>
{displayText}
</a>
) : (
<span title={citation.citedText}>
{displayText}
</span>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
<div className={`llm-chat-footer llm-chat-footer-${message.role}`}>
<span
className="llm-chat-footer-time"
title={utils.formatDateTime(new Date(message.createdAt))}
>
{utils.formatTime(new Date(message.createdAt))}
</span>
{message.usage && typeof message.usage.promptTokens === "number" && (
<>
{message.usage.model && (
<>
<span className="llm-chat-usage-separator">·</span>
<span className="llm-chat-usage-model">{message.usage.model}</span>
</>
)}
<span className="llm-chat-usage-separator">·</span>
<span
className="llm-chat-usage-tokens"
title={t("llm_chat.tokens_detail", {
prompt: message.usage.promptTokens.toLocaleString(),
completion: message.usage.completionTokens.toLocaleString()
})}
>
<span className="bx bx-chip" />{" "}
{t("llm_chat.total_tokens", { total: shortenNumber(message.usage.totalTokens) })}
</span>
{message.usage.cost != null && (
<>
<span className="llm-chat-usage-separator">·</span>
<span className="llm-chat-usage-cost">~${message.usage.cost.toFixed(4)}</span>
</>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,725 @@
.llm-chat-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
box-sizing: border-box;
}
.llm-chat-messages {
flex: 1;
overflow-y: auto;
padding-bottom: 1rem;
}
.llm-chat-message-wrapper {
position: relative;
margin-top: 1rem;
padding-bottom: 1.25rem;
max-width: 85%;
}
.llm-chat-message-wrapper:first-child {
margin-top: 0;
}
.llm-chat-message-wrapper-user {
margin-left: auto;
}
.llm-chat-message-wrapper-assistant {
margin-right: auto;
}
/* Show footer only on hover */
.llm-chat-message-wrapper:hover .llm-chat-footer {
opacity: 1;
}
.llm-chat-message {
padding: 0.75rem 1rem;
border-radius: 8px;
user-select: text;
}
.llm-chat-message-user {
background: var(--accented-background-color);
}
.llm-chat-message-assistant {
background: var(--main-background-color);
border: 1px solid var(--main-border-color);
}
.llm-chat-message-role {
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 0.8rem;
color: var(--muted-text-color);
}
.llm-chat-message-content {
word-wrap: break-word;
line-height: 1.5;
}
/* Preserve whitespace only for user messages (plain text) */
.llm-chat-message-user .llm-chat-message-content {
white-space: pre-wrap;
}
.llm-chat-cursor {
display: inline-block;
width: 8px;
height: 1.1em;
background: currentColor;
margin-left: 2px;
vertical-align: text-bottom;
animation: llm-chat-blink 1s infinite;
}
@keyframes llm-chat-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* Tool activity indicator */
.llm-chat-tool-activity {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
border-radius: 8px;
background: var(--accented-background-color);
color: var(--muted-text-color);
font-size: 0.9rem;
max-width: 85%;
}
.llm-chat-tool-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--muted-text-color);
border-top-color: transparent;
border-radius: 50%;
animation: llm-chat-spin 0.8s linear infinite;
}
@keyframes llm-chat-spin {
to { transform: rotate(360deg); }
}
/* Citations */
.llm-chat-citations {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-citations-label {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted-text-color);
margin-bottom: 0.25rem;
}
.llm-chat-citations-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.llm-chat-citations-list li {
font-size: 0.8rem;
}
.llm-chat-citations-list a {
color: var(--link-color, #007bff);
text-decoration: none;
padding: 0.125rem 0.5rem;
background: var(--accented-background-color);
border-radius: 4px;
display: inline-block;
}
.llm-chat-citations-list a:hover {
text-decoration: underline;
}
/* Error */
.llm-chat-error {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 8px;
background: var(--danger-background-color, #fee);
border: 1px solid var(--danger-border-color, #fcc);
color: var(--danger-text-color, #c00);
user-select: text;
}
/* Error message (persisted in conversation) */
.llm-chat-message-error {
background: var(--danger-background-color, #fee);
border: 1px solid var(--danger-border-color, #fcc);
color: var(--danger-text-color, #c00);
}
.llm-chat-message-error .llm-chat-message-role {
color: var(--danger-text-color, #c00);
}
/* Thinking message (collapsible) */
.llm-chat-message-thinking {
background: var(--accented-background-color);
border: 1px dashed var(--main-border-color);
cursor: pointer;
}
.llm-chat-thinking-summary {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--muted-text-color);
padding: 0.25rem 0;
list-style: none;
}
.llm-chat-thinking-summary::-webkit-details-marker {
display: none;
}
.llm-chat-thinking-summary::before {
content: "▶";
font-size: 0.7em;
transition: transform 0.2s ease;
}
.llm-chat-message-thinking[open] .llm-chat-thinking-summary::before {
transform: rotate(90deg);
}
.llm-chat-thinking-summary .bx {
font-size: 1rem;
}
.llm-chat-thinking-content {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--main-border-color);
font-size: 0.9rem;
color: var(--muted-text-color);
white-space: pre-wrap;
}
/* Input form */
.llm-chat-input-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-input {
flex: 1;
min-height: 60px;
max-height: 200px;
resize: vertical;
padding: 0.75rem;
border: 1px solid var(--main-border-color);
border-radius: 8px;
font-family: inherit;
font-size: inherit;
background: var(--main-background-color);
color: var(--main-text-color);
}
.llm-chat-input:focus {
outline: none;
border-color: var(--main-selection-color);
box-shadow: 0 0 0 2px var(--main-selection-color-soft, rgba(0, 123, 255, 0.25));
}
.llm-chat-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Options row */
.llm-chat-options {
display: flex;
align-items: center;
gap: 0.75rem;
}
.llm-chat-send-btn {
margin-left: auto;
font-size: 1.25rem;
}
.llm-chat-send-btn.disabled {
opacity: 0.4;
}
/* Model selector */
.llm-chat-model-selector {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.85rem;
color: var(--muted-text-color);
}
.llm-chat-model-selector .bx {
font-size: 1rem;
}
.llm-chat-model-selector .dropdown {
display: flex;
small {
margin-left: 0.5em;
color: var(--muted-text-color);
}
/* Position legacy models submenu to open upward */
.dropdown-submenu .dropdown-menu {
bottom: 0;
top: auto;
}
}
.llm-chat-model-select.select-button {
padding: 0.25rem 0.5rem;
border: 1px solid var(--main-border-color);
border-radius: 4px;
background: var(--main-background-color);
color: var(--main-text-color);
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
min-width: 140px;
text-align: left;
}
.llm-chat-model-select.select-button:focus {
outline: none;
border-color: var(--main-selection-color);
}
.llm-chat-model-select.select-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Note context toggle */
.llm-chat-note-context.tn-low-profile {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.5;
background: none;
border: none;
}
.llm-chat-note-context.tn-low-profile:hover:not(:disabled) {
opacity: 0.8;
background: none;
}
.llm-chat-note-context.tn-low-profile.active {
opacity: 1;
}
/* Markdown styles */
.llm-chat-markdown {
line-height: 1.6;
}
.llm-chat-markdown p {
margin: 0 0 0.75em 0;
}
.llm-chat-markdown p:last-child {
margin-bottom: 0;
}
.llm-chat-markdown h1,
.llm-chat-markdown h2,
.llm-chat-markdown h3,
.llm-chat-markdown h4,
.llm-chat-markdown h5,
.llm-chat-markdown h6 {
margin: 1em 0 0.5em 0;
font-weight: 600;
line-height: 1.3;
}
.llm-chat-markdown h1:first-child,
.llm-chat-markdown h2:first-child,
.llm-chat-markdown h3:first-child {
margin-top: 0;
}
.llm-chat-markdown h1 { font-size: 1.4em; }
.llm-chat-markdown h2 { font-size: 1.25em; }
.llm-chat-markdown h3 { font-size: 1.1em; }
.llm-chat-markdown ul,
.llm-chat-markdown ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.llm-chat-markdown li {
margin: 0.25em 0;
}
.llm-chat-markdown code {
background: var(--accented-background-color);
padding: 0.15em 0.4em;
border-radius: 4px;
font-family: var(--monospace-font-family, monospace);
font-size: 0.9em;
}
.llm-chat-markdown pre {
background: var(--accented-background-color);
padding: 0.75em 1em;
border-radius: 6px;
overflow-x: auto;
margin: 0.75em 0;
}
.llm-chat-markdown pre code {
background: none;
padding: 0;
font-size: 0.85em;
}
.llm-chat-markdown blockquote {
margin: 0.75em 0;
padding: 0.5em 1em;
border-left: 3px solid var(--main-border-color);
background: var(--accented-background-color);
}
.llm-chat-markdown blockquote p {
margin: 0;
}
.llm-chat-markdown a {
color: var(--link-color, #007bff);
text-decoration: none;
}
.llm-chat-markdown a:hover {
text-decoration: underline;
}
.llm-chat-markdown hr {
border: none;
border-top: 1px solid var(--main-border-color);
margin: 1em 0;
}
.llm-chat-markdown table {
border-collapse: collapse;
width: 100%;
margin: 0.75em 0;
}
.llm-chat-markdown th,
.llm-chat-markdown td {
border: 1px solid var(--main-border-color);
padding: 0.5em 0.75em;
text-align: left;
}
.llm-chat-markdown th {
background: var(--accented-background-color);
font-weight: 600;
}
.llm-chat-markdown strong {
font-weight: 600;
}
.llm-chat-markdown em {
font-style: italic;
}
/* Tool calls display */
.llm-chat-tool-calls {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-tool-calls-summary {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--muted-text-color);
padding: 0.25rem 0;
cursor: pointer;
list-style: none;
}
.llm-chat-tool-calls-summary::-webkit-details-marker {
display: none;
}
.llm-chat-tool-calls-summary::before {
content: "▶";
font-size: 0.7em;
transition: transform 0.2s ease;
}
.llm-chat-tool-calls[open] .llm-chat-tool-calls-summary::before {
transform: rotate(90deg);
}
.llm-chat-tool-calls-summary .bx {
font-size: 1rem;
}
.llm-chat-tool-calls-list {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.llm-chat-tool-call {
background: var(--accented-background-color);
border-radius: 6px;
padding: 0.75rem;
font-size: 0.85rem;
}
.llm-chat-tool-call-name {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--main-text-color);
font-family: var(--monospace-font-family, monospace);
}
.llm-chat-tool-call-input,
.llm-chat-tool-call-result {
margin-top: 0.5rem;
}
.llm-chat-tool-call-input strong,
.llm-chat-tool-call-result strong {
display: block;
font-size: 0.75rem;
color: var(--muted-text-color);
margin-bottom: 0.25rem;
}
.llm-chat-tool-call pre {
margin: 0;
padding: 0.5rem;
background: var(--main-background-color);
border-radius: 4px;
overflow-x: auto;
font-size: 0.8rem;
font-family: var(--monospace-font-family, monospace);
max-height: 200px;
overflow-y: auto;
}
/* Inline tool call cards (timeline style) */
.llm-chat-tool-call-inline {
margin: 0.5rem 0;
background: var(--accented-background-color);
border-radius: 6px;
border-left: 3px solid var(--muted-text-color);
font-size: 0.85rem;
}
.llm-chat-tool-call-inline-summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
list-style: none;
font-weight: 500;
color: var(--muted-text-color);
font-family: var(--monospace-font-family, monospace);
}
.llm-chat-tool-call-inline-summary::-webkit-details-marker {
display: none;
}
.llm-chat-tool-call-inline-summary::before {
content: "▶";
font-size: 0.7em;
transition: transform 0.2s ease;
}
.llm-chat-tool-call-inline[open] .llm-chat-tool-call-inline-summary::before {
transform: rotate(90deg);
}
.llm-chat-tool-call-inline-summary .bx {
font-size: 1rem;
}
.llm-chat-tool-call-inline-body {
padding: 0 0.75rem 0.75rem;
}
.llm-chat-tool-call-inline-body pre {
margin: 0;
padding: 0.5rem;
background: var(--main-background-color);
border-radius: 4px;
overflow-x: auto;
font-size: 0.8rem;
font-family: var(--monospace-font-family, monospace);
max-height: 200px;
overflow-y: auto;
}
.llm-chat-tool-call-inline-body strong {
display: block;
font-size: 0.75rem;
color: var(--muted-text-color);
margin-bottom: 0.25rem;
}
.llm-chat-tool-call-inline-body .llm-chat-tool-call-result {
margin-top: 0.5rem;
}
/* Tool call error styling */
.llm-chat-tool-call-error {
border-left-color: var(--danger-color, #dc3545);
}
.llm-chat-tool-call-error .llm-chat-tool-call-inline-summary {
color: var(--danger-color, #dc3545);
}
.llm-chat-tool-call-error-badge {
font-size: 0.75rem;
font-weight: 400;
font-family: var(--main-font-family);
color: var(--danger-color, #dc3545);
opacity: 0.8;
}
.llm-chat-tool-call-result-error pre {
color: var(--danger-color, #dc3545);
}
/* Message footer (timestamp + token usage, sits below the bubble) */
.llm-chat-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.5rem;
font-size: 0.7rem;
color: var(--muted-text-color);
cursor: default;
opacity: 0;
transition: opacity 0.15s ease;
}
.llm-chat-footer-user {
justify-content: flex-end;
}
.llm-chat-footer .bx {
font-size: 0.875rem;
}
.llm-chat-footer-time {
cursor: help;
}
.llm-chat-usage-model {
font-weight: 500;
}
.llm-chat-usage-separator {
opacity: 0.5;
}
.llm-chat-usage-tokens {
cursor: help;
font-family: var(--monospace-font-family, monospace);
}
.llm-chat-usage-cost {
font-family: var(--monospace-font-family, monospace);
}
/* Context window indicator */
.llm-chat-context-indicator {
display: flex;
align-items: center;
gap: 0.375rem;
margin-left: 0.5rem;
cursor: help;
}
.llm-chat-context-pie {
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.llm-chat-context-text {
font-size: 0.75rem;
color: var(--muted-text-color);
}
/* No provider state */
.llm-chat-no-provider {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-no-provider-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
color: var(--muted-text-color);
}
.llm-chat-no-provider-icon {
font-size: 2rem;
opacity: 0.5;
}
.llm-chat-no-provider-content p {
margin: 0;
font-size: 0.9rem;
}

View File

@@ -0,0 +1,109 @@
import "./LlmChat.css";
import { useCallback, useEffect, useRef } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import { useEditorSpacedUpdate } from "../../react/hooks.js";
import NoItems from "../../react/NoItems.js";
import { TypeWidgetProps } from "../type_widget.js";
import ChatInputBar from "./ChatInputBar.js";
import ChatMessage from "./ChatMessage.js";
import type { LlmChatContent } from "./llm_chat_types.js";
import { useLlmChat } from "./useLlmChat.js";
export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
const spacedUpdateRef = useRef<{ scheduleUpdate: () => void }>(null);
const chat = useLlmChat(
// onMessagesChange - trigger save
() => spacedUpdateRef.current?.scheduleUpdate(),
{ defaultEnableNoteTools: false, supportsExtendedThinking: true, chatNoteId: note?.noteId }
);
// Keep chatNoteId in sync when the note changes
useEffect(() => {
chat.setChatNoteId(note?.noteId);
}, [note?.noteId, chat.setChatNoteId]);
const spacedUpdate = useEditorSpacedUpdate({
note,
noteType: "llmChat",
noteContext,
getData: () => {
const content = chat.getContent();
return { content: JSON.stringify(content) };
},
onContentChange: (content) => {
if (!content) {
chat.clearMessages();
return;
}
try {
const parsed: LlmChatContent = JSON.parse(content);
chat.loadFromContent(parsed);
} catch (e) {
console.error("Failed to parse LLM chat content:", e);
chat.clearMessages();
}
}
});
spacedUpdateRef.current = spacedUpdate;
const triggerSave = useCallback(() => {
spacedUpdateRef.current?.scheduleUpdate();
}, []);
return (
<div className="llm-chat-container">
<div className="llm-chat-messages">
{chat.messages.length === 0 && !chat.isStreaming && (
<NoItems
icon="bx bx-conversation"
text={t("llm_chat.empty_state")}
/>
)}
{chat.messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{chat.toolActivity && !chat.streamingThinking && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{chat.toolActivity}
</div>
)}
{chat.isStreaming && chat.streamingThinking && (
<ChatMessage
message={{
id: "streaming-thinking",
role: "assistant",
content: chat.streamingThinking,
createdAt: new Date().toISOString(),
type: "thinking"
}}
isStreaming
/>
)}
{chat.isStreaming && chat.streamingContent && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: chat.streamingContent,
createdAt: new Date().toISOString(),
citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined
}}
isStreaming
/>
)}
<div ref={chat.messagesEndRef} />
</div>
<ChatInputBar
chat={chat}
onWebSearchChange={triggerSave}
onNoteToolsChange={triggerSave}
onExtendedThinkingChange={triggerSave}
onModelChange={triggerSave}
/>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import type { LlmCitation, LlmUsage } from "@triliumnext/commons";
export type MessageType = "message" | "error" | "thinking";
export interface ToolCall {
id: string;
toolName: string;
input: Record<string, unknown>;
result?: string;
isError?: boolean;
}
/** A block of text content (rendered as Markdown for assistant messages). */
export interface TextBlock {
type: "text";
content: string;
}
/** A tool invocation block shown inline in the message timeline. */
export interface ToolCallBlock {
type: "tool_call";
toolCall: ToolCall;
}
/** An ordered content block in an assistant message. */
export type ContentBlock = TextBlock | ToolCallBlock;
/**
* Extract the plain text from message content (works for both legacy string and block formats).
*/
export function getMessageText(content: string | ContentBlock[]): string {
if (typeof content === "string") {
return content;
}
return content
.filter((b): b is TextBlock => b.type === "text")
.map(b => b.content)
.join("");
}
/**
* Extract tool calls from message content blocks.
*/
export function getMessageToolCalls(message: StoredMessage): ToolCall[] {
// Legacy format: tool calls stored in separate field
if (message.toolCalls) {
return message.toolCalls;
}
// Block format: extract from content blocks
if (Array.isArray(message.content)) {
return message.content
.filter((b): b is ToolCallBlock => b.type === "tool_call")
.map(b => b.toolCall);
}
return [];
}
export interface StoredMessage {
id: string;
role: "user" | "assistant" | "system";
/** Message content: plain string (user messages, legacy) or ordered content blocks (assistant). */
content: string | ContentBlock[];
createdAt: string;
citations?: LlmCitation[];
/** Message type for special rendering. Defaults to "message" if omitted. */
type?: MessageType;
/** @deprecated Tool calls are now inline in content blocks. Kept for backward compatibility. */
toolCalls?: ToolCall[];
/** Token usage for this response */
usage?: LlmUsage;
}
export interface LlmChatContent {
version: 1;
messages: StoredMessage[];
selectedModel?: string;
enableWebSearch?: boolean;
enableNoteTools?: boolean;
enableExtendedThinking?: boolean;
}

View File

@@ -0,0 +1,406 @@
import type { LlmCitation, LlmMessage, LlmModelInfo, LlmUsage } from "@triliumnext/commons";
import { RefObject } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import { getAvailableModels, streamChatCompletion } from "../../../services/llm_chat.js";
import { randomString } from "../../../services/utils.js";
import type { ContentBlock, LlmChatContent, StoredMessage } from "./llm_chat_types.js";
export interface ModelOption extends LlmModelInfo {
costDescription?: string;
}
export interface LlmChatOptions {
/** Default value for enableNoteTools */
defaultEnableNoteTools?: boolean;
/** Whether extended thinking is supported */
supportsExtendedThinking?: boolean;
/** Initial context note ID (the note the user is viewing) */
contextNoteId?: string;
/** The chat note ID (used for auto-renaming on first message) */
chatNoteId?: string;
}
export interface UseLlmChatReturn {
// State
messages: StoredMessage[];
input: string;
isStreaming: boolean;
streamingContent: string;
streamingThinking: string;
toolActivity: string | null;
pendingCitations: LlmCitation[];
availableModels: ModelOption[];
selectedModel: string;
enableWebSearch: boolean;
enableNoteTools: boolean;
enableExtendedThinking: boolean;
contextNoteId: string | undefined;
lastPromptTokens: number;
messagesEndRef: RefObject<HTMLDivElement>;
textareaRef: RefObject<HTMLTextAreaElement>;
/** Whether a provider is configured and available */
hasProvider: boolean;
/** Whether we're still checking for providers */
isCheckingProvider: boolean;
// Setters
setInput: (value: string) => void;
setMessages: (messages: StoredMessage[]) => void;
setSelectedModel: (model: string) => void;
setEnableWebSearch: (value: boolean) => void;
setEnableNoteTools: (value: boolean) => void;
setEnableExtendedThinking: (value: boolean) => void;
setContextNoteId: (noteId: string | undefined) => void;
setChatNoteId: (noteId: string | undefined) => void;
// Actions
handleSubmit: (e: Event) => Promise<void>;
handleKeyDown: (e: KeyboardEvent) => void;
loadFromContent: (content: LlmChatContent) => void;
getContent: () => LlmChatContent;
clearMessages: () => void;
/** Refresh the provider/models list */
refreshModels: () => void;
}
export function useLlmChat(
onMessagesChange?: (messages: StoredMessage[]) => void,
options: LlmChatOptions = {}
): UseLlmChatReturn {
const { defaultEnableNoteTools = false, supportsExtendedThinking = false, contextNoteId: initialContextNoteId, chatNoteId: initialChatNoteId } = options;
const [messages, setMessagesInternal] = useState<StoredMessage[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
const [toolActivity, setToolActivity] = useState<string | null>(null);
const [pendingCitations, setPendingCitations] = useState<LlmCitation[]>([]);
const [availableModels, setAvailableModels] = useState<ModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [enableWebSearch, setEnableWebSearch] = useState(true);
const [enableNoteTools, setEnableNoteTools] = useState(defaultEnableNoteTools);
const [enableExtendedThinking, setEnableExtendedThinking] = useState(false);
const [contextNoteId, setContextNoteId] = useState<string | undefined>(initialContextNoteId);
const [chatNoteId, setChatNoteIdState] = useState<string | undefined>(initialChatNoteId);
const [lastPromptTokens, setLastPromptTokens] = useState<number>(0);
const [hasProvider, setHasProvider] = useState<boolean>(true); // Assume true initially
const [isCheckingProvider, setIsCheckingProvider] = useState<boolean>(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Refs to get fresh values in getContent (avoids stale closures)
const messagesRef = useRef(messages);
messagesRef.current = messages;
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
const enableWebSearchRef = useRef(enableWebSearch);
enableWebSearchRef.current = enableWebSearch;
const enableNoteToolsRef = useRef(enableNoteTools);
enableNoteToolsRef.current = enableNoteTools;
const enableExtendedThinkingRef = useRef(enableExtendedThinking);
enableExtendedThinkingRef.current = enableExtendedThinking;
const chatNoteIdRef = useRef(chatNoteId);
chatNoteIdRef.current = chatNoteId;
const setChatNoteId = useCallback((noteId: string | undefined) => {
chatNoteIdRef.current = noteId;
setChatNoteIdState(noteId);
}, []);
const contextNoteIdRef = useRef(contextNoteId);
contextNoteIdRef.current = contextNoteId;
// Wrapper to call onMessagesChange when messages update
const setMessages = useCallback((newMessages: StoredMessage[]) => {
setMessagesInternal(newMessages);
onMessagesChange?.(newMessages);
}, [onMessagesChange]);
// Fetch available models on mount
const refreshModels = useCallback(() => {
setIsCheckingProvider(true);
getAvailableModels().then(models => {
const modelsWithDescription = models.map(m => ({
...m,
costDescription: m.costMultiplier ? `${m.costMultiplier}x` : undefined
}));
setAvailableModels(modelsWithDescription);
setHasProvider(models.length > 0);
setIsCheckingProvider(false);
if (!selectedModel) {
const defaultModel = models.find(m => m.isDefault) || models[0];
if (defaultModel) {
setSelectedModel(defaultModel.id);
}
}
}).catch(err => {
console.error("Failed to fetch available models:", err);
setHasProvider(false);
setIsCheckingProvider(false);
});
}, [selectedModel]);
useEffect(() => {
refreshModels();
}, []);
// Scroll to bottom when content changes
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, streamingThinking, toolActivity, scrollToBottom]);
// Load state from content object
const loadFromContent = useCallback((content: LlmChatContent) => {
setMessagesInternal(content.messages || []);
if (content.selectedModel) {
setSelectedModel(content.selectedModel);
}
if (typeof content.enableWebSearch === "boolean") {
setEnableWebSearch(content.enableWebSearch);
}
if (typeof content.enableNoteTools === "boolean") {
setEnableNoteTools(content.enableNoteTools);
}
if (supportsExtendedThinking && typeof content.enableExtendedThinking === "boolean") {
setEnableExtendedThinking(content.enableExtendedThinking);
}
// Restore last prompt tokens from the most recent message with usage
const lastUsage = [...(content.messages || [])].reverse().find(m => m.usage)?.usage;
setLastPromptTokens(lastUsage?.promptTokens ?? 0);
}, [supportsExtendedThinking]);
// Get current state as content object (uses refs to avoid stale closures)
const getContent = useCallback((): LlmChatContent => {
const content: LlmChatContent = {
version: 1,
messages: messagesRef.current,
selectedModel: selectedModelRef.current || undefined,
enableWebSearch: enableWebSearchRef.current,
enableNoteTools: enableNoteToolsRef.current
};
if (supportsExtendedThinking) {
content.enableExtendedThinking = enableExtendedThinkingRef.current;
}
return content;
}, [supportsExtendedThinking]);
const clearMessages = useCallback(() => {
setMessages([]);
setLastPromptTokens(0);
}, [setMessages]);
const handleSubmit = useCallback(async (e: Event) => {
e.preventDefault();
if (!input.trim() || isStreaming) return;
setToolActivity(null);
setPendingCitations([]);
const userMessage: StoredMessage = {
id: randomString(),
role: "user",
content: input.trim(),
createdAt: new Date().toISOString()
};
const newMessages = [...messages, userMessage];
setMessagesInternal(newMessages);
setInput("");
setIsStreaming(true);
setStreamingContent("");
setStreamingThinking("");
let thinkingContent = "";
const contentBlocks: ContentBlock[] = [];
const citations: LlmCitation[] = [];
let usage: LlmUsage | undefined;
/** Get or create the last text block to append streaming text to. */
function lastTextBlock(): ContentBlock & { type: "text" } {
const last = contentBlocks[contentBlocks.length - 1];
if (last?.type === "text") {
return last;
}
const block: ContentBlock = { type: "text", content: "" };
contentBlocks.push(block);
return block as ContentBlock & { type: "text" };
}
const apiMessages: LlmMessage[] = newMessages.map(m => ({
role: m.role,
content: typeof m.content === "string" ? m.content : m.content
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
.map(b => b.content)
.join("")
}));
const selectedModelProvider = availableModels.find(m => m.id === selectedModel)?.provider;
const streamOptions: Parameters<typeof streamChatCompletion>[1] = {
model: selectedModel || undefined,
provider: selectedModelProvider,
enableWebSearch,
enableNoteTools,
contextNoteId,
chatNoteId: chatNoteIdRef.current
};
if (supportsExtendedThinking) {
streamOptions.enableExtendedThinking = enableExtendedThinking;
}
await streamChatCompletion(
apiMessages,
streamOptions,
{
onChunk: (text) => {
lastTextBlock().content += text;
setStreamingContent(contentBlocks
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
.map(b => b.content)
.join(""));
setToolActivity(null);
},
onThinking: (text) => {
thinkingContent += text;
setStreamingThinking(thinkingContent);
setToolActivity(t("llm_chat.thinking"));
},
onToolUse: (toolName, toolInput) => {
const toolLabel = toolName === "web_search"
? t("llm_chat.searching_web")
: `Using ${toolName}...`;
setToolActivity(toolLabel);
contentBlocks.push({
type: "tool_call",
toolCall: {
id: randomString(),
toolName,
input: toolInput
}
});
},
onToolResult: (toolName, result, isError) => {
// Find the most recent tool_call block for this tool without a result
for (let i = contentBlocks.length - 1; i >= 0; i--) {
const block = contentBlocks[i];
if (block.type === "tool_call" && block.toolCall.toolName === toolName && !block.toolCall.result) {
block.toolCall.result = result;
block.toolCall.isError = isError;
break;
}
}
},
onCitation: (citation) => {
citations.push(citation);
setPendingCitations([...citations]);
},
onUsage: (u) => {
usage = u;
setLastPromptTokens(u.promptTokens);
},
onError: (errorMsg) => {
console.error("Chat error:", errorMsg);
const errorMessage: StoredMessage = {
id: randomString(),
role: "assistant",
content: errorMsg,
createdAt: new Date().toISOString(),
type: "error"
};
const finalMessages = [...newMessages, errorMessage];
setMessages(finalMessages);
setStreamingContent("");
setStreamingThinking("");
setIsStreaming(false);
setToolActivity(null);
},
onDone: () => {
const finalNewMessages: StoredMessage[] = [];
if (thinkingContent) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: thinkingContent,
createdAt: new Date().toISOString(),
type: "thinking"
});
}
if (contentBlocks.length > 0) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: contentBlocks,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined,
usage
});
}
if (finalNewMessages.length > 0) {
const allMessages = [...newMessages, ...finalNewMessages];
setMessages(allMessages);
}
setStreamingContent("");
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
setToolActivity(null);
}
}
);
}, [input, isStreaming, messages, selectedModel, enableWebSearch, enableNoteTools, enableExtendedThinking, contextNoteId, supportsExtendedThinking, setMessages]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}, [handleSubmit]);
return {
// State
messages,
input,
isStreaming,
streamingContent,
streamingThinking,
toolActivity,
pendingCitations,
availableModels,
selectedModel,
enableWebSearch,
enableNoteTools,
enableExtendedThinking,
contextNoteId,
lastPromptTokens,
messagesEndRef,
textareaRef,
hasProvider,
isCheckingProvider,
// Setters
setInput,
setMessages,
setSelectedModel,
setEnableWebSearch,
setEnableNoteTools,
setEnableExtendedThinking,
setContextNoteId,
setChatNoteId,
// Actions
handleSubmit,
handleKeyDown,
loadFromContent,
getContent,
clearMessages,
refreshModels
};
}

View File

@@ -0,0 +1,104 @@
import { useCallback, useMemo, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import Button from "../../react/Button";
import OptionsSection from "./components/OptionsSection";
import AddProviderModal, { type LlmProviderConfig, PROVIDER_TYPES } from "./llm/AddProviderModal";
import ActionButton from "../../react/ActionButton";
import dialog from "../../../services/dialog";
import { useTriliumOption } from "../../react/hooks";
export default function LlmSettings() {
const [providersJson, setProvidersJson] = useTriliumOption("llmProviders");
const providers = useMemo<LlmProviderConfig[]>(() => {
try {
return providersJson ? JSON.parse(providersJson) : [];
} catch {
return [];
}
}, [providersJson]);
const setProviders = useCallback((newProviders: LlmProviderConfig[]) => {
setProvidersJson(JSON.stringify(newProviders));
}, [setProvidersJson]);
const [showAddModal, setShowAddModal] = useState(false);
const handleAddProvider = useCallback((newProvider: LlmProviderConfig) => {
setProviders([...providers, newProvider]);
}, [providers, setProviders]);
const handleDeleteProvider = useCallback(async (providerId: string, providerName: string) => {
if (!(await dialog.confirm(t("llm.delete_provider_confirmation", { name: providerName })))) {
return;
}
setProviders(providers.filter(p => p.id !== providerId));
}, [providers, setProviders]);
return (
<OptionsSection title={t("llm.settings_title")}>
<p>{t("llm.settings_description")}</p>
<Button
size="small"
icon="bx bx-plus"
text={t("llm.add_provider")}
onClick={() => setShowAddModal(true)}
/>
<hr />
<h5>{t("llm.configured_providers")}</h5>
<ProviderList
providers={providers}
onDelete={handleDeleteProvider}
/>
<AddProviderModal
show={showAddModal}
onHidden={() => setShowAddModal(false)}
onSave={handleAddProvider}
/>
</OptionsSection>
);
}
interface ProviderListProps {
providers: LlmProviderConfig[];
onDelete: (providerId: string, providerName: string) => Promise<void>;
}
function ProviderList({ providers, onDelete }: ProviderListProps) {
if (!providers.length) {
return <div>{t("llm.no_providers_configured")}</div>;
}
return (
<div style={{ overflow: "auto" }}>
<table className="table table-stripped">
<thead>
<tr>
<th>{t("llm.provider_name")}</th>
<th>{t("llm.provider_type")}</th>
<th>{t("llm.actions")}</th>
</tr>
</thead>
<tbody>
{providers.map((provider) => {
const providerType = PROVIDER_TYPES.find(p => p.id === provider.provider);
return (
<tr key={provider.id}>
<td>{provider.name}</td>
<td>{providerType?.name || provider.provider}</td>
<td>
<ActionButton
icon="bx bx-trash"
text={t("llm.delete_provider")}
onClick={() => onDelete(provider.id, provider.name)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { createPortal } from "preact/compat";
import { useState, useRef } from "preact/hooks";
import Modal from "../../../react/Modal";
import FormGroup from "../../../react/FormGroup";
import FormSelect from "../../../react/FormSelect";
import FormTextBox from "../../../react/FormTextBox";
import { t } from "../../../../services/i18n";
export interface LlmProviderConfig {
id: string;
name: string;
provider: string;
apiKey: string;
}
export interface ProviderType {
id: string;
name: string;
}
export const PROVIDER_TYPES: ProviderType[] = [
{ id: "anthropic", name: "Anthropic" },
{ id: "openai", name: "OpenAI" },
{ id: "google", name: "Google Gemini" }
];
interface AddProviderModalProps {
show: boolean;
onHidden: () => void;
onSave: (provider: LlmProviderConfig) => void;
}
export default function AddProviderModal({ show, onHidden, onSave }: AddProviderModalProps) {
const [selectedProvider, setSelectedProvider] = useState(PROVIDER_TYPES[0].id);
const [apiKey, setApiKey] = useState("");
const formRef = useRef<HTMLFormElement>(null);
function handleSubmit() {
if (!apiKey.trim()) {
return;
}
const providerType = PROVIDER_TYPES.find(p => p.id === selectedProvider);
const newProvider: LlmProviderConfig = {
id: `${selectedProvider}_${Date.now()}`,
name: providerType?.name || selectedProvider,
provider: selectedProvider,
apiKey: apiKey.trim()
};
onSave(newProvider);
resetForm();
onHidden();
}
function resetForm() {
setSelectedProvider(PROVIDER_TYPES[0].id);
setApiKey("");
}
function handleCancel() {
resetForm();
onHidden();
}
return createPortal(
<Modal
show={show}
onHidden={handleCancel}
onSubmit={handleSubmit}
formRef={formRef}
title={t("llm.add_provider_title")}
className="add-provider-modal"
size="md"
footer={
<>
<button type="button" className="btn btn-secondary" onClick={handleCancel}>
{t("llm.cancel")}
</button>
<button type="submit" className="btn btn-primary" disabled={!apiKey.trim()}>
{t("llm.add_provider")}
</button>
</>
}
>
<FormGroup name="provider-type" label={t("llm.provider_type")}>
<FormSelect
values={PROVIDER_TYPES}
keyProperty="id"
titleProperty="name"
currentValue={selectedProvider}
onChange={setSelectedProvider}
/>
</FormGroup>
<FormGroup name="api-key" label={t("llm.api_key")}>
<FormTextBox
type="password"
currentValue={apiKey}
onChange={setApiKey}
placeholder={t("llm.api_key_placeholder")}
autoFocus
/>
</FormGroup>
</Modal>,
document.body
);
}

View File

@@ -15,6 +15,7 @@
"start-no-dir": "cross-env TRILIUM_PORT=37743 tsx ../../scripts/electron-start.mts src/main.ts",
"build": "tsx scripts/build.ts",
"start-prod": "pnpm build && cross-env TRILIUM_DATA_DIR=data TRILIUM_PORT=37841 ELECTRON_IS_DEV=0 electron dist",
"start-prod-no-dir": "pnpm build && cross-env TRILIUM_PORT=37841 ELECTRON_IS_DEV=0 electron dist",
"electron-forge:make": "pnpm build && electron-forge make dist",
"electron-forge:make-flatpak": "pnpm build && DEBUG=* electron-forge make dist --targets=@electron-forge/maker-flatpak",
"electron-forge:package": "pnpm build && electron-forge package dist",

View File

@@ -30,6 +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": "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"
},
@@ -81,7 +85,7 @@
"express": "5.2.1",
"express-http-proxy": "2.1.2",
"express-openid-connect": "2.20.1",
"express-rate-limit": "8.3.1",
"express-rate-limit": "8.3.2",
"express-session": "1.19.0",
"file-uri-to-path": "2.0.0",
"fs-extra": "11.3.4",

View File

@@ -57,7 +57,16 @@ export default async function buildApp() {
});
if (!utils.isElectron) {
app.use(compression()); // HTTP compression
app.use(compression({
// Skip compression for SSE endpoints to enable real-time streaming
filter: (req, res) => {
// Skip compression for LLM chat streaming endpoint
if (req.path === "/api/llm-chat/stream") {
return false;
}
return compression.filter(req, res);
}
}));
}
let resourcePolicy = config["Network"]["corsResourcePolicy"] as 'same-origin' | 'same-site' | 'cross-origin' | undefined;

View 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')`.

View 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.

View 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

View File

@@ -256,7 +256,8 @@
},
"quarterNumber": "Quarter {quarterNumber}",
"special_notes": {
"search_prefix": "Search:"
"search_prefix": "Search:",
"llm_chat_prefix": "Chat:"
},
"test_sync": {
"not-configured": "Sync server host is not configured. Please configure sync first.",
@@ -267,6 +268,7 @@
"search-history-title": "Search History",
"note-map-title": "Note Map",
"sql-console-history-title": "SQL Console History",
"llm-chat-history-title": "AI Chat History",
"shared-notes-title": "Shared Notes",
"bulk-action-title": "Bulk Action",
"backend-log-title": "Backend Log",
@@ -310,11 +312,13 @@
"sync-title": "Sync",
"other": "Other",
"advanced-title": "Advanced",
"llm-title": "AI / LLM",
"visible-launchers-title": "Visible Launchers",
"user-guide": "User Guide",
"localization": "Language & Region",
"inbox-title": "Inbox",
"tab-switcher-title": "Tab Switcher"
"tab-switcher-title": "Tab Switcher",
"sidebar-chat-title": "AI Chat"
},
"notes": {
"new-note": "New note",

View File

@@ -1,399 +1,445 @@
{
"keyboard_actions": {
"open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"",
"search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active",
"expand-subtree": "Développer le sous-arbre de la note actuelle",
"collapse-tree": "Réduire toute l'arborescence des notes",
"collapse-subtree": "Réduire le sous-arbre de la note actuelle",
"sort-child-notes": "Trier les notes enfants",
"creating-and-moving-notes": "Créer et déplacer des notes",
"create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière",
"delete-note": "Supprimer la note",
"move-note-up": "Déplacer la note vers le haut",
"move-note-down": "Déplacer la note vers le bas",
"move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie",
"move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie",
"edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre",
"edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche",
"note-clipboard": "Note presse-papiers",
"copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers",
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active",
"cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers",
"select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active",
"add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection",
"add-note-below-to-selection": "Ajouter la note en dessous de la sélection",
"duplicate-subtree": "Dupliquer le sous-arbre",
"tabs-and-windows": "Onglets et fenêtres",
"open-new-tab": "Ouvrir un nouvel onglet",
"close-active-tab": "Fermer l'onglet actif",
"reopen-last-tab": "Rouvrir le dernier onglet fermé",
"activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif",
"activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif",
"open-new-window": "Ouvrir une nouvelle fenêtre vide",
"toggle-tray": "Afficher/masquer l'application dans la barre des tâches",
"first-tab": "Basculer vers le premier onglet dans la liste",
"second-tab": "Basculer vers le deuxième onglet dans la liste",
"third-tab": "Basculer vers le troisième onglet dans la liste",
"fourth-tab": "Basculer vers le quatrième onglet dans la liste",
"fifth-tab": "Basculer vers le cinquième onglet dans la liste",
"sixth-tab": "Basculer vers le sixième onglet dans la liste",
"seventh-tab": "Basculer vers le septième onglet dans la liste",
"eight-tab": "Basculer vers le huitième onglet dans la liste",
"ninth-tab": "Basculer vers le neuvième onglet dans la liste",
"last-tab": "Basculer vers le dernier onglet dans la liste",
"dialogs": "Boîtes de dialogue",
"show-note-source": "Affiche la boîte de dialogue Source de la note",
"show-options": "Afficher les Options",
"show-revisions": "Afficher la boîte de dialogue Versions de la note",
"show-recent-changes": "Afficher la boîte de dialogue Modifications récentes",
"show-sql-console": "Afficher la boîte de dialogue Console SQL",
"show-backend-log": "Afficher la boîte de dialogue Journal du backend",
"text-note-operations": "Opérations sur les notes textuelles",
"add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte",
"follow-link-under-cursor": "Suivre le lien sous le curseur",
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
"paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers",
"cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné",
"add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note",
"edit-readonly-note": "Éditer une note en lecture seule",
"attributes-labels-and-relations": "Attributs (labels et relations)",
"add-new-label": "Créer un nouveau label",
"create-new-relation": "Créer une nouvelle relation",
"ribbon-tabs": "Onglets du ruban",
"toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note",
"toggle-file-properties": "Afficher/masquer les Propriétés du fichier",
"toggle-image-properties": "Afficher/masquer les Propriétés de l'image",
"toggle-owned-attributes": "Afficher/masquer les Attributs propres",
"toggle-inherited-attributes": "Afficher/masquer les Attributs hérités",
"toggle-promoted-attributes": "Afficher/masquer les Attributs promus",
"toggle-link-map": "Afficher/masquer la Carte de la note",
"toggle-note-info": "Afficher/masquer les Informations de la note",
"toggle-note-paths": "Afficher/masquer les Emplacements de la note",
"toggle-similar-notes": "Afficher/masquer les Notes similaires",
"other": "Autre",
"toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations",
"print-active-note": "Imprimer la note active",
"open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut",
"render-active-note": "Rendre (ou re-rendre) la note active",
"run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active",
"toggle-note-hoisting": "Activer le focus sur la note active",
"unhoist": "Désactiver tout focus",
"reload-frontend-app": "Recharger l'application",
"open-dev-tools": "Ouvrir les outils de développement",
"toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)",
"toggle-full-screen": "Basculer en plein écran",
"zoom-out": "Dézoomer",
"zoom-in": "Zoomer",
"note-navigation": "Navigation dans les notes",
"reset-zoom-level": "Réinitialiser le niveau de zoom",
"copy-without-formatting": "Copier le texte sélectionné sans mise en forme",
"force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active",
"show-help": "Affiche le guide de l'utilisateur intégré",
"toggle-book-properties": "Afficher/masquer les Propriétés du Livre",
"toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
"export-as-pdf": "Exporte la note actuelle en PDF",
"show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes",
"toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)",
"back-in-note-history": "Naviguer à la note précédente dans l'historique",
"forward-in-note-history": "Naviguer a la note suivante dans l'historique",
"open-command-palette": "Ouvrir la palette de commandes",
"clone-notes-to": "Cloner les nœuds sélectionnés",
"move-notes-to": "Déplacer les nœuds sélectionnés",
"scroll-to-active-note": "Faire défiler larborescence des notes jusquà la note active",
"quick-search": "Activer la barre de recherche rapide",
"create-note-after": "Créer une note après la note active",
"create-note-into": "Créer une note enfant de la note active",
"find-in-text": "Afficher/Masquer le panneau de recherche"
},
"login": {
"title": "Connexion",
"heading": "Connexion à Trilium",
"incorrect-password": "Le mot de passe est incorrect. Veuillez réessayer.",
"password": "Mot de passe",
"remember-me": "Se souvenir de moi",
"button": "Connexion",
"sign_in_with_sso": "Se connecter avec {{ ssoIssuerName }}",
"incorrect-totp": "TOTP incorrect. Veuillez réessayer."
},
"set_password": {
"title": "Définir un mot de passe",
"heading": "Définir un mot de passe",
"description": "Avant de pouvoir commencer à utiliser Trilium depuis le web, vous devez d'abord définir un mot de passe. Vous utiliserez ensuite ce mot de passe pour vous connecter.",
"password": "Mot de passe",
"password-confirmation": "Confirmation du mot de passe",
"button": "Définir le mot de passe"
},
"share_404": {
"title": "Page non trouvée",
"heading": "Page non trouvée"
},
"share_page": {
"parent": "parent :",
"clipped-from": "Cette note a été initialement extraite de {{- url}}",
"child-notes": "Notes enfants :",
"no-content": "Cette note n'a aucun contenu."
},
"weekdays": {
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche"
},
"months": {
"january": "Janvier",
"february": "Février",
"march": "Mars",
"april": "Avril",
"may": "Mai",
"june": "Juin",
"july": "Juillet",
"august": "Août",
"september": "Septembre",
"october": "Octobre",
"november": "Novembre",
"december": "Décembre"
},
"special_notes": {
"search_prefix": "Recherche :"
},
"test_sync": {
"not-configured": "L'hôte du serveur de synchronisation n'est pas configuré. Veuillez d'abord configurer la synchronisation.",
"successful": "L'établissement de liaison du serveur de synchronisation a été réussi, la synchronisation a été démarrée."
},
"hidden-subtree": {
"root-title": "Notes cachées",
"search-history-title": "Historique de recherche",
"note-map-title": "Carte de la Note",
"sql-console-history-title": "Historique de la console SQL",
"shared-notes-title": "Notes partagées",
"bulk-action-title": "Action groupée",
"backend-log-title": "Journal Backend",
"user-hidden-title": "Utilisateur masqué",
"launch-bar-templates-title": "Modèles de barre de raccourcis",
"base-abstract-launcher-title": "Raccourci Base abstraite",
"command-launcher-title": "Raccourci Commande",
"note-launcher-title": "Raccourci Note",
"script-launcher-title": "Raccourci Script",
"built-in-widget-title": "Widget intégré",
"spacer-title": "Séparateur",
"custom-widget-title": "Widget personnalisé",
"launch-bar-title": "Barre de lancement",
"available-launchers-title": "Raccourcis disponibles",
"go-to-previous-note-title": "Aller à la note précédente",
"go-to-next-note-title": "Aller à la note suivante",
"new-note-title": "Nouvelle note",
"search-notes-title": "Rechercher des notes",
"calendar-title": "Calendrier",
"recent-changes-title": "Modifications récentes",
"bookmarks-title": "Signets",
"open-today-journal-note-title": "Ouvrir la note du journal du jour",
"quick-search-title": "Recherche rapide",
"protected-session-title": "Session protégée",
"sync-status-title": "État de la synchronisation",
"settings-title": "Réglages",
"options-title": "Options",
"appearance-title": "Apparence",
"shortcuts-title": "Raccourcis",
"text-notes": "Notes de texte",
"code-notes-title": "Notes de code",
"images-title": "Images",
"spellcheck-title": "Correcteur orthographique",
"password-title": "Mot de passe",
"etapi-title": "ETAPI",
"backup-title": "Sauvegarde",
"sync-title": "Synchronisation",
"other": "Autre",
"advanced-title": "Avancé",
"visible-launchers-title": "Raccourcis visibles",
"user-guide": "Guide de l'utilisateur",
"jump-to-note-title": "Aller à...",
"multi-factor-authentication-title": "MFA",
"localization": "Langue et région",
"inbox-title": "Boîte de réception",
"command-palette": "Ouvrir la palette de commandes",
"zen-mode": "Mode Zen"
},
"notes": {
"new-note": "Nouvelle note",
"duplicate-note-suffix": "(dup)",
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
},
"backend_log": {
"log-does-not-exist": "Le fichier journal '{{ fileName }}' n'existe pas (encore).",
"reading-log-failed": "La lecture du fichier journal d'administration '{{ fileName }}' a échoué."
},
"content_renderer": {
"note-cannot-be-displayed": "Ce type de note ne peut pas être affiché."
},
"pdf": {
"export_filter": "Document PDF (*.pdf)",
"unable-to-export-message": "La note actuelle n'a pas pu être exportée en format PDF.",
"unable-to-export-title": "Impossible d'exporter au format PDF",
"unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination.",
"unable-to-print": "Impossible d'imprimer la note"
},
"tray": {
"tooltip": "Trilium Notes",
"close": "Quitter Trilium",
"recents": "Notes récentes",
"bookmarks": "Signets",
"today": "Ouvrir la note du journal du jour",
"new-note": "Nouvelle note",
"show-windows": "Afficher les fenêtres",
"open_new_window": "Ouvrir une nouvelle fenêtre"
},
"migration": {
"old_version": "La migration directe à partir de votre version actuelle n'est pas prise en charge. Veuillez d'abord mettre à jour vers la version v0.60.4, puis vers cette nouvelle version.",
"error_message": "Erreur lors de la migration vers la version {{version}}: {{stack}}",
"wrong_db_version": "La version de la base de données ({{version}}) est plus récente que ce que l'application supporte actuellement ({{targetVersion}}), ce qui signifie qu'elle a été créée par une version plus récente et incompatible de Trilium. Mettez à jour vers la dernière version de Trilium pour résoudre ce problème."
},
"modals": {
"error_title": "Erreur"
},
"keyboard_action_names": {
"command-palette": "Palette de commandes",
"quick-search": "Recherche rapide",
"back-in-note-history": "Revenir dans lhistorique des notes",
"forward-in-note-history": "Suivant dans lhistorique des notes",
"jump-to-note": "Aller à…",
"scroll-to-active-note": "Faire défiler jusquà la note active",
"search-in-subtree": "Rechercher dans la sous-arborescence",
"expand-subtree": "Développer la sous-arborescence",
"collapse-tree": "Réduire larborescence",
"collapse-subtree": "Réduire la sous-arborescence",
"sort-child-notes": "Trier les notes enfants",
"create-note-after": "Créer une note après",
"create-note-into": "Créer une note dans",
"create-note-into-inbox": "Créer une note dans Inbox",
"delete-notes": "Supprimer les notes",
"move-note-up": "Remonter la note",
"move-note-down": "Descendre la note",
"move-note-up-in-hierarchy": "Monter la note dans la hiérarchie",
"move-note-down-in-hierarchy": "Descendre la note dans la hiérarchie",
"edit-note-title": "Modifier le titre de la note",
"edit-branch-prefix": "Modifier le préfixe de la branche",
"clone-notes-to": "Cloner les notes vers",
"move-notes-to": "Déplacer les notes vers",
"copy-notes-to-clipboard": "Copier les notes dans le presse-papiers",
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers",
"cut-notes-to-clipboard": "Couper les notes vers le presse-papier",
"select-all-notes-in-parent": "Selectionner toutes les notes dans le parent",
"add-note-above-to-selection": "Ajouter la note au-dessus à la selection",
"add-note-below-to-selection": "Ajouter la note dessous à la selection",
"duplicate-subtree": "Dupliquer la sous-arborescence",
"open-new-tab": "Ouvrir un nouvel onglet",
"close-active-tab": "Fermer l'onglet actif",
"reopen-last-tab": "Réouvrir le dernier onglet",
"activate-next-tab": "Activer l'onglet suivant",
"activate-previous-tab": "Activer l'onglet précédent",
"open-new-window": "Ouvrir une nouvelle fenêtre",
"toggle-system-tray-icon": "Activer/Désactiver l'icone de la barre d'état",
"toggle-zen-mode": "Activer/Désactiver le mode Zen",
"switch-to-first-tab": "Aller au premier onglet",
"switch-to-second-tab": "Aller au second onglet",
"switch-to-third-tab": "Aller au troisième onglet",
"switch-to-fourth-tab": "Aller au quatrième onglet",
"switch-to-fifth-tab": "Aller au cinquième onglet",
"switch-to-sixth-tab": "Aller au sixième onglet",
"switch-to-seventh-tab": "Aller au septième onglet",
"switch-to-eighth-tab": "Aller au huitième onglet",
"switch-to-ninth-tab": "Aller au neuvième onglet",
"switch-to-last-tab": "Aller au dernier onglet",
"show-note-source": "Afficher la source de la note",
"show-options": "Afficher les options",
"show-revisions": "Afficher les révisions",
"show-recent-changes": "Afficher les changements récents",
"show-sql-console": "Afficher la console SQL",
"show-backend-log": "Afficher le journal du backend",
"show-help": "Afficher l'aide",
"show-cheatsheet": "Afficher la fiche de triche",
"add-link-to-text": "Ajouter un lien au texte",
"follow-link-under-cursor": "Suivre le lien en dessous du curseur",
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
"paste-markdown-into-text": "Coller du Markdown dans le texte",
"cut-into-note": "Couper dans une note",
"add-include-note-to-text": "Ajouter une note inclusion au texte",
"edit-read-only-note": "Modifier une note en lecture seule",
"add-new-label": "Ajouter une nouvelle étiquette",
"add-new-relation": "Ajouter une nouvelle relation",
"toggle-ribbon-tab-classic-editor": "Basculer l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
"toggle-ribbon-tab-basic-properties": "Afficher/masquer les Propriétés de base de la note",
"toggle-ribbon-tab-book-properties": "Afficher/masquer les Propriétés du Livre",
"toggle-ribbon-tab-file-properties": "Afficher/masquer les Propriétés du fichier",
"toggle-ribbon-tab-image-properties": "Afficher/masquer les Propriétés de l'image",
"toggle-ribbon-tab-owned-attributes": "Afficher/masquer les Attributs propres",
"toggle-ribbon-tab-inherited-attributes": "Afficher/masquer les Attributs hérités",
"toggle-right-pane": "Afficher le panneau de droite",
"print-active-note": "Imprimer la note active",
"export-active-note-as-pdf": "Exporter la note active en PDF",
"open-note-externally": "Ouvrir la note à l'extérieur",
"render-active-note": "Faire un rendu de la note active",
"run-active-note": "Lancer la note active",
"reload-frontend-app": "Recharger l'application Frontend",
"open-developer-tools": "Ouvrir les outils développeur",
"find-in-text": "Chercher un texte",
"toggle-left-pane": "Afficher le panneau de gauche",
"toggle-full-screen": "Passer en mode plein écran",
"zoom-out": "Dézoomer",
"zoom-in": "Zoomer",
"reset-zoom-level": "Réinitilaliser le zoom",
"copy-without-formatting": "Copier sans mise en forme",
"force-save-revision": "Forcer la sauvegarde de la révision",
"toggle-ribbon-tab-promoted-attributes": "Basculer les attributs promus de l'onglet du ruban",
"toggle-ribbon-tab-note-map": "Basculer l'onglet du ruban Note Map",
"toggle-ribbon-tab-note-info": "Basculer l'onglet du ruban Note Info",
"toggle-ribbon-tab-note-paths": "Basculer les chemins de notes de l'onglet du ruban",
"toggle-ribbon-tab-similar-notes": "Basculer l'onglet du ruban Notes similaires",
"toggle-note-hoisting": "Activer la focalisation sur la note",
"unhoist-note": "Désactiver la focalisation sur la note"
},
"sql_init": {
"db_not_initialized_desktop": "Base de données non initialisée, merci de suivre les instructions à l'écran.",
"db_not_initialized_server": "Base de données non initialisée, veuillez visitez - http://[your-server-host]:{{port}} pour consulter les instructions d'initialisation de Trilium."
},
"desktop": {
"instance_already_running": "Une instance est déjà en cours d'execution, ouverture de cette instance à la place."
},
"weekdayNumber": "Semaine {weekNumber}",
"quarterNumber": "Trimestre {quarterNumber}",
"share_theme": {
"site-theme": "Thème du site",
"search_placeholder": "Recherche...",
"image_alt": "Image de l'article",
"last-updated": "Dernière mise à jour le {{- date}}",
"subpages": "Sous-pages:",
"on-this-page": "Sur cette page",
"expand": "Développer"
},
"hidden_subtree_templates": {
"text-snippet": "Extrait de texte",
"description": "Description",
"list-view": "Vue en liste",
"grid-view": "Vue en grille",
"calendar": "Calendrier",
"table": "Tableau",
"geo-map": "Carte géographique",
"start-date": "Date de début",
"end-date": "Date de fin",
"start-time": "Heure de début",
"end-time": "Heure de fin",
"geolocation": "Géolocalisation",
"built-in-templates": "Modèles intégrés",
"board": "Tableau de bord",
"status": "État",
"board_note_first": "Première note",
"board_note_second": "Deuxième note",
"board_note_third": "Troisième note",
"board_status_todo": "A faire",
"board_status_progress": "En cours",
"board_status_done": "Terminé",
"presentation": "Présentation",
"presentation_slide": "Diapositive de présentation",
"presentation_slide_first": "Première diapositive",
"presentation_slide_second": "Deuxième diapositive",
"background": "Arrière-plan"
}
"keyboard_actions": {
"open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"",
"search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active",
"expand-subtree": "Développer le sous-arbre de la note actuelle",
"collapse-tree": "Réduire toute l'arborescence des notes",
"collapse-subtree": "Réduire le sous-arbre de la note actuelle",
"sort-child-notes": "Trier les notes enfants",
"creating-and-moving-notes": "Créer et déplacer des notes",
"create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière",
"delete-note": "Supprimer la note",
"move-note-up": "Déplacer la note vers le haut",
"move-note-down": "Déplacer la note vers le bas",
"move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie",
"move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie",
"edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre",
"edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche",
"note-clipboard": "Note presse-papiers",
"copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers",
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active",
"cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers",
"select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active",
"add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection",
"add-note-below-to-selection": "Ajouter la note en dessous de la sélection",
"duplicate-subtree": "Dupliquer le sous-arbre",
"tabs-and-windows": "Onglets et fenêtres",
"open-new-tab": "Ouvrir un nouvel onglet",
"close-active-tab": "Fermer l'onglet actif",
"reopen-last-tab": "Rouvrir le dernier onglet fermé",
"activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif",
"activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif",
"open-new-window": "Ouvrir une nouvelle fenêtre vide",
"toggle-tray": "Afficher/masquer l'application dans la barre des tâches",
"first-tab": "Basculer vers le premier onglet dans la liste",
"second-tab": "Basculer vers le deuxième onglet dans la liste",
"third-tab": "Basculer vers le troisième onglet dans la liste",
"fourth-tab": "Basculer vers le quatrième onglet dans la liste",
"fifth-tab": "Basculer vers le cinquième onglet dans la liste",
"sixth-tab": "Basculer vers le sixième onglet dans la liste",
"seventh-tab": "Basculer vers le septième onglet dans la liste",
"eight-tab": "Basculer vers le huitième onglet dans la liste",
"ninth-tab": "Basculer vers le neuvième onglet dans la liste",
"last-tab": "Basculer vers le dernier onglet dans la liste",
"dialogs": "Boîtes de dialogue",
"show-note-source": "Affiche la boîte de dialogue Source de la note",
"show-options": "Afficher les Options",
"show-revisions": "Afficher la boîte de dialogue Versions de la note",
"show-recent-changes": "Afficher la boîte de dialogue Modifications récentes",
"show-sql-console": "Afficher la boîte de dialogue Console SQL",
"show-backend-log": "Afficher la boîte de dialogue Journal du backend",
"text-note-operations": "Opérations sur les notes textuelles",
"add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte",
"follow-link-under-cursor": "Suivre le lien sous le curseur",
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
"paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers",
"cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné",
"add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note",
"edit-readonly-note": "Éditer une note en lecture seule",
"attributes-labels-and-relations": "Attributs (labels et relations)",
"add-new-label": "Créer un nouveau label",
"create-new-relation": "Créer une nouvelle relation",
"ribbon-tabs": "Onglets du ruban",
"toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note",
"toggle-file-properties": "Afficher/masquer les Propriétés du fichier",
"toggle-image-properties": "Afficher/masquer les Propriétés de l'image",
"toggle-owned-attributes": "Afficher/masquer les Attributs propres",
"toggle-inherited-attributes": "Afficher/masquer les Attributs hérités",
"toggle-promoted-attributes": "Afficher/masquer les Attributs promus",
"toggle-link-map": "Afficher/masquer la Carte de la note",
"toggle-note-info": "Afficher/masquer les Informations de la note",
"toggle-note-paths": "Afficher/masquer les Emplacements de la note",
"toggle-similar-notes": "Afficher/masquer les Notes similaires",
"other": "Autre",
"toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations",
"print-active-note": "Imprimer la note active",
"open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut",
"render-active-note": "Rendre (ou re-rendre) la note active",
"run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active",
"toggle-note-hoisting": "Activer le focus sur la note active",
"unhoist": "Désactiver tout focus",
"reload-frontend-app": "Recharger l'application",
"open-dev-tools": "Ouvrir les outils de développement",
"toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)",
"toggle-full-screen": "Basculer en plein écran",
"zoom-out": "Dézoomer",
"zoom-in": "Zoomer",
"note-navigation": "Navigation dans les notes",
"reset-zoom-level": "Réinitialiser le niveau de zoom",
"copy-without-formatting": "Copier le texte sélectionné sans mise en forme",
"force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active",
"show-help": "Affiche le guide de l'utilisateur intégré",
"toggle-book-properties": "Afficher/masquer les Propriétés du Livre",
"toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
"export-as-pdf": "Exporte la note actuelle en PDF",
"show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes",
"toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)",
"back-in-note-history": "Naviguer à la note précédente dans l'historique",
"forward-in-note-history": "Naviguer a la note suivante dans l'historique",
"open-command-palette": "Ouvrir la palette de commandes",
"clone-notes-to": "Cloner les nœuds sélectionnés",
"move-notes-to": "Déplacer les nœuds sélectionnés",
"scroll-to-active-note": "Faire défiler larborescence des notes jusquà la note active",
"quick-search": "Activer la barre de recherche rapide",
"create-note-after": "Créer une note après la note active",
"create-note-into": "Créer une note enfant de la note active",
"find-in-text": "Afficher/Masquer le panneau de recherche"
},
"login": {
"title": "Connexion",
"heading": "Connexion à Trilium",
"incorrect-password": "Le mot de passe est incorrect. Veuillez réessayer.",
"password": "Mot de passe",
"remember-me": "Se souvenir de moi",
"button": "Connexion",
"sign_in_with_sso": "Se connecter avec {{ ssoIssuerName }}",
"incorrect-totp": "TOTP incorrect. Veuillez réessayer."
},
"set_password": {
"title": "Définir un mot de passe",
"heading": "Définir un mot de passe",
"description": "Avant de pouvoir commencer à utiliser Trilium depuis le web, vous devez d'abord définir un mot de passe. Vous utiliserez ensuite ce mot de passe pour vous connecter.",
"password": "Mot de passe",
"password-confirmation": "Confirmation du mot de passe",
"button": "Définir le mot de passe"
},
"setup": {
"heading": "Configuration de Trilium Notes",
"new-document": "Je suis un nouvel utilisateur et je souhaite créer un nouveau document Trilium pour mes notes",
"sync-from-desktop": "J'ai déjà l'application de bureau et je souhaite configurer la synchronisation avec celle-ci",
"sync-from-server": "J'ai déjà un serveur et je souhaite configurer la synchronisation avec celui-ci",
"next": "Suivant",
"init-in-progress": "Initialisation du document en cours",
"redirecting": "Vous serez bientôt redirigé vers l'application.",
"title": "Configuration"
},
"setup_sync-from-desktop": {
"heading": "Synchroniser depuis une application de bureau",
"description": "Cette procédure doit être réalisée depuis l'application de bureau :",
"step1": "Ouvrez l'application Trilium Notes.",
"step2": "Dans le menu Trilium, cliquez sur Options.",
"step3": "Cliquez sur la catégorie Synchroniser.",
"step4": "Remplacez l'adresse de l'instance de serveur par : {{- host}} et cliquez sur Enregistrer.",
"step5": "Cliquez sur le bouton 'Tester la synchronisation' pour vérifier que la connexion fonctionne.",
"step6": "Une fois que vous avez terminé ces étapes, cliquez sur {{- link}}.",
"step6-here": "ici"
},
"setup_sync-from-server": {
"heading": "Synchroniser depuis le serveur",
"instructions": "Veuillez saisir l'adresse du serveur Trilium et les informations d'identification ci-dessous. Cela téléchargera l'intégralité du document Trilium à partir du serveur et configurera la synchronisation avec celui-ci. En fonction de la taille du document et de votre vitesse de connexion, cela peut prendre un plusieurs minutes.",
"server-host": "Adresse du serveur Trilium",
"server-host-placeholder": "https://<nom d'hôte>:<port>",
"proxy-server": "Serveur proxy (facultatif)",
"proxy-server-placeholder": "https://<nom d'hôte>:<port>",
"note": "Note :",
"proxy-instruction": "Si vous laissez le paramètre de proxy vide, le proxy du système sera utilisé (s'applique uniquement à l'application de bureau)",
"password": "Mot de passe",
"password-placeholder": "Mot de passe",
"back": "Retour",
"finish-setup": "Terminer"
},
"setup_sync-in-progress": {
"heading": "Synchronisation en cours",
"successful": "La synchronisation a été correctement configurée. La synchronisation initiale prendra un certain temps. Une fois terminée, vous serez redirigé vers la page de connexion.",
"outstanding-items": "Éléments de synchronisation exceptionnels :",
"outstanding-items-default": "N/A"
},
"share_404": {
"title": "Page non trouvée",
"heading": "Page non trouvée"
},
"share_page": {
"parent": "parent :",
"clipped-from": "Cette note a été initialement extraite de {{- url}}",
"child-notes": "Notes enfants :",
"no-content": "Cette note n'a aucun contenu."
},
"weekdays": {
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche"
},
"months": {
"january": "Janvier",
"february": "Février",
"march": "Mars",
"april": "Avril",
"may": "Mai",
"june": "Juin",
"july": "Juillet",
"august": "Août",
"september": "Septembre",
"october": "Octobre",
"november": "Novembre",
"december": "Décembre"
},
"special_notes": {
"search_prefix": "Recherche :",
"llm_chat_prefix": "Chat:"
},
"test_sync": {
"not-configured": "L'hôte du serveur de synchronisation n'est pas configuré. Veuillez d'abord configurer la synchronisation.",
"successful": "L'établissement de liaison du serveur de synchronisation a été réussi, la synchronisation a été démarrée."
},
"hidden-subtree": {
"root-title": "Notes cachées",
"search-history-title": "Historique de recherche",
"note-map-title": "Carte de la Note",
"sql-console-history-title": "Historique de la console SQL",
"shared-notes-title": "Notes partagées",
"bulk-action-title": "Action groupée",
"backend-log-title": "Journal Backend",
"user-hidden-title": "Utilisateur masqué",
"launch-bar-templates-title": "Modèles de barre de raccourcis",
"base-abstract-launcher-title": "Raccourci Base abstraite",
"command-launcher-title": "Raccourci Commande",
"note-launcher-title": "Raccourci Note",
"script-launcher-title": "Raccourci Script",
"built-in-widget-title": "Widget intégré",
"spacer-title": "Séparateur",
"custom-widget-title": "Widget personnalisé",
"launch-bar-title": "Barre de lancement",
"available-launchers-title": "Raccourcis disponibles",
"go-to-previous-note-title": "Aller à la note précédente",
"go-to-next-note-title": "Aller à la note suivante",
"new-note-title": "Nouvelle note",
"search-notes-title": "Rechercher des notes",
"calendar-title": "Calendrier",
"recent-changes-title": "Modifications récentes",
"bookmarks-title": "Signets",
"open-today-journal-note-title": "Ouvrir la note du journal du jour",
"quick-search-title": "Recherche rapide",
"protected-session-title": "Session protégée",
"sync-status-title": "État de la synchronisation",
"settings-title": "Réglages",
"options-title": "Options",
"appearance-title": "Apparence",
"shortcuts-title": "Raccourcis",
"text-notes": "Notes de texte",
"code-notes-title": "Notes de code",
"images-title": "Images",
"spellcheck-title": "Correcteur orthographique",
"password-title": "Mot de passe",
"etapi-title": "ETAPI",
"backup-title": "Sauvegarde",
"sync-title": "Synchronisation",
"other": "Autre",
"advanced-title": "Avancé",
"visible-launchers-title": "Raccourcis visibles",
"user-guide": "Guide de l'utilisateur",
"jump-to-note-title": "Aller à...",
"multi-factor-authentication-title": "MFA",
"localization": "Langue et région",
"inbox-title": "Boîte de réception",
"command-palette": "Ouvrir la palette de commandes",
"zen-mode": "Mode Zen",
"llm-chat-history-title": "Historique du chat",
"llm-title": "AI / LLM",
"tab-switcher-title": "Commutateur d'onglets",
"sidebar-chat-title": "AI Chat"
},
"notes": {
"new-note": "Nouvelle note",
"duplicate-note-suffix": "(dup)",
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
},
"backend_log": {
"log-does-not-exist": "Le fichier journal '{{ fileName }}' n'existe pas (encore).",
"reading-log-failed": "La lecture du fichier journal d'administration '{{ fileName }}' a échoué."
},
"content_renderer": {
"note-cannot-be-displayed": "Ce type de note ne peut pas être affiché."
},
"pdf": {
"export_filter": "Document PDF (*.pdf)",
"unable-to-export-message": "La note actuelle n'a pas pu être exportée en format PDF.",
"unable-to-export-title": "Impossible d'exporter au format PDF",
"unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination.",
"unable-to-print": "Impossible d'imprimer la note"
},
"tray": {
"tooltip": "Trilium Notes",
"close": "Quitter Trilium",
"recents": "Notes récentes",
"bookmarks": "Signets",
"today": "Ouvrir la note du journal du jour",
"new-note": "Nouvelle note",
"show-windows": "Afficher les fenêtres",
"open_new_window": "Ouvrir une nouvelle fenêtre"
},
"migration": {
"old_version": "La migration directe à partir de votre version actuelle n'est pas prise en charge. Veuillez d'abord mettre à jour vers la version v0.60.4, puis vers cette nouvelle version.",
"error_message": "Erreur lors de la migration vers la version {{version}}: {{stack}}",
"wrong_db_version": "La version de la base de données ({{version}}) est plus récente que ce que l'application supporte actuellement ({{targetVersion}}), ce qui signifie qu'elle a été créée par une version plus récente et incompatible de Trilium. Mettez à jour vers la dernière version de Trilium pour résoudre ce problème."
},
"modals": {
"error_title": "Erreur"
},
"keyboard_action_names": {
"command-palette": "Palette de commandes",
"quick-search": "Recherche rapide",
"back-in-note-history": "Revenir dans lhistorique des notes",
"forward-in-note-history": "Suivant dans lhistorique des notes",
"jump-to-note": "Aller à…",
"scroll-to-active-note": "Faire défiler jusquà la note active",
"search-in-subtree": "Rechercher dans la sous-arborescence",
"expand-subtree": "Développer la sous-arborescence",
"collapse-tree": "Réduire larborescence",
"collapse-subtree": "Réduire la sous-arborescence",
"sort-child-notes": "Trier les notes enfants",
"create-note-after": "Créer une note après",
"create-note-into": "Créer une note dans",
"create-note-into-inbox": "Créer une note dans Inbox",
"delete-notes": "Supprimer les notes",
"move-note-up": "Remonter la note",
"move-note-down": "Descendre la note",
"move-note-up-in-hierarchy": "Monter la note dans la hiérarchie",
"move-note-down-in-hierarchy": "Descendre la note dans la hiérarchie",
"edit-note-title": "Modifier le titre de la note",
"edit-branch-prefix": "Modifier le préfixe de la branche",
"clone-notes-to": "Cloner les notes vers",
"move-notes-to": "Déplacer les notes vers",
"copy-notes-to-clipboard": "Copier les notes dans le presse-papiers",
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers",
"cut-notes-to-clipboard": "Couper les notes vers le presse-papier",
"select-all-notes-in-parent": "Selectionner toutes les notes dans le parent",
"add-note-above-to-selection": "Ajouter la note au-dessus à la selection",
"add-note-below-to-selection": "Ajouter la note dessous à la selection",
"duplicate-subtree": "Dupliquer la sous-arborescence",
"open-new-tab": "Ouvrir un nouvel onglet",
"close-active-tab": "Fermer l'onglet actif",
"reopen-last-tab": "Réouvrir le dernier onglet",
"activate-next-tab": "Activer l'onglet suivant",
"activate-previous-tab": "Activer l'onglet précédent",
"open-new-window": "Ouvrir une nouvelle fenêtre",
"toggle-system-tray-icon": "Activer/Désactiver l'icone de la barre d'état",
"toggle-zen-mode": "Activer/Désactiver le mode Zen",
"switch-to-first-tab": "Aller au premier onglet",
"switch-to-second-tab": "Aller au second onglet",
"switch-to-third-tab": "Aller au troisième onglet",
"switch-to-fourth-tab": "Aller au quatrième onglet",
"switch-to-fifth-tab": "Aller au cinquième onglet",
"switch-to-sixth-tab": "Aller au sixième onglet",
"switch-to-seventh-tab": "Aller au septième onglet",
"switch-to-eighth-tab": "Aller au huitième onglet",
"switch-to-ninth-tab": "Aller au neuvième onglet",
"switch-to-last-tab": "Aller au dernier onglet",
"show-note-source": "Afficher la source de la note",
"show-options": "Afficher les options",
"show-revisions": "Afficher les révisions",
"show-recent-changes": "Afficher les changements récents",
"show-sql-console": "Afficher la console SQL",
"show-backend-log": "Afficher le journal du backend",
"show-help": "Afficher l'aide",
"show-cheatsheet": "Afficher la fiche de triche",
"add-link-to-text": "Ajouter un lien au texte",
"follow-link-under-cursor": "Suivre le lien en dessous du curseur",
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
"paste-markdown-into-text": "Coller du Markdown dans le texte",
"cut-into-note": "Couper dans une note",
"add-include-note-to-text": "Ajouter une note inclusion au texte",
"edit-read-only-note": "Modifier une note en lecture seule",
"add-new-label": "Ajouter une nouvelle étiquette",
"add-new-relation": "Ajouter une nouvelle relation",
"toggle-ribbon-tab-classic-editor": "Basculer l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
"toggle-ribbon-tab-basic-properties": "Afficher/masquer les Propriétés de base de la note",
"toggle-ribbon-tab-book-properties": "Afficher/masquer les Propriétés du Livre",
"toggle-ribbon-tab-file-properties": "Afficher/masquer les Propriétés du fichier",
"toggle-ribbon-tab-image-properties": "Afficher/masquer les Propriétés de l'image",
"toggle-ribbon-tab-owned-attributes": "Afficher/masquer les Attributs propres",
"toggle-ribbon-tab-inherited-attributes": "Afficher/masquer les Attributs hérités",
"toggle-right-pane": "Afficher le panneau de droite",
"print-active-note": "Imprimer la note active",
"export-active-note-as-pdf": "Exporter la note active en PDF",
"open-note-externally": "Ouvrir la note à l'extérieur",
"render-active-note": "Faire un rendu de la note active",
"run-active-note": "Lancer la note active",
"reload-frontend-app": "Recharger l'application Frontend",
"open-developer-tools": "Ouvrir les outils développeur",
"find-in-text": "Chercher un texte",
"toggle-left-pane": "Afficher le panneau de gauche",
"toggle-full-screen": "Passer en mode plein écran",
"zoom-out": "Dézoomer",
"zoom-in": "Zoomer",
"reset-zoom-level": "Réinitilaliser le zoom",
"copy-without-formatting": "Copier sans mise en forme",
"force-save-revision": "Forcer la sauvegarde de la révision",
"toggle-ribbon-tab-promoted-attributes": "Basculer les attributs promus de l'onglet du ruban",
"toggle-ribbon-tab-note-map": "Basculer l'onglet du ruban Note Map",
"toggle-ribbon-tab-note-info": "Basculer l'onglet du ruban Note Info",
"toggle-ribbon-tab-note-paths": "Basculer les chemins de notes de l'onglet du ruban",
"toggle-ribbon-tab-similar-notes": "Basculer l'onglet du ruban Notes similaires",
"toggle-note-hoisting": "Activer la focalisation sur la note",
"unhoist-note": "Désactiver la focalisation sur la note"
},
"sql_init": {
"db_not_initialized_desktop": "Base de données non initialisée, merci de suivre les instructions à l'écran.",
"db_not_initialized_server": "Base de données non initialisée, veuillez visitez - http://[your-server-host]:{{port}} pour consulter les instructions d'initialisation de Trilium."
},
"desktop": {
"instance_already_running": "Une instance est déjà en cours d'execution, ouverture de cette instance à la place."
},
"weekdayNumber": "Semaine {weekNumber}",
"quarterNumber": "Trimestre {quarterNumber}",
"share_theme": {
"site-theme": "Thème du site",
"search_placeholder": "Recherche...",
"image_alt": "Image de l'article",
"last-updated": "Dernière mise à jour le {{- date}}",
"subpages": "Sous-pages:",
"on-this-page": "Sur cette page",
"expand": "Développer"
},
"hidden_subtree_templates": {
"text-snippet": "Extrait de texte",
"description": "Description",
"list-view": "Vue en liste",
"grid-view": "Vue en grille",
"calendar": "Calendrier",
"table": "Tableau",
"geo-map": "Carte géographique",
"start-date": "Date de début",
"end-date": "Date de fin",
"start-time": "Heure de début",
"end-time": "Heure de fin",
"geolocation": "Géolocalisation",
"built-in-templates": "Modèles intégrés",
"board": "Tableau Kanban",
"status": "État",
"board_note_first": "Première note",
"board_note_second": "Deuxième note",
"board_note_third": "Troisième note",
"board_status_todo": "A faire",
"board_status_progress": "En cours",
"board_status_done": "Terminé",
"presentation": "Présentation",
"presentation_slide": "Diapositive de présentation",
"presentation_slide_first": "Première diapositive",
"presentation_slide_second": "Deuxième diapositive",
"background": "Arrière-plan"
}
}

View File

@@ -148,7 +148,10 @@
"script-launcher-title": "Scorciatoie degli script",
"command-palette": "Apri tavolozza comandi",
"zen-mode": "Modalità Zen",
"tab-switcher-title": "Selettore scheda"
"tab-switcher-title": "Selettore scheda",
"llm-chat-history-title": "Cronologia chat IA",
"llm-title": "AI / LLM",
"sidebar-chat-title": "Chat con IA"
},
"notes": {
"new-note": "Nuova nota",
@@ -359,7 +362,8 @@
},
"quarterNumber": "Quadrimestre n. {quarterNumber}",
"special_notes": {
"search_prefix": "Ricerca:"
"search_prefix": "Ricerca:",
"llm_chat_prefix": "Chat:"
},
"test_sync": {
"not-configured": "L'host del server di sincronizzazione non è impostato. Configurare prima la sincronizzazione.",

View File

@@ -17,6 +17,11 @@ export declare module "express-serve-static-core" {
"user-agent"?: string;
};
}
interface Response {
/** Set to true to prevent apiResultHandler from double-handling the response (e.g., for SSE streams) */
triliumResponseHandled?: boolean;
}
}
export declare module "express-session" {

View File

@@ -0,0 +1,104 @@
import type { LlmMessage } from "@triliumnext/commons";
import type { Request, Response } from "express";
import { generateChatTitle } from "../../services/llm/chat_title.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";
interface ChatRequest {
messages: LlmMessage[];
config?: LlmProviderConfig;
}
/**
* SSE endpoint for streaming chat completions.
*
* Response format (Server-Sent Events):
* data: {"type":"text","content":"Hello"}
* data: {"type":"text","content":" world"}
* data: {"type":"done"}
*
* On error:
* data: {"type":"error","error":"Error message"}
*/
async function streamChat(req: Request, res: Response) {
const { messages, config = {} } = req.body as ChatRequest;
if (!messages || !Array.isArray(messages) || messages.length === 0) {
res.status(400).json({ error: "messages array is required" });
return;
}
// Set up SSE headers - disable compression and buffering for real-time streaming
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
res.flushHeaders();
// Mark response as handled to prevent double-handling by apiResultHandler
res.triliumResponseHandled = true;
// Type assertion for flush method (available when compression is used)
const flushableRes = res as Response & { flush?: () => void };
try {
if (!hasConfiguredProviders()) {
res.write(`data: ${JSON.stringify({ type: "error", error: "No LLM providers configured. Please add a provider in Options → AI / LLM." })}\n\n`);
return;
}
const provider = getProviderByType(config.provider || "anthropic");
const result = provider.chat(messages, config);
// Get pricing and display name for the model
const modelId = config.model || provider.getAvailableModels().find(m => m.isDefault)?.id;
if (!modelId) {
res.write(`data: ${JSON.stringify({ type: "error", error: "No model specified and no default model available for the provider." })}\n\n`);
return;
}
const pricing = provider.getModelPricing(modelId);
const modelDisplayName = provider.getAvailableModels().find(m => m.id === modelId)?.name || modelId;
for await (const chunk of streamToChunks(result, { model: modelDisplayName, pricing })) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
// Flush immediately to ensure real-time streaming
if (typeof flushableRes.flush === "function") {
flushableRes.flush();
}
}
// Auto-generate a title for the chat note on the first user message
const userMessages = messages.filter(m => m.role === "user");
if (userMessages.length === 1 && config.chatNoteId) {
try {
await generateChatTitle(config.chatNoteId, userMessages[0].content);
} catch (err) {
// Title generation is best-effort; don't fail the chat
log.error(`Failed to generate chat title: ${safeExtractMessageAndStackFromError(err)}`);
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
res.write(`data: ${JSON.stringify({ type: "error", error: errorMessage })}\n\n`);
} finally {
res.end();
}
}
/**
* Get available models from all configured providers.
*/
function getModels(_req: Request, _res: Response) {
if (!hasConfiguredProviders()) {
return { models: [] };
}
return { models: getAllModels() };
}
export default {
streamChat,
getModels
};

View File

@@ -0,0 +1,34 @@
import { Request } from "express";
import specialNotesService from "../../services/special_notes";
function createLlmChat() {
return specialNotesService.createLlmChat();
}
function getMostRecentLlmChat() {
const chat = specialNotesService.getMostRecentLlmChat();
// Return null explicitly if no chat found (not undefined)
return chat || null;
}
function getOrCreateLlmChat() {
return specialNotesService.getOrCreateLlmChat();
}
function getRecentLlmChats(req: Request) {
const limit = parseInt(req.query.limit as string) || 10;
return specialNotesService.getRecentLlmChats(limit);
}
function saveLlmChat(req: Request<{ llmChatNoteId: string }>) {
return specialNotesService.saveLlmChat(req.body?.llmChatNoteId);
}
export default {
createLlmChat,
getMostRecentLlmChat,
getOrCreateLlmChat,
getRecentLlmChats,
saveLlmChat,
};

View File

@@ -115,6 +115,7 @@ class FakeResponse extends EventEmitter implements Pick<Response<any, Record<str
}
json(obj) {
this.respHeaders["Content-Type"] = "application/json";
this.send(JSON.stringify(obj));
return this as unknown as MockedResponse;
}

View File

@@ -115,7 +115,7 @@ function internalRoute<P extends ParamsDictionary>(method: HttpMethod, path: str
function handleResponse(resultHandler: ApiResultHandler, req: express.Request, res: express.Response, result: unknown, start: number) {
// Skip result handling if the response has already been handled
if ((res as any).triliumResponseHandled) {
if (res.triliumResponseHandled) {
// Just log the request without additional processing
log.request(req, res, Date.now() - start, 0);
return;
@@ -131,7 +131,7 @@ function handleException(e: unknown | Error, method: HttpMethod, path: string, r
log.error(`${method} ${path} threw exception: '${errMessage}', stack: ${errStack}`);
// Skip sending response if it's already been handled by the route handler
if ((res as unknown as { triliumResponseHandled?: boolean }).triliumResponseHandled || res.headersSent) {
if (res.triliumResponseHandled || res.headersSent) {
return;
}

View File

@@ -24,6 +24,8 @@ import databaseRoute from "./api/database.js";
import etapiTokensApiRoutes from "./api/etapi_tokens.js";
import filesRoute from "./api/files.js";
import fontsRoute from "./api/fonts.js";
import llmChatRoute from "./api/llm_chat.js";
import llmSpecialNotesRoute from "./api/llm_special_notes.js";
import loginApiRoute from "./api/login.js";
import metricsRoute from "./api/metrics.js";
import passwordApiRoute from "./api/password.js";
@@ -158,6 +160,11 @@ function register(app: express.Application) {
route(PST, "/api/clipper/open/:noteId", clipperMiddleware, clipperRoute.openNote, apiResultHandler);
asyncRoute(GET, "/api/clipper/notes-by-url/:noteUrl", clipperMiddleware, clipperRoute.findNotesByUrl, apiResultHandler);
apiRoute(PST, "/api/special-notes/llm-chat", llmSpecialNotesRoute.createLlmChat);
apiRoute(GET, "/api/special-notes/most-recent-llm-chat", llmSpecialNotesRoute.getMostRecentLlmChat);
apiRoute(GET, "/api/special-notes/get-or-create-llm-chat", llmSpecialNotesRoute.getOrCreateLlmChat);
apiRoute(GET, "/api/special-notes/recent-llm-chats", llmSpecialNotesRoute.getRecentLlmChats);
apiRoute(PST, "/api/special-notes/save-llm-chat", llmSpecialNotesRoute.saveLlmChat);
asyncRoute(PST, "/api/database/anonymize/:type", [auth.checkApiAuthOrElectron, csrfMiddleware], databaseRoute.anonymize, apiResultHandler);
apiRoute(GET, "/api/database/anonymized-databases", databaseRoute.getExistingAnonymizedDatabases);
@@ -176,6 +183,10 @@ function register(app: express.Application) {
apiRoute(GET, "/api/database/check-integrity", databaseRoute.checkIntegrity);
// LLM chat endpoints
asyncRoute(PST, "/api/llm-chat/stream", [auth.checkApiAuthOrElectron, csrfMiddleware], llmChatRoute.streamChat, null);
apiRoute(GET, "/api/llm-chat/models", llmChatRoute.getModels);
// no CSRF since this is called from android app
route(PST, "/api/sender/login", [loginRateLimiter], loginApiRoute.token, apiResultHandler);
asyncRoute(PST, "/api/sender/image", [auth.checkEtapiToken, uploadMiddlewareWithErrorHandling], senderRoute.uploadImage, apiResultHandler);

View File

@@ -0,0 +1,37 @@
import becca from "../../becca/becca.js";
import { getProvider } from "./index.js";
import log from "../log.js";
import { t } from "i18next";
/** Default title prefixes that indicate the note hasn't been manually renamed. */
function hasDefaultTitle(title: string): boolean {
// "Chat: <timestamp>" from sidebar/API-created chats
const chatPrefix = t("special_notes.llm_chat_prefix");
// "New note" from manually created chats
const newNoteTitle = t("notes.new-note");
return title.startsWith(chatPrefix) || title === newNoteTitle;
}
/**
* Generate a short descriptive title for a chat note based on the first user message,
* then rename the note. Only renames if the note still has a default title.
*/
export async function generateChatTitle(chatNoteId: string, firstMessage: string): Promise<void> {
const note = becca.getNote(chatNoteId);
if (!note) {
return;
}
if (!hasDefaultTitle(note.title)) {
return;
}
const provider = getProvider();
const title = await provider.generateTitle(firstMessage);
if (title) {
note.title = title;
note.save();
log.info(`Auto-renamed chat note ${chatNoteId} to "${title}"`);
}
}

View File

@@ -0,0 +1,138 @@
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";
/**
* Configuration for a single LLM provider instance.
* This matches the structure stored in the llmProviders option.
*/
export interface LlmProviderSetup {
id: string;
name: string;
provider: string;
apiKey: string;
}
/** Factory functions for creating provider instances */
const providerFactories: Record<string, (apiKey: string) => LlmProvider> = {
anthropic: (apiKey) => new AnthropicProvider(apiKey),
openai: (apiKey) => new OpenAiProvider(apiKey),
google: (apiKey) => new GoogleProvider(apiKey)
};
/** Cache of instantiated providers by their config ID */
let cachedProviders: Record<string, LlmProvider> = {};
/**
* Get configured providers from the options.
*/
function getConfiguredProviders(): LlmProviderSetup[] {
try {
const providersJson = optionService.getOptionOrNull("llmProviders");
if (!providersJson) {
return [];
}
return JSON.parse(providersJson) as LlmProviderSetup[];
} catch (e) {
log.error(`Failed to parse llmProviders option: ${e}`);
return [];
}
}
/**
* Get a provider instance by its configuration ID.
* If no ID is provided, returns the first configured provider.
*/
export function getProvider(providerId?: string): LlmProvider {
const configs = getConfiguredProviders();
if (configs.length === 0) {
throw new Error("No LLM providers configured. Please add a provider in Options → AI / LLM.");
}
// Find the requested provider or use the first one
const config = providerId
? configs.find(c => c.id === providerId)
: configs[0];
if (!config) {
throw new Error(`LLM provider not found: ${providerId}`);
}
// Check cache
if (cachedProviders[config.id]) {
return cachedProviders[config.id];
}
// Create new provider instance
const factory = providerFactories[config.provider];
if (!factory) {
throw new Error(`Unknown LLM provider type: ${config.provider}. Available: ${Object.keys(providerFactories).join(", ")}`);
}
const provider = factory(config.apiKey);
cachedProviders[config.id] = provider;
return provider;
}
/**
* Get the first configured provider of a specific type (e.g., "anthropic").
*/
export function getProviderByType(providerType: string): LlmProvider {
const configs = getConfiguredProviders();
const config = configs.find(c => c.provider === providerType);
if (!config) {
throw new Error(`No ${providerType} provider configured. Please add one in Options → AI / LLM.`);
}
return getProvider(config.id);
}
/**
* Check if any providers are configured.
*/
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.
*/
export function clearProviderCache(): void {
cachedProviders = {};
}
export type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing } from "./types.js";

View File

@@ -0,0 +1,168 @@
import { createAnthropic, type AnthropicProvider as AnthropicSDKProvider } from "@ai-sdk/anthropic";
import { stepCountIs, streamText, type ModelMessage, 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 Anthropic models with pricing (USD per million tokens).
* Source: https://docs.anthropic.com/en/docs/about-claude/models
*/
const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([
// ===== Current Models =====
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
pricing: { input: 3, output: 15 },
contextWindow: 1000000,
isDefault: true
},
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
pricing: { input: 5, output: 25 },
contextWindow: 1000000
},
{
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
pricing: { input: 1, output: 5 },
contextWindow: 200000
},
// ===== Legacy Models =====
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
pricing: { input: 3, output: 15 },
contextWindow: 200000,
isLegacy: true
},
{
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
pricing: { input: 5, output: 25 },
contextWindow: 200000,
isLegacy: true
},
{
id: "claude-opus-4-1-20250805",
name: "Claude Opus 4.1",
pricing: { input: 15, output: 75 },
contextWindow: 200000,
isLegacy: true
},
{
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4.0",
pricing: { input: 3, output: 15 },
contextWindow: 200000,
isLegacy: true
},
{
id: "claude-opus-4-20250514",
name: "Claude Opus 4.0",
pricing: { input: 15, output: 75 },
contextWindow: 200000,
isLegacy: true
}
]);
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 });
}
protected createModel(modelId: string) {
return this.anthropic(modelId);
}
protected override addWebSearchTool(tools: ToolSet): void {
tools.web_search = this.anthropic.tools.webSearch_20250305({
maxUses: 5
});
}
/**
* 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[] = [];
if (systemPrompt) {
coreMessages.push({
role: "system",
content: systemPrompt,
providerOptions: CACHE_CONTROL
});
}
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,
...(isLastBeforeNewTurn && { providerOptions: CACHE_CONTROL })
});
}
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);
const streamOptions: Parameters<typeof streamText>[0] = {
model: this.createModel(config.model || this.defaultModel),
messages: coreMessages,
maxOutputTokens: maxTokens,
providerOptions: {
anthropic: {
thinking: {
type: "enabled",
budgetTokens: thinkingBudget
}
}
}
};
const tools = this.buildTools(config);
if (Object.keys(tools).length > 0) {
streamOptions.tools = tools;
streamOptions.stopWhen = stepCountIs(5);
streamOptions.toolChoice = "auto";
}
return streamText(streamOptions);
}
}

View 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();
}
}

View 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);
}
}

View 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();
}
}

View 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
};

View File

@@ -0,0 +1,106 @@
/**
* Shared streaming utilities for converting AI SDK streams to SSE chunks.
*/
import type { LlmStreamChunk } from "@triliumnext/commons";
import type { ModelPricing, StreamResult } from "./types.js";
/**
* Calculate estimated cost in USD based on token usage and pricing.
*/
function calculateCost(inputTokens: number, outputTokens: number, pricing?: ModelPricing): number | undefined {
if (!pricing) return undefined;
const inputCost = (inputTokens / 1_000_000) * pricing.input;
const outputCost = (outputTokens / 1_000_000) * pricing.output;
return inputCost + outputCost;
}
export interface StreamOptions {
/** Model identifier for display */
model?: string;
/** Model pricing for cost calculation (from provider) */
pricing?: ModelPricing;
}
/**
* Convert an AI SDK StreamResult to an async iterable of LlmStreamChunk.
* This is provider-agnostic - works with any AI SDK provider.
*/
export async function* streamToChunks(result: StreamResult, options: StreamOptions = {}): AsyncIterable<LlmStreamChunk> {
try {
for await (const part of result.fullStream) {
switch (part.type) {
case "text-delta":
yield { type: "text", content: part.text };
break;
case "reasoning-delta":
yield { type: "thinking", content: part.text };
break;
case "tool-call":
yield {
type: "tool_use",
toolName: part.toolName,
toolInput: part.input as Record<string, unknown>
};
break;
case "tool-result": {
const output = part.output;
const isError = typeof output === "object" && output !== null && "error" in output;
yield {
type: "tool_result",
toolName: part.toolName,
result: typeof output === "string"
? output
: JSON.stringify(output),
isError
};
break;
}
case "source":
// Citation from web search (only URL sources have url property)
if (part.sourceType === "url") {
yield {
type: "citation",
citation: {
url: part.url,
title: part.title
}
};
}
break;
case "error":
yield { type: "error", error: String(part.error) };
break;
}
}
// Get usage information after stream completes
const usage = await result.usage;
if (usage && typeof usage.inputTokens === "number" && typeof usage.outputTokens === "number") {
const cost = calculateCost(usage.inputTokens, usage.outputTokens, options.pricing);
yield {
type: "usage",
usage: {
promptTokens: usage.inputTokens,
completionTokens: usage.outputTokens,
totalTokens: usage.inputTokens + usage.outputTokens,
cost,
model: options.model
}
};
}
yield { type: "done" };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
yield { type: "error", error: message };
}
}

View File

@@ -0,0 +1,137 @@
/**
* LLM tools for attribute operations (get, set, delete labels/relations).
*/
import { tool } from "ai";
import { z } from "zod";
import becca from "../../../becca/becca.js";
import attributeService from "../../attributes.js";
/**
* Get all owned attributes (labels/relations) of a note.
*/
export const getAttributes = tool({
description: "Get all attributes (labels and relations) of a note. Labels store text values; relations link to other notes by ID.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note")
}),
execute: async ({ noteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
return note.getOwnedAttributes()
.filter((attr) => !attr.isAutoLink())
.map((attr) => ({
attributeId: attr.attributeId,
type: attr.type,
name: attr.name,
value: attr.value,
isInheritable: attr.isInheritable
}));
}
});
/**
* Get a single attribute by its ID.
*/
export const getAttribute = tool({
description: "Get a single attribute by its ID.",
inputSchema: z.object({
attributeId: z.string().describe("The ID of the attribute")
}),
execute: async ({ attributeId }) => {
const attribute = becca.getAttribute(attributeId);
if (!attribute) {
return { error: "Attribute not found" };
}
return {
attributeId: attribute.attributeId,
noteId: attribute.noteId,
type: attribute.type,
name: attribute.name,
value: attribute.value,
isInheritable: attribute.isInheritable
};
}
});
/**
* Add or update an attribute on a note.
*/
export const setAttribute = tool({
description: "Add or update an attribute on a note. If an attribute with the same type and name exists, it is updated; otherwise a new one is created. Use type 'label' for text values, 'relation' for linking to another note (value must be a noteId).",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note"),
type: z.enum(["label", "relation"]).describe("The attribute type"),
name: z.string().describe("The attribute name"),
value: z.string().optional().describe("The attribute value (for relations, this must be a target noteId)")
}),
execute: async ({ noteId, type, name, value = "" }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (note.isProtected) {
return { error: "Note is protected and cannot be modified" };
}
if (attributeService.isAttributeDangerous(type, name)) {
return { error: `Attribute '${name}' is potentially dangerous and cannot be set by the LLM` };
}
if (type === "relation" && value && !becca.getNote(value)) {
return { error: "Target note not found for relation" };
}
note.setAttribute(type, name, value);
return {
success: true,
noteId: note.noteId,
type,
name,
value
};
}
});
/**
* Remove an attribute from a note.
*/
export const deleteAttribute = tool({
description: "Remove an attribute from a note by its attribute ID.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note that owns the attribute"),
attributeId: z.string().describe("The ID of the attribute to delete")
}),
execute: async ({ noteId, attributeId }) => {
const attribute = becca.getAttribute(attributeId);
if (!attribute) {
return { error: "Attribute not found" };
}
if (attribute.noteId !== noteId) {
return { error: "Attribute does not belong to the specified note" };
}
const note = becca.getNote(noteId);
if (note?.isProtected) {
return { error: "Note is protected and cannot be modified" };
}
attribute.markAsDeleted();
return {
success: true,
attributeId
};
}
});
export const attributeTools = {
get_attributes: getAttributes,
get_attribute: getAttribute,
set_attribute: setAttribute,
delete_attribute: deleteAttribute
};

View 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
};

View File

@@ -0,0 +1,9 @@
/**
* LLM tools that wrap existing Trilium services.
* These reuse the same logic as ETAPI without any HTTP overhead.
*/
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";

View File

@@ -0,0 +1,278 @@
/**
* LLM tools for note operations (search, read, create, update, append).
*/
import { tool } from "ai";
import { z } from "zod";
import becca from "../../../becca/becca.js";
import markdownExport from "../../export/markdown.js";
import markdownImport from "../../import/markdown.js";
import noteService from "../../notes.js";
import SearchContext from "../../search/search_context.js";
import searchService from "../../search/services/search.js";
/**
* Convert note content to a format suitable for LLM consumption.
* Text notes are converted from HTML to Markdown to reduce token usage.
*/
export function getNoteContentForLlm(note: { type: string; getContent: () => string | Buffer }) {
const content = note.getContent();
if (typeof content !== "string") {
return "[binary content]";
}
if (note.type === "text") {
return markdownExport.toMarkdown(content);
}
return content;
}
/**
* Convert LLM-provided content to a format suitable for storage.
* For text notes, converts Markdown to HTML.
*/
function setNoteContentFromLlm(note: { type: string; title: string; setContent: (content: string) => void }, content: string) {
if (note.type === "text") {
note.setContent(markdownImport.renderToHtml(content, note.title));
} else {
note.setContent(content);
}
}
/**
* Search for notes in the knowledge base.
*/
export const searchNotes = tool({
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 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, fastSearch, includeArchivedNotes, ancestorNoteId, limit = 10 }) => {
const searchContext = new SearchContext({
fastSearch,
includeArchivedNotes,
ancestorNoteId
});
const results = searchService.findResultsWithQuery(query, searchContext);
return results.slice(0, limit).map(sr => {
const note = becca.notes[sr.noteId];
if (!note) return null;
return {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type
};
}).filter(Boolean);
}
});
/**
* Read the content of a specific note.
*/
export const readNote = tool({
description: "Read the full content of a note by its ID. Use search_notes first to find relevant note IDs. Text notes are returned as Markdown.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to read")
}),
execute: async ({ noteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected" };
}
return {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type,
content: getNoteContentForLlm(note)
};
}
});
/**
* Update the content of a note.
*/
export const updateNoteContent = tool({
description: "Replace the entire content of a note. Use this to completely rewrite a note's content. For text notes, provide Markdown content.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to update"),
content: z.string().describe("The new content for the note (Markdown for text notes, plain text for code notes)")
}),
execute: async ({ noteId, content }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected and cannot be modified" };
}
if (!note.hasStringContent()) {
return { error: `Cannot update content for note type: ${note.type}` };
}
note.saveRevision();
setNoteContentFromLlm(note, content);
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected()
};
}
});
/**
* Append content to a note.
*/
export const appendToNote = tool({
description: "Append content to the end of an existing note. For text notes, provide Markdown content.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to append to"),
content: z.string().describe("The content to append (Markdown for text notes, plain text for code notes)")
}),
execute: async ({ noteId, content }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected and cannot be modified" };
}
if (!note.hasStringContent()) {
return { error: `Cannot update content for note type: ${note.type}` };
}
const existingContent = note.getContent();
if (typeof existingContent !== "string") {
return { error: "Note has binary content" };
}
let newContent: string;
if (note.type === "text") {
const htmlToAppend = markdownImport.renderToHtml(content, note.getTitleOrProtected());
newContent = existingContent + htmlToAppend;
} else {
newContent = existingContent + (existingContent.endsWith("\n") ? "" : "\n") + content;
}
note.saveRevision();
note.setContent(newContent);
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected()
};
}
});
/**
* 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.",
"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. 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"]).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, 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" };
}
if (!parentNote.isContentAvailable()) {
return { error: "Cannot create note under a protected parent" };
}
const htmlContent = type === "text"
? markdownImport.renderToHtml(content, title)
: content;
try {
const { note } = noteService.createNewNote({
parentNoteId,
title,
content: htmlContent,
type,
...(mime ? { mime } : {})
});
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type
};
} catch (err) {
return { error: err instanceof Error ? err.message : "Failed to create note" };
}
}
});
/**
* Read the content of the note the user is currently viewing.
* Created dynamically so it captures the contextNoteId.
*/
export function currentNoteTools(contextNoteId: string) {
return {
get_current_note: tool({
description: "Read the content of the note the user is currently viewing. Call this when the user asks about or refers to their current note.",
inputSchema: z.object({}),
execute: async () => {
const note = becca.getNote(contextNoteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected" };
}
return {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type,
content: getNoteContentForLlm(note)
};
}
})
};
}
export const noteTools = {
search_notes: searchNotes,
read_note: readNote,
update_note_content: updateNoteContent,
append_to_note: appendToNote,
create_note: createNote
};

View File

@@ -0,0 +1,82 @@
/**
* Server-specific LLM Provider types.
* Shared types (LlmMessage, LlmCitation, LlmStreamChunk, LlmChatConfig)
* should be imported from @triliumnext/commons.
*/
import type { LlmChatConfig, LlmMessage } from "@triliumnext/commons";
import type { streamText } from "ai";
/**
* Extended provider config with server-specific options.
*/
export interface LlmProviderConfig extends LlmChatConfig {
maxTokens?: number;
temperature?: number;
}
/**
* Result type from streamText - the AI SDK's unified streaming interface.
*/
export type StreamResult = ReturnType<typeof streamText>;
/**
* Pricing per million tokens for a model.
*/
export interface ModelPricing {
/** Cost per million input tokens in USD */
input: number;
/** Cost per million output tokens in USD */
output: number;
}
/**
* Information about an available model.
*/
export interface ModelInfo {
/** Model identifier (e.g., "claude-sonnet-4-20250514") */
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 */
isDefault?: boolean;
/** Cost multiplier relative to the cheapest model (1x = cheapest) */
costMultiplier?: number;
/** Maximum context window size in tokens */
contextWindow?: number;
/** Whether this is a legacy/older model */
isLegacy?: boolean;
}
export interface LlmProvider {
name: string;
/**
* Create a streaming chat completion.
* Returns the AI SDK StreamResult which is provider-agnostic.
*/
chat(
messages: LlmMessage[],
config: LlmProviderConfig
): StreamResult;
/**
* Get pricing for a model. Returns undefined if pricing is not available.
*/
getModelPricing(model: string): ModelPricing | undefined;
/**
* Get list of available models for this provider.
*/
getAvailableModels(): ModelInfo[];
/**
* Generate a short title summarizing a message.
* Used for auto-renaming chat notes. Should use a fast, cheap model.
*/
generateTitle(firstMessage: string): Promise<string>;
}

View File

@@ -1,2 +1,147 @@
import { special_notes } from "@triliumnext/core";
export default special_notes;
import { SaveLlmChatResponse } from "@triliumnext/commons";
import { t } from "i18next";
import becca from "../becca/becca.js";
import dateNoteService from "./date_notes.js";
import dateUtils from "./date_utils.js";
import hoistedNoteService from "./hoisted_note.js";
import noteService from "./notes.js";
import SearchContext from "./search/search_context.js";
import searchService from "./search/services/search.js";
function createLlmChat() {
const { note } = noteService.createNewNote({
parentNoteId: getMonthlyParentNoteId("_llmChat", "llmChat"),
title: `${t("special_notes.llm_chat_prefix")} ${dateUtils.localNowDateTime()}`,
content: JSON.stringify({
version: 1,
messages: []
}),
type: "llmChat",
mime: "application/json"
});
note.setLabel("iconClass", "bx bx-message-square-dots");
note.setLabel("keepCurrentHoisting");
return note;
}
/**
* Gets the most recently modified LLM chat note.
* Used by sidebar chat to persist conversation across page refreshes.
* Returns null if no chat exists.
*/
function getMostRecentLlmChat() {
// Search for all llmChat notes and return the most recently modified
const results = searchService.searchNotes(
"note.type = llmChat",
new SearchContext({
ancestorNoteId: "_llmChat",
limit: 1,
orderBy: "utcDateModified",
orderDirection: "desc"
})
);
return results.length > 0 ? results[0] : null;
}
/**
* Gets the most recent LLM chat or creates a new one if none exists.
* Used by sidebar chat for persistent conversations.
*/
function getOrCreateLlmChat() {
const existingChat = getMostRecentLlmChat();
if (existingChat) {
return existingChat;
}
return createLlmChat();
}
/**
* Gets a list of recent LLM chat notes.
* Used by sidebar chat history popup.
*/
function getRecentLlmChats(limit: number = 10) {
const results = searchService.searchNotes(
"note.type = llmChat",
new SearchContext({
ancestorNoteId: "_llmChat",
limit,
orderBy: "utcDateModified",
orderDirection: "desc"
})
);
return results.map(note => ({
noteId: note.noteId,
title: note.title,
dateModified: note.utcDateModified
}));
}
function getLlmChatHome() {
const workspaceNote = hoistedNoteService.getWorkspaceNote();
if (!workspaceNote) {
throw new Error("Unable to find workspace note");
}
if (!workspaceNote.isRoot()) {
return workspaceNote.searchNoteInSubtree("#workspaceLlmChatHome") || workspaceNote.searchNoteInSubtree("#llmChatHome") || workspaceNote;
}
const today = dateUtils.localNowDate();
return workspaceNote.searchNoteInSubtree("#llmChatHome") || dateNoteService.getDayNote(today);
}
function saveLlmChat(llmChatNoteId: string | null) {
const llmChatNote = becca.getNote(llmChatNoteId);
if (!llmChatNote) {
throw new Error(`Unable to find LLM chat note ID: ${llmChatNoteId}`);
}
const llmChatHome = getLlmChatHome();
const result = llmChatNote.cloneTo(llmChatHome.noteId);
for (const parentBranch of llmChatNote.getParentBranches()) {
if (parentBranch.parentNote?.hasAncestor("_hidden")) {
parentBranch.markAsDeleted();
}
}
return result satisfies SaveLlmChatResponse;
}
function getMonthlyParentNoteId(rootNoteId: string, prefix: string) {
const month = dateUtils.localNowDate().substring(0, 7);
const labelName = `${prefix}MonthNote`;
let monthNote = searchService.findFirstNoteWithQuery(`#${labelName}="${month}"`, new SearchContext({ ancestorNoteId: rootNoteId }));
if (!monthNote) {
monthNote = noteService.createNewNote({
parentNoteId: rootNoteId,
title: month,
content: "",
isProtected: false,
type: "book"
}).note;
monthNote.addLabel(labelName, month);
}
return monthNote.noteId;
}
export default {
createLlmChat,
getMostRecentLlmChat,
getOrCreateLlmChat,
getRecentLlmChats,
saveLlmChat,
};

View File

@@ -13,8 +13,8 @@
"i18next-http-backend": "3.0.2",
"preact": "10.29.0",
"preact-iso": "2.11.1",
"preact-render-to-string": "6.6.6",
"react-i18next": "17.0.0"
"preact-render-to-string": "6.6.7",
"react-i18next": "17.0.1"
},
"devDependencies": {
"@preact/preset-vite": "2.10.5",

View File

@@ -195,6 +195,14 @@
"header": {
"get-started": "Commencer",
"documentation": "Documentation",
"support-us": "Soutenez-nous"
"support-us": "Soutenez-nous",
"resources": "Ressources"
},
"resources": {
"title": "Ressources",
"icon_packs": "Packs d'icônes",
"download": "Télécharger",
"website": "Site Internet",
"icon_packs_intro": "Élargissez la sélection dicônes disponibles pour vos notes en utilisant un pack dicônes. Pour plus d'informations sur les packs d'icônes, consultez <DocumentationLink>la documentation officielle</DocumentationLink>."
}
}

13
docs/README-fr.md vendored
View File

@@ -98,8 +98,8 @@ Notre documentation est disponible sous plusieurs formats :
connexion plus sécurisée
* [Synchronisation](https://docs.triliumnotes.org/user-guide/setup/synchronization)
avec un serveur de synchronisation auto-hébergé
* there are [3rd party services for hosting synchronisation
server](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
* Il existe des [services tiers pour héberger un serveur de
synchronisation](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
* [Partage](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing)
(publication) de notes sur Internet
* [Cryptage de
@@ -107,10 +107,11 @@ Notre documentation est disponible sous plusieurs formats :
fort avec granularité par note
* Diagrammes d'esquisse, basés sur [Excalidraw](https://excalidraw.com/) (type
de note "canvas"))
* [Relation
maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and
[note/link maps](https://docs.triliumnotes.org/user-guide/note-types/note-map)
for visualizing notes and their relations
* [Cartes de
relations](https://docs.triliumnotes.org/user-guide/note-types/relation-map)
et [cartes de
notes/liens](https://docs.triliumnotes.org/user-guide/note-types/note-map)
pour visualiser les notes et leurs liens
* Cartes mentales, basées sur [Mind Elixir] (https://docs.mind-elixir.com/)
* [Cartes
géographiques](https://docs.triliumnotes.org/user-guide/collections/geomap)

View File

@@ -19,6 +19,7 @@
"desktop:build": "pnpm run --filter desktop build",
"desktop:start-prod": "pnpm run --filter desktop start-prod",
"standalone:start": "pnpm run --filter client-standalone dev",
"desktop:start-prod-no-dir": "pnpm run --filter desktop start-prod-no-dir",
"edit-docs:edit-docs": "pnpm run --filter edit-docs edit-docs",
"edit-docs:build": "pnpm run --filter edit-docs build",
"website:start": "pnpm run --filter website dev",
@@ -137,7 +138,8 @@
"lodash@>=4.0.0 <=4.17.22": ">=4.17.23",
"diff@<4.0.4": ">=4.0.4",
"diff@>=6.0.0 <8.0.3": ">=8.0.3",
"tar@<7.5.7": ">=7.5.7"
"tar@<7.5.7": ">=7.5.7",
"zod@<3.25.76": ">=4.0.0"
},
"ignoredBuiltDependencies": [
"sqlite3"

View File

@@ -21,7 +21,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",

View File

@@ -22,7 +22,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",

View File

@@ -16,7 +16,7 @@
"ckeditor5-premium-features": "47.6.1"
},
"devDependencies": {
"@smithy/middleware-retry": "4.4.44",
"@smithy/middleware-retry": "4.4.45",
"@types/jquery": "4.0.0"
}
}

View File

@@ -16,3 +16,4 @@ export * from "./lib/notes.js";
export * from "./lib/week_utils.js";
export { default as BUILTIN_ATTRIBUTES } from "./lib/builtin_attributes.js";
export * from "./lib/spreadsheet/render_to_html.js";
export * from "./lib/llm_api.js";

View File

@@ -44,7 +44,8 @@ export interface HiddenSubtreeItem {
| "quickSearch"
| "commandPalette"
| "toggleZenMode"
| "mobileTabSwitcher";
| "mobileTabSwitcher"
| "sidebarChat";
command?: keyof typeof Command;
/**
* If set to true, then branches will be enforced to be in the correct place.

View File

@@ -0,0 +1,105 @@
/**
* Shared LLM types for chat integration.
* Used by both client and server for API communication.
*/
/**
* A chat message in the conversation.
*/
export interface LlmMessage {
role: "user" | "assistant" | "system";
content: string;
}
/**
* Citation information extracted from LLM responses.
* May include URL (for web search) or document metadata (for document citations).
*/
export interface LlmCitation {
/** Source URL (typically from web search) */
url?: string;
/** Document or page title */
title?: string;
/** The text that was cited */
citedText?: string;
}
/**
* Configuration for LLM chat requests.
*/
export interface LlmChatConfig {
provider?: string;
model?: string;
systemPrompt?: string;
/** Enable web search tool */
enableWebSearch?: boolean;
/** Enable note tools (search and read notes) */
enableNoteTools?: boolean;
/** Enable extended thinking for deeper reasoning */
enableExtendedThinking?: boolean;
/** Token budget for extended thinking (default: 10000) */
thinkingBudget?: number;
/** Current note context (note ID the user is viewing) */
contextNoteId?: string;
/** The note ID of the chat note (used for auto-renaming on first message) */
chatNoteId?: string;
}
/**
* Pricing per million tokens for a model.
*/
export interface LlmModelPricing {
/** Cost per million input tokens in USD */
input: number;
/** Cost per million output tokens in USD */
output: number;
}
/**
* Information about an available LLM model.
*/
export interface LlmModelInfo {
/** Model identifier (e.g., "claude-sonnet-4-20250514") */
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 */
isDefault?: boolean;
/** Whether this is a legacy/older model */
isLegacy?: boolean;
/** Cost multiplier relative to the cheapest model (1x = cheapest) */
costMultiplier?: number;
/** Maximum context window size in tokens */
contextWindow?: number;
}
/**
* Token usage information from the LLM response.
*/
export interface LlmUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
/** Estimated cost in USD (if available) */
cost?: number;
/** Model identifier used for this response */
model?: string;
}
/**
* Stream chunk types for real-time SSE updates.
* Defines the protocol between server and client.
*/
export type LlmStreamChunk =
| { type: "text"; content: string }
| { type: "thinking"; content: string }
| { type: "tool_use"; toolName: string; toolInput: Record<string, unknown> }
| { type: "tool_result"; toolName: string; result: string; isError?: boolean }
| { type: "citation"; citation: LlmCitation }
| { type: "usage"; usage: LlmUsage }
| { type: "error"; error: string }
| { type: "done" };

View File

@@ -21,7 +21,8 @@ export const NOTE_TYPE_ICONS = {
doc: "bx bxs-file-doc",
contentWidget: "bx bxs-widget",
mindMap: "bx bx-sitemap",
spreadsheet: "bx bx-table"
spreadsheet: "bx bx-table",
llmChat: "bx bx-message-square-dots"
};
const FILE_MIME_MAPPINGS = {

View File

@@ -140,6 +140,10 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
seenCallToActions: string;
experimentalFeatures: string;
// AI / LLM
/** JSON array of configured LLM providers with their API keys */
llmProviders: string;
}
export type OptionNames = keyof OptionDefinitions;

View File

@@ -122,7 +122,8 @@ export const ALLOWED_NOTE_TYPES = [
"webView",
"code",
"mindMap",
"spreadsheet"
"spreadsheet",
"llmChat"
] as const;
export type NoteType = (typeof ALLOWED_NOTE_TYPES)[number];

View File

@@ -215,6 +215,8 @@ export interface ConvertAttachmentToNoteResponse {
export type SaveSqlConsoleResponse = CloneResponse;
export type SaveLlmChatResponse = CloneResponse;
export interface BacklinkCountResponse {
count: number;
}

View File

@@ -79,7 +79,7 @@ CREATE UNIQUE INDEX `IDX_entityChanges_entityName_entityId` ON "entity_changes"
`entityId`
);
CREATE INDEX `IDX_branches_noteId_parentNoteId` ON `branches` (`noteId`,`parentNoteId`);
CREATE INDEX IDX_branches_parentNoteId ON branches (parentNoteId);
CREATE INDEX IDX_branches_parentNoteId_isDeleted_notePosition ON branches (parentNoteId, isDeleted, notePosition);
CREATE INDEX `IDX_notes_title` ON `notes` (`title`);
CREATE INDEX `IDX_notes_type` ON `notes` (`type`);
CREATE INDEX `IDX_notes_dateCreated` ON `notes` (`dateCreated`);
@@ -146,6 +146,13 @@ CREATE INDEX IDX_notes_blobId on notes (blobId);
CREATE INDEX IDX_revisions_blobId on revisions (blobId);
CREATE INDEX IDX_attachments_blobId on attachments (blobId);
CREATE INDEX IDX_entity_changes_isSynced_id ON entity_changes (isSynced, id);
CREATE INDEX IDX_entity_changes_isErased_entityName ON entity_changes (isErased, entityName);
CREATE INDEX IDX_notes_isDeleted_utcDateModified ON notes (isDeleted, utcDateModified);
CREATE INDEX IDX_branches_isDeleted_utcDateModified ON branches (isDeleted, utcDateModified);
CREATE INDEX IDX_attributes_isDeleted_utcDateModified ON attributes (isDeleted, utcDateModified);
CREATE INDEX IDX_attachments_isDeleted_utcDateModified ON attachments (isDeleted, utcDateModified);
CREATE INDEX IDX_attachments_utcDateScheduledForErasureSince ON attachments (utcDateScheduledForErasureSince);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,

View File

@@ -61,7 +61,8 @@ export default class Becca {
name = name.substr(1);
}
return this.attributeIndex[`${type}-${name}`] || [];
const key = `${type}-${name}`;
return Object.hasOwn(this.attributeIndex, key) ? this.attributeIndex[key] : [];
}
findAttributesWithPrefix(type: string, name: string): BAttribute[] {
@@ -89,11 +90,11 @@ export default class Becca {
}
getNote(noteId: string): BNote | null {
return this.notes[noteId];
return Object.hasOwn(this.notes, noteId) ? this.notes[noteId] : null;
}
getNoteOrThrow(noteId: string): BNote {
const note = this.notes[noteId];
const note = Object.hasOwn(this.notes, noteId) ? this.notes[noteId] : null;
if (!note) {
throw new NotFoundError(`Note '${noteId}' doesn't exist.`);
}
@@ -105,7 +106,7 @@ export default class Becca {
const filteredNotes: BNote[] = [];
for (const noteId of noteIds) {
const note = this.notes[noteId];
const note = Object.hasOwn(this.notes, noteId) ? this.notes[noteId] : null;
if (!note) {
if (ignoreMissing) {
@@ -122,7 +123,7 @@ export default class Becca {
}
getBranch(branchId: string): BBranch | null {
return this.branches[branchId];
return Object.hasOwn(this.branches, branchId) ? this.branches[branchId] : null;
}
getBranchOrThrow(branchId: string): BBranch {
@@ -134,7 +135,7 @@ export default class Becca {
}
getAttribute(attributeId: string): BAttribute | null {
return this.attributes[attributeId];
return Object.hasOwn(this.attributes, attributeId) ? this.attributes[attributeId] : null;
}
getAttributeOrThrow(attributeId: string): BAttribute {
@@ -147,7 +148,8 @@ export default class Becca {
}
getBranchFromChildAndParent(childNoteId: string, parentNoteId: string): BBranch | null {
return this.childParentToBranch[`${childNoteId}-${parentNoteId}`];
const key = `${childNoteId}-${parentNoteId}`;
return Object.hasOwn(this.childParentToBranch, key) ? this.childParentToBranch[key] : null;
}
getRevision(revisionId: string): BRevision | null {
@@ -195,7 +197,7 @@ export default class Becca {
}
getOption(name: string): BOption | null {
return this.options[name];
return Object.hasOwn(this.options, name) ? this.options[name] : null;
}
getEtapiTokens(): BEtapiToken[] {
@@ -203,7 +205,7 @@ export default class Becca {
}
getEtapiToken(etapiTokenId: string): BEtapiToken | null {
return this.etapiTokens[etapiTokenId];
return Object.hasOwn(this.etapiTokens, etapiTokenId) ? this.etapiTokens[etapiTokenId] : null;
}
getEntity<T extends AbstractBeccaEntity<T>>(entityName: string, entityId: string): AbstractBeccaEntity<T> | null {
@@ -223,7 +225,8 @@ export default class Becca {
throw new Error(`Unknown entity name '${camelCaseEntityName}' (original argument '${entityName}')`);
}
return (this as any)[camelCaseEntityName][entityId];
const collection = (this as any)[camelCaseEntityName];
return Object.hasOwn(collection, entityId) ? collection[entityId] : null;
}
getRecentNotesFromQuery(query: string, params: string[] = []): BRecentNote[] {

View File

@@ -6,6 +6,27 @@
// Migrations should be kept in descending order, so the latest migration is first.
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
// Add missing database indices for query performance
{
version: 235,
sql: /*sql*/`
CREATE INDEX IF NOT EXISTS IDX_entity_changes_isSynced_id
ON entity_changes (isSynced, id);
CREATE INDEX IF NOT EXISTS IDX_entity_changes_isErased_entityName
ON entity_changes (isErased, entityName);
CREATE INDEX IF NOT EXISTS IDX_notes_isDeleted_utcDateModified
ON notes (isDeleted, utcDateModified);
CREATE INDEX IF NOT EXISTS IDX_branches_isDeleted_utcDateModified
ON branches (isDeleted, utcDateModified);
CREATE INDEX IF NOT EXISTS IDX_attributes_isDeleted_utcDateModified
ON attributes (isDeleted, utcDateModified);
CREATE INDEX IF NOT EXISTS IDX_attachments_isDeleted_utcDateModified
ON attachments (isDeleted, utcDateModified);
DROP INDEX IF EXISTS IDX_branches_parentNoteId;
CREATE INDEX IF NOT EXISTS IDX_branches_parentNoteId_isDeleted_notePosition
ON branches (parentNoteId, isDeleted, notePosition);
`
},
// Migrate aiChat notes to code notes since LLM integration has been removed
{
version: 234,

View File

@@ -104,7 +104,8 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"experimentalFeatures",
"newLayout",
"mfaEnabled",
"mfaMethod"
"mfaMethod",
"llmProviders"
]);
function getOptions() {

View File

@@ -2,7 +2,7 @@ import build from "./build.js";
import packageJson from "../../package.json" with { type: "json" };
import { AppInfo } from "@triliumnext/commons";
const APP_DB_VERSION = 234;
const APP_DB_VERSION = 235;
const SYNC_VERSION = 37;
const CLIPPER_PROTOCOL_VERSION = "1.0";

View File

@@ -10,8 +10,6 @@ import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js";
import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js";
import migrationService from "./migration.js";
import noteService from "./notes.js";
import { getLog } from "./log.js";
import { getSql } from "./sql/index.js";
export const LBTPL_ROOT = "_lbTplRoot";
export const LBTPL_BASE = "_lbTplBase";
@@ -67,6 +65,12 @@ function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenS
type: "doc",
icon: "bx-data"
},
{
id: "_llmChat",
title: t("hidden-subtree.llm-chat-history-title"),
type: "doc",
icon: "bx-message-square-dots"
},
{
id: "_share",
title: t("hidden-subtree.shared-notes-title"),
@@ -248,6 +252,7 @@ function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenS
{ id: "_optionsEtapi", title: t("hidden-subtree.etapi-title"), type: "contentWidget", icon: "bx-extension" },
{ id: "_optionsBackup", title: t("hidden-subtree.backup-title"), type: "contentWidget", icon: "bx-data" },
{ id: "_optionsSync", title: t("hidden-subtree.sync-title"), type: "contentWidget", icon: "bx-wifi" },
{ id: "_optionsLlm", title: t("hidden-subtree.llm-title"), type: "contentWidget", icon: "bx-bot" },
{ id: "_optionsAi", title: "AI Chat", type: "contentWidget", enforceDeleted: true },
{ id: "_optionsOther", title: t("hidden-subtree.other"), type: "contentWidget", icon: "bx-dots-horizontal" },
{ id: "_optionsLocalization", title: t("hidden-subtree.localization"), type: "contentWidget", icon: "bx-world" },

View File

@@ -78,6 +78,13 @@ export default function buildLaunchBarConfig() {
type: "launcher",
command: "toggleZenMode",
icon: "bx bxs-yin-yang"
},
{
id: "_lbSidebarChat",
title: t("hidden-subtree.sidebar-chat-title"),
type: "launcher",
builtinWidget: "sidebarChat",
icon: "bx bx-message-square-dots"
}
];

View File

@@ -15,7 +15,8 @@ const noteTypes = [
{ type: "doc", defaultMime: "" },
{ type: "contentWidget", defaultMime: "" },
{ type: "mindMap", defaultMime: "application/json" },
{ type: "spreadsheet", defaultMime: "application/json" }
{ type: "spreadsheet", defaultMime: "application/json" },
{ type: "llmChat", defaultMime: "application/json" }
];
function getDefaultMimeForNoteType(typeName: string) {

View File

@@ -210,7 +210,10 @@ const defaultOptions: DefaultOption[] = [
]),
isSynced: true
},
{ name: "experimentalFeatures", value: "[]", isSynced: true }
{ name: "experimentalFeatures", value: "[]", isSynced: true },
// AI / LLM
{ name: "llmProviders", value: "[]", isSynced: false }
];
/**

2191
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"prHourlyLimit": 0,
"prConcurrentLimit": 0,
"branchConcurrentLimit": 0,
"minimumReleaseAge": "3 days",
"ignorePaths": [
"**/node_modules/**",
"apps/edit-docs/demo/**",