Compare commits

..

51 Commits

Author SHA1 Message Date
Jin
a820d1d6ef refactor: clean up heavy test case 2026-03-27 22:24:36 +00:00
Jin
69af1f6e82 refactor: use normal Preact lifecycle 2026-03-27 17:45:56 +00:00
Jin
a8408fbc68 Merge branch 'main' into autocomplete 2026-03-27 17:20:23 +00:00
Jin
8828df733a refactor: fix input focus issue 2026-03-27 17:17:31 +00:00
Jin
d1cb56de71 refactor: use Preact for rendering 2026-03-22 13:46:02 +00:00
Jin
e6c5df30d7 refactor: remove redundant code 2026-03-22 13:38:31 +00:00
Jin
4f7cf741ab Merge branch 'main' into autocomplete 2026-03-22 13:36:07 +00:00
JYC333
f65ddb08a0 fix: pnpm lock conflict 2026-03-19 10:53:55 +00:00
JYC333
22507f75bd chore: fix pnpm lock file 2026-03-19 10:34:21 +00:00
Jin
915c49b472 fix: address white space issue 2026-03-19 10:34:21 +00:00
Jin
aa58ad2812 fix: test file error 2026-03-19 10:34:21 +00:00
Jin
17609799da refactor: remove plan file 2026-03-19 10:34:21 +00:00
Jin
f6201d8581 fix: add link dialog enter act correctly 2026-03-19 10:34:21 +00:00
Jin
5b77152fdf refactor: address gemini review 2026-03-19 10:34:21 +00:00
Jin
b419602d74 test: add test for NoteAutocomplete 2026-03-19 10:34:21 +00:00
Jin
2c9a0ed682 test: add test for autocomplete_core 2026-03-19 10:34:21 +00:00
Jin
59ebd0f122 test: add test for attribute_autocomplete 2026-03-19 10:34:21 +00:00
Jin
334c8cbea3 refactor: cleanup unused parts 2026-03-19 10:34:21 +00:00
Jin
5adc79f867 refactor: fix related test 2026-03-19 10:34:21 +00:00
Jin
d1fc4780b7 refactor: remove old autocomplete completely 2026-03-19 10:34:18 +00:00
Jin
99937bd8f4 refactor: fix attribute detail can't save with ctrl+enter directly 2026-03-19 10:33:27 +00:00
Jin
03a9685c96 refactor: remove old autocomplete declare 2026-03-19 10:33:27 +00:00
Jin
39408f2b22 refactor: update related css 2026-03-19 10:33:27 +00:00
Jin
eeb917ea97 refactor: fix enter can't execute action in dialog 2026-03-19 10:33:27 +00:00
Jin
5ea355a587 refactor: avoid xss attack 2026-03-19 10:33:27 +00:00
Jin
e4ad356a02 refactor: fix attribute panel mouse hover behavior 2026-03-19 10:33:27 +00:00
JYC333
ff939071ac refactor: migrate cleanup function 2026-03-19 10:33:27 +00:00
JYC333
3f97516d98 refactor: extract common logic 2026-03-19 10:33:27 +00:00
JYC333
f06dd3cfea refactor: fix missing function def 2026-03-19 10:33:27 +00:00
JYC333
0dee06262b refactor: migrate react part 2026-03-19 10:33:27 +00:00
JYC333
3ac2e2785d refactor: address gemini review 2026-03-19 10:33:27 +00:00
JYC333
9869d29146 refactor: minor cleanup 2026-03-19 10:33:27 +00:00
JYC333
a1b51e1de8 refactor: fix full search 2026-03-19 10:33:27 +00:00
JYC333
cd7fb3d584 refactor: minor fix 2026-03-19 10:33:27 +00:00
JYC333
b92a5d1188 refactor: fix attribute detail autocomplete doesn't catch default value 2026-03-19 10:33:27 +00:00
JYC333
530e606ddb refactor: restore behaviors 2026-03-19 10:33:27 +00:00
JYC333
b6dea44460 refactor: fix behaviour difference 2026-03-19 10:33:26 +00:00
JYC333
a5445d35cb refactor: add back UI for note_autocomplete 2026-03-19 10:33:26 +00:00
JYC333
128fa63e7e refactor: migrate note_autocomplete core function 2026-03-19 10:33:26 +00:00
JYC333
8a4a06e656 refactor: address gemini code review 2026-03-19 10:33:26 +00:00
JYC333
0ca54396aa refactor: migrate label autocomplete 2026-03-19 10:33:26 +00:00
JYC333
3a6606b9ac refactor: limit ctrl+enter action only at when creating reation on relation map 2026-03-19 10:33:26 +00:00
JYC333
1614ccf6f6 refactor: fix cleanup to avoid DOM leaks 2026-03-19 10:33:26 +00:00
JYC333
27a7a157d5 refactor: use ctrl+enter to confirm in relation creation at relation map page 2026-03-19 10:33:26 +00:00
JYC333
d5b496e597 fix: relation definition is not included when create relation in relation map 2026-03-19 10:33:26 +00:00
JYC333
06f2aa1fd8 refactor: clean up old autocomplete implementation 2026-03-19 10:33:26 +00:00
JYC333
3328266cae refactor: migrate relation map 2026-03-19 10:33:26 +00:00
JYC333
6dd5352f40 fix: dropdown menu not follow the input when attribute detail dialog height changed 2026-03-19 10:33:26 +00:00
JYC333
eaf89c63a1 refactor: use headless autocomplete, migrate attribute deatil 2026-03-19 10:33:26 +00:00
JYC333
34ce5ebcbb refactor: add new autocomplete registry 2026-03-19 10:33:26 +00:00
JYC333
c7980f42fe refactor: add plan and package 2026-03-19 10:33:23 +00:00
153 changed files with 4646 additions and 8787 deletions

View File

@@ -186,14 +186,6 @@ When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), main
**Auth note**: ETAPI uses basic auth with tokens. Internal API endpoints trust the frontend.
### Adding New LLM Tools
Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server.
1. Add the tool definition in the appropriate module (`note_tools.ts`, `attribute_tools.ts`, `hierarchy_tools.ts`) or create a new module
2. Each tool needs: `description`, `inputSchema` (Zod), `execute` function, and optionally `mutates: true` for write operations or `needsContext: true` for tools that need the current note context
3. If creating a new module, wrap tools in `defineTools({...})` and add the registry to `allToolRegistries` in `tools/index.ts`
4. Add a client-side friendly name in `apps/client/src/translations/en/translation.json` under `llm.tools.<tool_name>` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous
### Database Migrations
- Add scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
- Update schema in `apps/server/src/assets/db/schema.sql`
@@ -221,12 +213,6 @@ Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/`
10. **Attribute inheritance can be complex** - When checking for labels/relations, use `note.getOwnedAttribute()` for direct attributes or `note.getAttribute()` for inherited ones. Don't assume attributes are directly on the note.
## MCP Server
- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json`
- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`)
- It provides tools for reading, searching, and modifying notes directly from the AI assistant
- Use it to interact with actual note data when developing or debugging note-related features
## TypeScript Configuration
- **Project references**: Monorepo uses TypeScript project references (`tsconfig.json`)
@@ -313,7 +299,6 @@ Trilium provides powerful user scripting capabilities:
- Translation files in `apps/client/src/translations/`
- Use translation system via `t()` function
- Automatic pluralization: Add `_other` suffix to translation keys (e.g., `item` and `item_other` for singular/plural)
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
## Testing Conventions

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"trilium": {
"type": "http",
"url": "http://localhost:8080/mcp"
}
}
}

View File

@@ -118,9 +118,6 @@ Trilium provides powerful user scripting capabilities:
### 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"`)
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
### Security Considerations
- Per-note encryption with granular protected sessions
@@ -128,15 +125,6 @@ Trilium provides powerful user scripting capabilities:
- OpenID and TOTP authentication support
- Sanitization of user-generated content
### 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
## Common Development Tasks
### Adding New Note Types
@@ -152,30 +140,10 @@ Trilium provides powerful user scripting capabilities:
- Create new package in `packages/` following existing plugin structure
- Register in `packages/ckeditor5/src/plugins.ts`
### Adding New LLM Tools
Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server.
1. Add the tool definition in the appropriate module (`note_tools.ts`, `attribute_tools.ts`, `hierarchy_tools.ts`) or create a new module
2. Each tool needs: `description`, `inputSchema` (Zod), `execute` function, and optionally `mutates: true` for write operations or `needsContext: true` for tools that need the current note context
3. If creating a new module, wrap tools in `defineTools({...})` and add the registry to `allToolRegistries` in `tools/index.ts`
4. Add a client-side friendly name in `apps/client/src/translations/en/translation.json` under `llm.tools.<tool_name>` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous
### Database Migrations
- Add migration scripts in `apps/server/src/migrations/`
- Update schema in `apps/server/src/assets/db/schema.sql`
### Server-Side Static Assets
- Static assets (templates, SQL, translations, etc.) go in `apps/server/src/assets/`
- Access them at runtime via `RESOURCE_DIR` from `apps/server/src/services/resource_dir.ts` (e.g. `path.join(RESOURCE_DIR, "llm", "skills", "file.md")`)
- **Do not use `import.meta.url`/`fileURLToPath`** to resolve file paths — the server is bundled into CJS for production, so `import.meta.url` will not point to the source directory
- **Do not use `__dirname` with relative paths** from source files — after bundling, `__dirname` points to the bundle output, not the original source tree
## MCP Server
- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json`
- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`)
- It provides tools for reading, searching, and modifying notes directly from the AI assistant
- Use it to interact with actual note data when developing or debugging note-related features
## Build System Notes
- Uses pnpm for monorepo management
- Vite for fast development builds

View File

@@ -16,7 +16,7 @@
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.33.0",
"devDependencies": {
"@redocly/cli": "2.25.3",
"@redocly/cli": "2.25.1",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",

View File

@@ -16,6 +16,7 @@
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
},
"dependencies": {
"@algolia/autocomplete-js": "1.19.6",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
@@ -28,29 +29,27 @@
"@mermaid-js/layout-elk": "0.2.1",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.9.0",
"@preact/signals": "2.8.2",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@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",
"@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.5.0",
"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",
@@ -59,17 +58,17 @@
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.44",
"katex": "0.16.43",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.5",
"mermaid": "11.13.0",
"mind-elixir": "5.10.0",
"mind-elixir": "5.9.3",
"normalize.css": "8.0.1",
"panzoom": "9.4.4",
"preact": "10.29.0",
"react-i18next": "17.0.1",
"react-i18next": "16.6.6",
"react-window": "2.2.7",
"reveal.js": "6.0.0",
"rrule": "2.8.1",
@@ -90,6 +89,6 @@
"happy-dom": "20.8.9",
"lightningcss": "1.32.0",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "4.0.0"
"vite-plugin-static-copy": "3.4.0"
}
}

View File

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

View File

@@ -1,5 +1,6 @@
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js";
import bundleService from "../services/bundle.js";
import dateNoteService from "../services/date_notes.js";
import froca from "../services/froca.js";
@@ -197,7 +198,7 @@ export default class Entrypoints extends Component {
hideAllPopups() {
if (utils.isDesktop()) {
$(".aa-input").autocomplete("close");
closeAllHeadlessAutocompletes();
}
}

View File

@@ -1,15 +1,16 @@
import Component from "./component.js";
import SpacedUpdate from "../services/spaced_update.js";
import server from "../services/server.js";
import options from "../services/options.js";
import froca from "../services/froca.js";
import treeService from "../services/tree.js";
import NoteContext from "./note_context.js";
import appContext from "./app_context.js";
import Mutex from "../utils/mutex.js";
import linkService from "../services/link.js";
import type { EventData } from "./app_context.js";
import type FNote from "../entities/fnote.js";
import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js";
import froca from "../services/froca.js";
import linkService from "../services/link.js";
import options from "../services/options.js";
import server from "../services/server.js";
import SpacedUpdate from "../services/spaced_update.js";
import treeService from "../services/tree.js";
import Mutex from "../utils/mutex.js";
import type { EventData } from "./app_context.js";
import appContext from "./app_context.js";
import Component from "./component.js";
import NoteContext from "./note_context.js";
interface TabState {
contexts: NoteContext[];
@@ -429,10 +430,7 @@ export default class TabManager extends Component {
}
// close dangling autocompletes after closing the tab
const $autocompleteEl = $(".aa-input");
if ("autocomplete" in $autocompleteEl) {
$autocompleteEl.autocomplete("close");
}
closeAllHeadlessAutocompletes();
// close dangling tooltips
$("body > div.tooltip").remove();

View File

@@ -1,5 +1,3 @@
import "autocomplete.js/index_jquery.js";
import type ElectronRemote from "@electron/remote";
import type Electron from "electron";

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" | "llmChat";
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet";
export interface NotePathRecord {
isArchived: boolean;

View File

@@ -16,17 +16,6 @@ async function initJQuery() {
const $ = (await import("jquery")).default;
window.$ = $;
window.jQuery = $;
// Polyfill removed jQuery methods for autocomplete.js compatibility
($ as any).isArray = Array.isArray;
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
($ as any).isPlainObject = function(obj: any) {
if (obj == null || typeof obj !== 'object') { return false; }
const proto = Object.getPrototypeOf(obj);
if (proto === null) { return true; }
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
return typeof Ctor === 'function' && Ctor === Object;
};
}
async function setupGlob() {

View File

@@ -1,5 +1,3 @@
import "autocomplete.js/index_jquery.js";
import appContext from "./components/app_context.js";
import glob from "./services/glob.js";
import noteAutocompleteService from "./services/note_autocomplete.js";

View File

@@ -8,17 +8,6 @@ async function loadBootstrap() {
}
}
// Polyfill removed jQuery methods for autocomplete.js compatibility
($ as any).isArray = Array.isArray;
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
($ as any).isPlainObject = function(obj: any) {
if (obj == null || typeof obj !== 'object') { return false; }
const proto = Object.getPrototypeOf(obj);
if (proto === null) { return true; }
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
return typeof Ctor === 'function' && Ctor === Object;
};
(window as any).$ = $;
(window as any).jQuery = $;
await loadBootstrap();

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { shouldAutocompleteHandleEnterKey } from "./attribute_autocomplete.js";
describe("attribute autocomplete enter handling", () => {
it("delegates plain Enter when the panel is open and an item is active", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: false },
{ isPanelOpen: true, hasActiveItem: true }
)).toBe(true);
});
it("does not delegate plain Enter when there is no active suggestion", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: false },
{ isPanelOpen: true, hasActiveItem: false }
)).toBe(false);
});
it("does not delegate plain Enter when the panel is closed", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: false },
{ isPanelOpen: false, hasActiveItem: false }
)).toBe(false);
});
it("does not delegate Ctrl+Enter even when an item is active", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: true, metaKey: false },
{ isPanelOpen: true, hasActiveItem: true }
)).toBe(false);
});
it("does not delegate Cmd+Enter even when an item is active", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: true },
{ isPanelOpen: true, hasActiveItem: true }
)).toBe(false);
});
it("ignores non-Enter keys", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "ArrowDown", ctrlKey: false, metaKey: false },
{ isPanelOpen: false, hasActiveItem: false }
)).toBe(true);
});
});

View File

@@ -1,114 +1,450 @@
import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core";
import { createAutocomplete } from "@algolia/autocomplete-core";
import type { AttributeType } from "../entities/fattribute.js";
import { bindAutocompleteInput, createHeadlessPanelController, registerHeadlessAutocompleteCloser, withHeadlessSourceDefaults } from "./autocomplete_core.js";
import server from "./server.js";
interface InitOptions {
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NameItem extends BaseItem {
name: string;
}
export function shouldAutocompleteHandleEnterKey(
event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey">,
{ isPanelOpen, hasActiveItem }: { isPanelOpen: boolean; hasActiveItem: boolean }
) {
if (event.key !== "Enter") {
return true;
}
if (event.ctrlKey || event.metaKey) {
return false;
}
return isPanelOpen && hasActiveItem;
}
interface InitAttributeNameOptions {
/** The <input> element where the user types */
$el: JQuery<HTMLElement>;
attributeType?: AttributeType | (() => AttributeType);
open: boolean;
nameCallback?: () => string;
/** Called when the user selects a value or the panel closes */
onValueChange?: (value: string) => void;
}
/**
* @param $el - element on which to init autocomplete
* @param attributeType - "relation" or "label" or callback providing one of those values as a type of autocompleted attributes
* @param open - should the autocomplete be opened after init?
*/
function initAttributeNameAutocomplete({ $el, attributeType, open }: InitOptions) {
if (!$el.hasClass("aa-input")) {
$el.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
openOnFocus: true,
minLength: 0,
tabAutocomplete: false
},
[
{
displayKey: "name",
// disabling cache is important here because otherwise cache can stay intact when switching between attribute type which will lead to autocomplete displaying attribute names for incorrect attribute type
cache: false,
source: async (term, cb) => {
const type = typeof attributeType === "function" ? attributeType() : attributeType;
// ---------------------------------------------------------------------------
// Instance tracking
// ---------------------------------------------------------------------------
const names = await server.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`);
const result = names.map((name) => ({ name }));
interface ManagedInstance {
autocomplete: CoreAutocompleteApi<NameItem>;
panelEl: HTMLElement;
cleanup: () => void;
}
cb(result);
}
}
]
);
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
$el.on("autocomplete:opened", () => {
if ($el.attr("readonly")) {
$el.autocomplete("close");
function renderItems(
panelEl: HTMLElement,
items: NameItem[],
activeItemId: number | null,
onSelect: (item: NameItem) => void,
onActivate: (index: number) => void,
onDeactivate: () => void
): void {
panelEl.innerHTML = "";
if (items.length === 0) {
panelEl.style.display = "none";
return;
}
const list = document.createElement("ul");
list.className = "aa-core-list";
items.forEach((item, index) => {
const li = document.createElement("li");
li.className = "aa-core-item";
if (index === activeItemId) {
li.classList.add("aa-core-item--active");
}
li.textContent = item.name;
li.addEventListener("mousemove", () => {
if (activeItemId === index) {
return;
}
onActivate(index);
});
}
li.addEventListener("mouseleave", (event) => {
const relatedTarget = event.relatedTarget;
if (relatedTarget instanceof HTMLElement && li.contains(relatedTarget)) {
return;
}
if (open) {
$el.autocomplete("open");
}
onDeactivate();
});
li.addEventListener("mousedown", (e) => {
e.preventDefault(); // prevent input blur
e.stopPropagation();
onSelect(item);
});
list.appendChild(li);
});
panelEl.appendChild(list);
}
async function initLabelValueAutocomplete({ $el, open, nameCallback }: InitOptions) {
if ($el.hasClass("aa-input")) {
// we reinit every time because autocomplete seems to have a bug where it retains state from last
// open even though the value was reset
$el.autocomplete("destroy");
}
// ---------------------------------------------------------------------------
// Attribute name autocomplete — new (autocomplete-core, headless)
// ---------------------------------------------------------------------------
let attributeName = "";
if (nameCallback) {
attributeName = nameCallback();
}
function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) {
const inputEl = $el[0] as HTMLInputElement;
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
autocomplete.setQuery(inputEl.value || "");
};
if (attributeName.trim() === "") {
// Already initialized — just open if requested
if (instanceMap.has(inputEl)) {
if (open) {
const inst = instanceMap.get(inputEl)!;
syncQueryFromInputValue(inst.autocomplete);
inst.autocomplete.setIsOpen(true);
inst.autocomplete.refresh();
}
return;
}
const attributeValues = (await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute }));
const panelController = createHeadlessPanelController({ inputEl });
const { panelEl } = panelController;
if (attributeValues.length === 0) {
return;
}
let isPanelOpen = false;
let hasActiveItem = false;
$el.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
openOnFocus: false, // handled manually
minLength: 0,
tabAutocomplete: false
const autocomplete = createAutocomplete<NameItem>({
openOnFocus: true,
defaultActiveItemId: 0,
shouldPanelOpen() {
return true;
},
[
{
displayKey: "value",
cache: false,
source: async function (term, cb) {
term = term.toLowerCase();
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
getSources({ query }) {
return [
withHeadlessSourceDefaults({
sourceId: "attribute-names",
getItems() {
const type = typeof attributeType === "function" ? attributeType() : attributeType;
return server
.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(query)}`)
.then((names) => names.map((name) => ({ name })));
},
getItemInputValue({ item }) {
return item.name;
},
onSelect({ item }) {
inputEl.value = item.name;
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
},
}),
];
},
cb(filtered);
}
onStateChange({ state }) {
isPanelOpen = state.isOpen;
hasActiveItem = state.activeItemId !== null;
// Render items
const collections = state.collections;
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
const activeId = state.activeItemId ?? null;
if (state.isOpen && items.length > 0) {
renderItems(
panelEl,
items,
activeId,
(item) => {
inputEl.value = item.name;
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
},
(index) => {
autocomplete.setActiveItemId(index);
},
() => {
autocomplete.setActiveItemId(null);
}
);
panelController.startPositioning();
} else {
panelController.hide();
}
]
);
$el.on("autocomplete:opened", () => {
if ($el.attr("readonly")) {
$el.autocomplete("close");
if (!state.isOpen) {
panelController.hide();
}
},
});
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
autocomplete.setIsOpen(false);
panelController.hide();
});
const cleanupInputBindings = bindAutocompleteInput<NameItem>({
inputEl,
autocomplete,
onInput(e, handlers) {
handlers.onChange(e as any);
},
onFocus(e, handlers) {
syncQueryFromInputValue(autocomplete);
handlers.onFocus(e as any);
},
onBlur() {
// Delay to allow mousedown on panel items
setTimeout(() => {
autocomplete.setIsOpen(false);
panelController.hide();
onValueChange?.(inputEl.value);
}, 50);
},
onKeyDown(e, handlers) {
if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) {
return;
}
if (e.key === "Enter") {
// Prevent the enter key from propagating to parent dialogs
// (which might interpret it as "submit" or "save and close")
e.stopPropagation();
}
handlers.onKeyDown(e as any);
}
});
const cleanup = () => {
unregisterGlobalCloser();
cleanupInputBindings();
panelController.destroy();
};
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
if (open) {
$el.autocomplete("open");
syncQueryFromInputValue(autocomplete);
autocomplete.setIsOpen(true);
autocomplete.refresh();
panelController.startPositioning();
}
}
// ---------------------------------------------------------------------------
// Label value autocomplete (headless autocomplete-core)
// ---------------------------------------------------------------------------
interface LabelValueInitOptions {
$el: JQuery<HTMLElement>;
open: boolean;
nameCallback?: () => string;
onValueChange?: (value: string) => void;
}
function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) {
const inputEl = $el[0] as HTMLInputElement;
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
autocomplete.setQuery(inputEl.value || "");
};
if (instanceMap.has(inputEl)) {
if (open) {
const inst = instanceMap.get(inputEl)!;
syncQueryFromInputValue(inst.autocomplete);
inst.autocomplete.setIsOpen(true);
inst.autocomplete.refresh();
}
return;
}
const panelController = createHeadlessPanelController({ inputEl });
const { panelEl } = panelController;
let isPanelOpen = false;
let hasActiveItem = false;
let isSelecting = false;
let cachedAttributeName = "";
let cachedAttributeValues: NameItem[] = [];
const handleSelect = (item: NameItem) => {
isSelecting = true;
inputEl.value = item.name;
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
isSelecting = false;
setTimeout(() => {
// Preserve the legacy contract: several consumers still commit the
// selected value from their existing Enter key handlers instead of
// listening to the autocomplete selection event directly.
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
}));
}, 0);
};
const autocomplete = createAutocomplete<NameItem>({
openOnFocus: true,
defaultActiveItemId: null,
shouldPanelOpen() {
return true;
},
getSources({ query }) {
return [
withHeadlessSourceDefaults({
sourceId: "attribute-values",
async getItems() {
const attributeName = nameCallback ? nameCallback() : "";
if (!attributeName.trim()) {
return [];
}
if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) {
cachedAttributeName = attributeName;
const values = await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`);
cachedAttributeValues = values.map((name) => ({ name }));
}
const q = query.toLowerCase();
return cachedAttributeValues.filter((attr) => attr.name.toLowerCase().includes(q));
},
getItemInputValue({ item }) {
return item.name;
},
onSelect({ item }) {
handleSelect(item);
},
}),
];
},
onStateChange({ state }) {
isPanelOpen = state.isOpen;
hasActiveItem = state.activeItemId !== null;
const collections = state.collections;
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
const activeId = state.activeItemId ?? null;
if (state.isOpen && items.length > 0) {
renderItems(
panelEl,
items,
activeId,
handleSelect,
(index) => {
autocomplete.setActiveItemId(index);
},
() => {
autocomplete.setActiveItemId(null);
}
);
panelController.startPositioning();
} else {
panelController.hide();
}
if (!state.isOpen) {
panelController.hide();
}
},
});
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
autocomplete.setIsOpen(false);
panelController.hide();
});
const cleanupInputBindings = bindAutocompleteInput<NameItem>({
inputEl,
autocomplete,
onInput(e, handlers) {
if (!isSelecting) {
handlers.onChange(e as any);
}
},
onFocus(e, handlers) {
const attributeName = nameCallback ? nameCallback() : "";
if (attributeName !== cachedAttributeName) {
cachedAttributeName = "";
cachedAttributeValues = [];
}
syncQueryFromInputValue(autocomplete);
handlers.onFocus(e as any);
},
onBlur() {
setTimeout(() => {
autocomplete.setIsOpen(false);
panelController.hide();
onValueChange?.(inputEl.value);
}, 50);
},
onKeyDown(e, handlers) {
if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) {
return;
}
if (e.key === "Enter") {
e.stopPropagation();
}
handlers.onKeyDown(e as any);
}
});
const cleanup = () => {
unregisterGlobalCloser();
cleanupInputBindings();
panelController.destroy();
};
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
if (open) {
syncQueryFromInputValue(autocomplete);
autocomplete.setIsOpen(true);
autocomplete.refresh();
panelController.startPositioning();
}
}
export function destroyAutocomplete($el: JQuery<HTMLElement> | HTMLElement) {
const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement;
const instance = instanceMap.get(inputEl);
if (instance) {
instance.cleanup();
instanceMap.delete(inputEl);
}
}
export default {
initAttributeNameAutocomplete,
initLabelValueAutocomplete
destroyAutocomplete,
initLabelValueAutocomplete,
};

View File

@@ -0,0 +1,93 @@
import $ from "jquery";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
showSpy,
hideSpy,
updateDisplayedShortcutsSpy,
saveFocusedElementSpy,
focusSavedElementSpy
} = vi.hoisted(() => ({
showSpy: vi.fn(),
hideSpy: vi.fn(),
updateDisplayedShortcutsSpy: vi.fn(),
saveFocusedElementSpy: vi.fn(),
focusSavedElementSpy: vi.fn()
}));
vi.mock("bootstrap", () => ({
Modal: {
getOrCreateInstance: vi.fn(() => ({
show: showSpy,
hide: hideSpy
}))
}
}));
vi.mock("./keyboard_actions.js", () => ({
default: {
updateDisplayedShortcuts: updateDisplayedShortcutsSpy
}
}));
vi.mock("./focus.js", () => ({
saveFocusedElement: saveFocusedElementSpy,
focusSavedElement: focusSavedElementSpy
}));
import { closeAllHeadlessAutocompletes, registerHeadlessAutocompleteCloser } from "./autocomplete_core.js";
import { openDialog } from "./dialog.js";
describe("headless autocomplete closing", () => {
const unregisterClosers: Array<() => void> = [];
beforeEach(() => {
vi.clearAllMocks();
(window as any).glob = {
...(window as any).glob,
activeDialog: null
};
});
afterEach(() => {
while (unregisterClosers.length > 0) {
unregisterClosers.pop()?.();
}
});
it("closes every registered closer and skips unregistered ones", () => {
const closer1 = vi.fn();
const closer2 = vi.fn();
const closer3 = vi.fn();
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer1));
const unregister2 = registerHeadlessAutocompleteCloser(closer2);
unregisterClosers.push(unregister2);
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer3));
unregister2();
closeAllHeadlessAutocompletes();
expect(closer1).toHaveBeenCalledTimes(1);
expect(closer2).not.toHaveBeenCalled();
expect(closer3).toHaveBeenCalledTimes(1);
});
it("closes registered autocompletes when a dialog finishes hiding", async () => {
const closer = vi.fn();
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer));
const dialogEl = document.createElement("div");
const $dialog = $(dialogEl);
await openDialog($dialog, false);
$dialog.trigger("hidden.bs.modal");
expect(showSpy).toHaveBeenCalledTimes(1);
expect(updateDisplayedShortcutsSpy).toHaveBeenCalledWith($dialog);
expect(saveFocusedElementSpy).toHaveBeenCalledTimes(1);
expect(closer).toHaveBeenCalledTimes(1);
expect(focusSavedElementSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,195 @@
import type { AutocompleteApi, AutocompleteSource, BaseItem } from "@algolia/autocomplete-core";
export const HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR = ".aa-core-panel";
type HeadlessSourceDefaults = Required<Pick<AutocompleteSource<any>, "getItemUrl" | "onActive" | "onResolve">>;
const headlessAutocompleteClosers = new Set<() => void>();
export function withHeadlessSourceDefaults<TSource extends AutocompleteSource<any>>(
source: TSource
): TSource & HeadlessSourceDefaults {
return {
getItemUrl() {
return undefined;
},
onActive() {
// Headless consumers handle highlight side effects themselves.
},
onResolve() {
// Headless consumers resolve and render items manually.
},
...source
} as TSource & HeadlessSourceDefaults;
}
export function registerHeadlessAutocompleteCloser(close: () => void) {
headlessAutocompleteClosers.add(close);
return () => {
headlessAutocompleteClosers.delete(close);
};
}
export function closeAllHeadlessAutocompletes() {
for (const close of Array.from(headlessAutocompleteClosers)) {
close();
}
}
interface HeadlessPanelControllerOptions {
inputEl: HTMLElement;
container?: HTMLElement | null;
className?: string;
containedClassName?: string;
}
export function createHeadlessPanelController({
inputEl,
container,
className = "aa-core-panel",
containedClassName = "aa-core-panel--contained"
}: HeadlessPanelControllerOptions) {
const panelEl = document.createElement("div");
panelEl.className = className;
const isContained = Boolean(container);
if (isContained) {
panelEl.classList.add(containedClassName);
container!.appendChild(panelEl);
} else {
document.body.appendChild(panelEl);
}
panelEl.style.display = "none";
let rafId: number | null = null;
const positionPanel = () => {
if (isContained) {
panelEl.style.position = "static";
panelEl.style.top = "";
panelEl.style.left = "";
panelEl.style.width = "100%";
panelEl.style.display = "block";
return;
}
const rect = inputEl.getBoundingClientRect();
panelEl.style.position = "fixed";
panelEl.style.top = `${rect.bottom}px`;
panelEl.style.left = `${rect.left}px`;
panelEl.style.width = `${rect.width}px`;
panelEl.style.display = "block";
};
const stopPositioning = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
const startPositioning = () => {
if (isContained) {
positionPanel();
return;
}
if (rafId !== null) {
return;
}
const update = () => {
positionPanel();
rafId = requestAnimationFrame(update);
};
update();
};
const hide = () => {
panelEl.style.display = "none";
stopPositioning();
};
const destroy = () => {
hide();
panelEl.remove();
};
return {
panelEl,
hide,
destroy,
startPositioning,
stopPositioning
};
}
type InputHandlers<TItem extends BaseItem> = ReturnType<AutocompleteApi<TItem>["getInputProps"]>;
interface InputBinding<TEvent extends Event = Event> {
type: string;
listener: (event: TEvent) => void;
}
interface BindAutocompleteInputOptions<TItem extends BaseItem> {
inputEl: HTMLInputElement;
autocomplete: AutocompleteApi<TItem>;
onInput?: (event: Event, handlers: InputHandlers<TItem>) => void;
onFocus?: (event: Event, handlers: InputHandlers<TItem>) => void;
onBlur?: (event: Event, handlers: InputHandlers<TItem>) => void;
onKeyDown?: (event: KeyboardEvent, handlers: InputHandlers<TItem>) => void;
extraBindings?: InputBinding[];
}
export function bindAutocompleteInput<TItem extends BaseItem>({
inputEl,
autocomplete,
onInput,
onFocus,
onBlur,
onKeyDown,
extraBindings = []
}: BindAutocompleteInputOptions<TItem>) {
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
const bindings: InputBinding[] = [
{
type: "input",
listener: (event: Event) => {
onInput?.(event, handlers);
}
},
{
type: "focus",
listener: (event: Event) => {
onFocus?.(event, handlers);
}
},
{
type: "blur",
listener: (event: Event) => {
onBlur?.(event, handlers);
}
},
{
type: "keydown",
listener: (event: Event) => {
onKeyDown?.(event as KeyboardEvent, handlers);
}
},
...extraBindings
];
bindings.forEach(({ type, listener }) => {
inputEl.addEventListener(type, listener as EventListener);
});
return () => {
bindings.forEach(({ type, listener }) => {
inputEl.removeEventListener(type, listener as EventListener);
});
};
}

View File

@@ -84,55 +84,6 @@ 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,
@@ -143,9 +94,5 @@ export default {
getMonthNote,
getYearNote,
createSqlConsole,
createSearchNote,
createLlmChat,
getMostRecentLlmChat,
getOrCreateLlmChat,
getRecentLlmChats
createSearchNote
};

View File

@@ -1,9 +1,11 @@
import { Modal } from "bootstrap";
import appContext from "../components/app_context.js";
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import { focusSavedElement, saveFocusedElement } from "./focus.js";
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import { closeAllHeadlessAutocompletes } from "./autocomplete_core.js";
import { focusSavedElement, saveFocusedElement } from "./focus.js";
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
if (closeActDialog) {
@@ -15,10 +17,7 @@ export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog =
Modal.getOrCreateInstance($dialog[0], config).show();
$dialog.on("hidden.bs.modal", () => {
const $autocompleteEl = $(".aa-input");
if ("autocomplete" in $autocompleteEl) {
$autocompleteEl.autocomplete("close");
}
closeAllHeadlessAutocompletes();
if (!glob.activeDialog || glob.activeDialog === $dialog) {
focusSavedElement();

View File

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

View File

@@ -1,9 +1,10 @@
import server from "./server.js";
import appContext from "../components/app_context.js";
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
import type Component from "../components/component.js";
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
import appContext from "../components/app_context.js";
import type Component from "../components/component.js";
import server from "./server.js";
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
const keyboardActionsLoaded = server.get<ActionKeyboardShortcut[]>("keyboard-actions").then((actions) => {
@@ -51,7 +52,10 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
getActionsForScope("window").then((actions) => {
for (const action of actions) {
for (const shortcut of action.effectiveShortcuts ?? []) {
shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
shortcutService.bindGlobalShortcut(shortcut, () => {
const ntxId = appContext.tabManager?.activeNtxId ?? null;
appContext.triggerCommand(action.actionName, { ntxId });
});
}
}
});

View File

@@ -1,110 +0,0 @@
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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
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";
@@ -42,7 +41,6 @@ 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" },
@@ -94,7 +92,6 @@ 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

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

View File

@@ -892,33 +892,6 @@ table.promoted-attributes-in-tooltip th {
opacity: 1;
}
.algolia-autocomplete {
width: calc(100% - 30px);
z-index: 2000 !important;
}
.algolia-autocomplete-container .aa-dropdown-menu {
position: inherit !important;
overflow: auto;
}
.algolia-autocomplete .aa-input,
.algolia-autocomplete .aa-hint {
width: 100%;
}
.algolia-autocomplete .aa-dropdown-menu {
width: 100%;
background-color: var(--main-background-color);
border: 1px solid var(--main-border-color);
border-top: none;
z-index: 2000 !important;
max-height: 500px;
overflow: auto;
padding: 0;
margin: 0;
}
.aa-dropdown-menu .aa-suggestion {
cursor: pointer;
padding: 6px 16px;
@@ -960,6 +933,153 @@ table.promoted-attributes-in-tooltip th {
background-color: var(--active-item-background-color);
}
/* ===== @algolia/autocomplete-core (headless, custom panel) ===== */
.aa-core-panel {
z-index: 10000;
background-color: var(--main-background-color);
border: 1px solid var(--main-border-color);
border-top: none;
max-height: 500px;
overflow: auto;
padding: 0;
margin: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.aa-core-panel.aa-dropdown-menu {
width: 100%;
}
.aa-core-panel--contained {
position: static !important;
border: 0;
background: transparent;
box-shadow: none;
}
.aa-core-list {
list-style: none;
padding: 0;
margin: 0;
}
.aa-core-item {
cursor: pointer;
padding: 7px 16px;
margin: 0;
white-space: normal;
}
.aa-core-item--active {
color: var(--active-item-text-color);
background-color: var(--active-item-background-color);
}
.aa-core-item .note-suggestion {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.aa-core-item .icon,
.aa-core-item .command-icon {
flex-shrink: 0;
line-height: 1.4;
margin-top: 1px;
}
.aa-core-item .text {
min-width: 0;
flex: 1;
}
.aa-core-item .aa-core-primary-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.aa-core-item .search-result-title {
display: block;
min-width: 0;
line-height: 1.35;
word-break: break-word;
font-size: 1.02em;
}
.aa-core-item .search-result-attributes {
display: block;
margin-top: 1px;
font-size: 0.8em;
color: var(--muted-text-color);
opacity: 0.65;
line-height: 1.2;
word-break: break-word;
}
.aa-core-item .search-result-attributes {
padding-inline-start: 14px;
}
.aa-core-item .aa-core-shortcut,
.aa-core-item kbd.command-shortcut {
flex-shrink: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--muted-text-color);
font-family: inherit !important;
font-size: 0.8em;
opacity: 0.85;
}
.aa-core-item .command-suggestion {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
font-size: 0.9em;
}
.aa-core-item .command-content {
flex-grow: 1;
min-width: 0;
}
.aa-core-item .command-name {
font-weight: bold;
line-height: 1.35;
}
.aa-core-item .command-description {
font-size: 0.8em;
line-height: 1.3;
opacity: 0.75;
}
.aa-core-item .search-result-title b,
.aa-core-item .search-result-path b,
.aa-core-item .search-result-attributes b,
.aa-core-item .command-name b,
.aa-core-item .command-description b {
color: var(--admonition-warning-accent-color);
text-decoration: underline;
}
.aa-core-item .aa-core-separator {
padding: 0 2px;
}
.jump-to-note-results .aa-core-panel--contained {
max-height: calc(80vh - 200px);
overflow-y: auto;
overflow-x: hidden;
text-overflow: ellipsis;
}
.help-button {
float: inline-end;
background: none;
@@ -1750,11 +1870,8 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
justify-content: space-between;
align-items: baseline;
font-weight: bold;
color: var(--muted-text-color) !important;
}
#right-pane .card-header-title {
text-transform: uppercase;
color: var(--muted-text-color) !important;
}
#right-pane .card-header-buttons {

View File

@@ -128,8 +128,8 @@
margin-inline: auto;
}
/* The search results list */
.note-detail-empty span.aa-dropdown-menu {
/* The headless autocomplete panel rendered into the empty-note results container */
.note-detail-empty .aa-core-panel--contained {
margin-top: 1em;
border: unset;
}

View File

@@ -1157,9 +1157,7 @@
"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.",
"llm_name": "AI / LLM Chat",
"llm_description": "Enable the AI chat sidebar and LLM chat notes powered by large language models."
"new_layout_description": "Try out the new layout for a more modern look and improved usability. Subject to heavy change in the upcoming releases."
},
"fonts": {
"theme_defined": "Theme defined",
@@ -1601,7 +1599,6 @@
"geo-map": "Geo Map",
"beta-feature": "Beta",
"ai-chat": "AI Chat",
"llm-chat": "AI Chat",
"task-list": "Task List",
"new-feature": "New",
"collections": "Collections",
@@ -1613,49 +1610,6 @@
"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",
@@ -2276,55 +2230,5 @@
"sample_xy": "XY",
"sample_venn": "Venn",
"sample_ishikawa": "Ishikawa"
},
"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",
"mcp_title": "MCP (Model Context Protocol)",
"mcp_enabled": "Enable MCP server",
"mcp_enabled_description": "Expose a Model Context Protocol (MCP) endpoint so that AI coding assistants (e.g. Claude Code, GitHub Copilot) can read and modify your notes. The endpoint is only accessible from localhost.",
"tools": {
"search_notes": "Search notes",
"read_note": "Read note",
"update_note_content": "Update note content",
"append_to_note": "Append to note",
"create_note": "Create note",
"get_current_note": "Read current note",
"get_attributes": "Get attributes",
"get_attribute": "Get attribute",
"set_attribute": "Set attribute",
"delete_attribute": "Delete attribute",
"get_child_notes": "Get child notes",
"get_subtree": "Get subtree",
"load_skill": "Load skill",
"web_search": "Web search",
"note_in_parent": "<Note/> in <Parent/>"
}
}
}

View File

@@ -28,10 +28,7 @@
},
"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",
@@ -446,8 +443,7 @@
"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",
"textarea": "Texte multiligne"
"color_type": "Couleur"
},
"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>",
@@ -663,8 +659,7 @@
"show-cheatsheet": "Afficher l'aide rapide",
"toggle-zen-mode": "Zen Mode",
"new-version-available": "Nouvelle mise à jour disponible",
"download-update": "Obtenir la version {{latestVersion}}",
"search_notes": "Rechercher notes"
"download-update": "Obtenir la version {{latestVersion}}"
},
"zen_mode": {
"button_exit": "Sortir du Zen mode"
@@ -708,8 +703,7 @@
"advanced": "Avancé",
"export_as_image": "Exporter en tant qu'image",
"export_as_image_png": "PNG",
"export_as_image_svg": "SVG (vectoriel)",
"note_map": "Note Carte"
"export_as_image_svg": "SVG (vectoriel)"
},
"onclick_button": {
"no_click_handler": "Le widget bouton '{{componentId}}' n'a pas de gestionnaire de clic défini"
@@ -747,7 +741,7 @@
"button_title": "Exporter le diagramme au format SVG"
},
"relation_map_buttons": {
"create_child_note_title": "Créer une note enfant et l'ajouter à la carte",
"create_child_note_title": "Créer une nouvelle note enfant et l'ajouter à cette carte de relation",
"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"
@@ -763,9 +757,7 @@
"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",
"backlinks": "Rétro-liens",
"content_language_switcher": "Langue du contenu: {{language}}"
"note_revisions": "Révision de la note"
},
"note_icon": {
"change_note_icon": "Changer l'icône de note",
@@ -774,12 +766,7 @@
"filter": "Filtre",
"filter-none": "Toutes les icônes",
"filter-default": "Icônes par défaut",
"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}}"
"icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}"
},
"basic_properties": {
"note_type": "Type de note",
@@ -806,8 +793,7 @@
"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",
"hide_child_notes": "Masquer les notes enfants dans larborescence"
"expand_all_levels": "Développer tous les niveaux"
},
"edited_notes": {
"no_edited_notes_found": "Aucune note modifiée ce jour-là...",
@@ -820,7 +806,7 @@
"file_type": "Type de fichier",
"file_size": "Taille du fichier",
"download": "Télécharger",
"open": "Ouvrir dans une nouvelle fenêtre",
"open": "Ouvrir",
"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é.",
@@ -840,8 +826,7 @@
},
"inherited_attribute_list": {
"title": "Attributs hérités",
"no_inherited_attributes": "Aucun attribut hérité.",
"none": "aucun"
"no_inherited_attributes": "Aucun attribut hérité."
},
"note_info_widget": {
"note_id": "Identifiant de la note",
@@ -918,8 +903,7 @@
"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:",
"option": "option"
"view_options": "Afficher les options:"
},
"similar_notes": {
"title": "Notes similaires",
@@ -1013,7 +997,7 @@
"no_attachments": "Cette note ne contient aucune pièce jointe."
},
"book": {
"no_children_help": "Cette collection ne contient pas de notes enfants, il n'y a donc rien à afficher.",
"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.",
"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é."
},
@@ -1383,8 +1367,7 @@
"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 ?",
"no_results": "Aucun raccourci correspondant à '{{filter}}'"
"confirm_reset": "Voulez-vous vraiment réinitialiser tous les raccourcis clavier par défaut ?"
},
"spellcheck": {
"title": "Vérification orthographique",
@@ -1419,7 +1402,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}}, MIME: {{- mimeType}}",
"role_and_size": "Rôle : {{role}}, Taille : {{size}}",
"link_copied": "Lien de pièce jointe copié dans le presse-papiers.",
"unrecognized_role": "Rôle de pièce jointe « {{role}} » non reconnu."
},
@@ -1470,13 +1453,10 @@
"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 parentales? Cette opération s'applique uniquement aux notes d'image, les autres notes seront ignorées.",
"convert-to-attachment-confirm": "Êtes-vous sûr de vouloir convertir les notes sélectionnées en pièces jointes de leurs notes parentes ?",
"archive": "Archive",
"unarchive": "Désarchiver",
"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"
"open-in-popup": "Modification rapide"
},
"shared_info": {
"shared_publicly": "Cette note est partagée publiquement sur {{- link}}.",
@@ -1505,10 +1485,7 @@
"task-list": "Liste de tâches",
"book": "Collection",
"new-feature": "Nouveau",
"collections": "Collections",
"ai-chat": "Chat IA",
"llm-chat": "Chat AI",
"spreadsheet": "Feuille de calcul"
"collections": "Collections"
},
"protect_note": {
"toggle-on": "Protéger la note",
@@ -1857,7 +1834,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)",
@@ -1996,9 +1973,7 @@
"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.",
"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."
"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."
},
"read-only-info": {
"read-only-note": "Vous consultez actuellement une note en lecture seule.",
@@ -2007,57 +1982,5 @@
},
"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,8 +1718,7 @@
"new-feature": "Nuovo",
"collections": "Collezioni",
"ai-chat": "Chat con IA",
"spreadsheet": "Foglio di calcolo",
"llm-chat": "Chat con IA"
"spreadsheet": "Foglio di calcolo"
},
"protect_note": {
"toggle-on": "Proteggi la nota",
@@ -2052,9 +2051,7 @@
"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.",
"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."
"new_layout_description": "Prova il nuovo layout per un look più moderno e una maggiore usabilità. Soggetto a modifiche significative nelle prossime versioni."
},
"server": {
"unknown_http_error_title": "Errore di comunicazione con il server",
@@ -2248,64 +2245,5 @@
"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

@@ -875,7 +875,7 @@
"print_note": "Imprimare notiță",
"re_render_note": "Reinterpretare notiță",
"save_revision": "Salvează o nouă revizie",
"advanced": "Avansat",
"advanced": "Advansat",
"search_in_note": "Caută în notiță",
"convert_into_attachment_failed": "Nu s-a putut converti notița „{{title}}”.",
"convert_into_attachment_successful": "Notița „{{title}}” a fost convertită în atașament.",

View File

@@ -6,7 +6,6 @@ import type { PrintReport } from "./print";
import type { lint } from "./services/eslint";
import type { Froca } from "./services/froca-interface";
import { Library } from "./services/library_loader";
import { Suggestion } from "./services/note_autocomplete";
import server from "./services/server";
import utils from "./services/utils";
@@ -83,34 +82,7 @@ declare global {
"note-load-progress": CustomEvent<{ progress: number }>;
}
interface AutoCompleteConfig {
appendTo?: HTMLElement | null;
hint?: boolean;
openOnFocus?: boolean;
minLength?: number;
tabAutocomplete?: boolean;
autoselect?: boolean;
dropdownMenuContainer?: HTMLElement;
debug?: boolean;
}
type AutoCompleteCallback = (values: AutoCompleteArg[]) => void;
interface AutoCompleteArg {
name?: string;
value?: string;
notePathTitle?: string;
displayKey?: "name" | "value" | "notePathTitle";
cache?: boolean;
source?: (term: string, cb: AutoCompleteCallback) => void,
templates?: {
suggestion: (suggestion: Suggestion) => string | undefined
}
}
interface JQuery {
autocomplete: (action?: "close" | "open" | "destroy" | "val" | AutoCompleteConfig, args?: AutoCompleteArg[] | string) => JQuery<HTMLElement>;
getSelectedNotePath(): string | undefined;
getSelectedNoteId(): string | null;
setSelectedNotePath(notePath: string | null | undefined);

View File

@@ -8,6 +8,7 @@ import { Dispatch, StateUpdater, useCallback, useEffect, useRef, useState } from
import NoteContext from "../components/note_context";
import FAttribute from "../entities/fattribute";
import FNote from "../entities/fnote";
import attributeAutocompleteService from "../services/attribute_autocomplete";
import { Attribute } from "../services/attribute_parser";
import attributes from "../services/attributes";
import { t } from "../services/i18n";
@@ -36,8 +37,7 @@ interface CellProps {
setCellToFocus(cell: Cell): void;
}
type OnChangeEventData = TargetedEvent<HTMLInputElement | HTMLTextAreaElement, Event> | InputEvent | JQuery.TriggeredEvent<HTMLInputElement, undefined, HTMLInputElement, HTMLInputElement>;
type OnChangeListener = (e: OnChangeEventData) => void | Promise<void>;
type OnChangeEventData = TargetedEvent<HTMLInputElement | HTMLTextAreaElement, Event> | InputEvent;
export default function PromotedAttributes() {
const { note, componentId, noteContext } = useNoteContext();
@@ -201,10 +201,9 @@ function LabelInput(props: CellProps & { inputId: string }) {
}, [ cell, componentId, note, setCells ]);
const extraInputProps: InputHTMLAttributes = {};
useTextLabelAutocomplete(inputId, valueAttr, definition, (e) => {
if (e.currentTarget instanceof HTMLInputElement) {
setDraft(e.currentTarget.value);
}
useTextLabelAutocomplete(inputId, valueAttr, definition, async (value) => {
setDraft(value);
await updateAttribute(note, cell, componentId, value, setCells);
});
// React to model changes.
@@ -260,7 +259,7 @@ function LabelInput(props: CellProps & { inputId: string }) {
className="open-external-link-button"
icon="bx bx-window-open"
title={t("promoted_attributes.open_external_link")}
onClick={(e) => {
onClick={() => {
const inputEl = document.getElementById(inputId) as HTMLInputElement | null;
const url = inputEl?.value;
if (url) {
@@ -415,55 +414,31 @@ function InputButton({ icon, className, title, onClick }: {
);
}
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onChangeListener: OnChangeListener) {
const [ attributeValues, setAttributeValues ] = useState<{ value: string }[] | null>(null);
// Obtain data.
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onValueChange: (value: string) => void) {
useEffect(() => {
if (definition.labelType !== "text") {
return;
}
server.get<string[]>(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((_attributesValues) => {
setAttributeValues(_attributesValues.map((attribute) => ({ value: attribute })));
});
}, [ definition.labelType, valueAttr.name ]);
// Initialize autocomplete.
useEffect(() => {
if (attributeValues?.length === 0) return;
const el = document.getElementById(inputId) as HTMLInputElement | null;
if (!el) return;
if (!el) {
return;
}
const $input = $(el);
$input.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
autoselect: false,
openOnFocus: true,
minLength: 0,
tabAutocomplete: false
},
[
{
displayKey: "value",
source (term, cb) {
term = term.toLowerCase();
attributeAutocompleteService.initLabelValueAutocomplete({
$el: $input,
open: false,
nameCallback: () => valueAttr.name,
onValueChange: (value) => {
onValueChange(value);
}
});
const filtered = (attributeValues ?? []).filter((attr) => attr.value.toLowerCase().includes(term));
cb(filtered);
}
}
]
);
$input.off("autocomplete:selected");
$input.on("autocomplete:selected", onChangeListener);
return () => $input.autocomplete("destroy");
}, [ inputId, attributeValues, onChangeListener ]);
return () => {
attributeAutocompleteService.destroyAutocomplete($input);
};
}, [ definition.labelType, inputId, onValueChange, valueAttr.name ]);
}
async function updateAttribute(note: FNote, cell: Cell, componentId: string, value: string | undefined, setCells: Dispatch<StateUpdater<Cell[] | undefined>>) {

View File

@@ -1,6 +1,7 @@
import appContext from "../../components/app_context.js";
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
import type { Attribute } from "../../services/attribute_parser.js";
import { HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR } from "../../services/autocomplete_core.js";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
import froca from "../../services/froca.js";
@@ -375,13 +376,13 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
}
});
this.$inputName.on("change", () => this.userEditedAttribute());
this.$inputName.on("autocomplete:closed", () => this.userEditedAttribute());
this.$inputName.on("focus", () => {
attributeAutocompleteService.initAttributeNameAutocomplete({
$el: this.$inputName,
attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"),
open: true
open: true,
onValueChange: () => this.userEditedAttribute(),
});
});
@@ -394,12 +395,12 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
}
});
this.$inputValue.on("change", () => this.userEditedAttribute());
this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute());
this.$inputValue.on("focus", () => {
attributeAutocompleteService.initLabelValueAutocomplete({
$el: this.$inputValue,
open: true,
nameCallback: () => String(this.$inputName.val())
nameCallback: () => String(this.$inputName.val()),
onValueChange: () => this.userEditedAttribute(),
});
});
@@ -480,7 +481,9 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
this.$relatedNotesMoreNotes = this.$relatedNotesContainer.find(".related-notes-more-notes");
$(window).on("mousedown", (e) => {
if (!$(e.target).closest(this.$widget[0]).length && !$(e.target).closest(".algolia-autocomplete").length && !$(e.target).closest("#context-menu-container").length) {
if (!$(e.target).closest(this.$widget[0]).length
&& !$(e.target).closest(HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR).length
&& !$(e.target).closest("#context-menu-container").length) {
this.hide();
}
});

View File

@@ -0,0 +1,70 @@
import type { FunctionComponent } from "preact";
import { render } from "preact";
import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type AddLinkDialogTestState,createAddLinkDialogTestState, setupAddLinkDialogMocks } from "./add_link.spec_utils";
describe("AddLinkDialog", () => {
let container: HTMLDivElement;
let AddLinkDialog: FunctionComponent;
let state: AddLinkDialogTestState;
beforeEach(async () => {
vi.resetModules();
state = createAddLinkDialogTestState();
vi.clearAllMocks();
setupAddLinkDialogMocks(state);
({ default: AddLinkDialog } = await import("./add_link"));
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
act(() => {
render(null, container);
});
container.remove();
});
it("submits the selected note when Enter picks an autocomplete suggestion", async () => {
act(() => {
render(<AddLinkDialog />, container);
});
const showDialog = state.triliumEventHandlers.get("showAddLinkDialog");
if (!showDialog) {
throw new Error("showAddLinkDialog handler was not registered");
}
await act(async () => {
showDialog({
text: "",
hasSelection: false,
addLink: state.addLinkSpy
});
});
act(() => {
state.latestNoteAutocompletePropsRef.current.onKeyDownCapture({
key: "Enter",
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
isComposing: false
});
state.latestNoteAutocompletePropsRef.current.onChange({
notePath: "root/target-note"
});
});
await act(async () => {
state.latestModalPropsRef.current.onHidden();
});
expect(state.addLinkSpy).toHaveBeenCalledWith("root/target-note", null);
});
});

View File

@@ -0,0 +1,99 @@
import $ from "jquery";
import type { ComponentChildren } from "preact";
import { vi } from "vitest";
export interface AddLinkDialogTestState {
triliumEventHandlers: Map<string, (payload: any) => void>;
latestModalPropsRef: { current: any };
latestNoteAutocompletePropsRef: { current: any };
addLinkSpy: ReturnType<typeof vi.fn>;
logErrorSpy: ReturnType<typeof vi.fn>;
showRecentNotesSpy: ReturnType<typeof vi.fn>;
setTextSpy: ReturnType<typeof vi.fn>;
}
export function createAddLinkDialogTestState(): AddLinkDialogTestState {
return {
triliumEventHandlers: new Map<string, (payload: any) => void>(),
latestModalPropsRef: { current: null as any },
latestNoteAutocompletePropsRef: { current: null as any },
addLinkSpy: vi.fn(() => Promise.resolve()),
logErrorSpy: vi.fn(),
showRecentNotesSpy: vi.fn(),
setTextSpy: vi.fn()
};
}
export function setupAddLinkDialogMocks(state: AddLinkDialogTestState) {
vi.doMock("../../services/i18n", () => ({
t: (key: string) => key
}));
vi.doMock("../../services/tree", () => ({
default: {
getNoteIdFromUrl: (notePath: string) => notePath.split("/").at(-1),
getNoteTitle: vi.fn(async () => "Target note")
}
}));
vi.doMock("../../services/ws", () => ({
logError: state.logErrorSpy
}));
vi.doMock("../../services/note_autocomplete", () => ({
__esModule: true,
default: {
showRecentNotes: state.showRecentNotesSpy,
setText: state.setTextSpy
}
}));
vi.doMock("../react/react_utils", () => ({
refToJQuerySelector: (ref: { current: HTMLInputElement | null }) => ref.current ? $(ref.current) : $()
}));
vi.doMock("../react/hooks", () => ({
useTriliumEvent: (name: string, handler: (payload: any) => void) => {
state.triliumEventHandlers.set(name, handler);
}
}));
vi.doMock("../react/Modal", () => ({
default: (props: any) => {
state.latestModalPropsRef.current = props;
if (!props.show) {
return null;
}
return (
<form onSubmit={(e) => {
e.preventDefault();
props.onSubmit?.();
}}>
{props.children}
{props.footer}
</form>
);
}
}));
vi.doMock("../react/FormGroup", () => ({
default: ({ children }: { children: ComponentChildren }) => <div>{children}</div>
}));
vi.doMock("../react/Button", () => ({
default: ({ text }: { text: string }) => <button type="submit">{text}</button>
}));
vi.doMock("../react/FormRadioGroup", () => ({
default: () => null
}));
vi.doMock("../react/NoteAutocomplete", () => ({
default: (props: any) => {
state.latestNoteAutocompletePropsRef.current = props;
return <input ref={props.inputRef} />;
}
}));
}

View File

@@ -1,15 +1,17 @@
import type { JSX } from "preact";
import { useEffect,useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import Modal from "../react/Modal";
import Button from "../react/Button";
import FormRadioGroup from "../react/FormRadioGroup";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { useRef, useState, useEffect } from "preact/hooks";
import tree from "../../services/tree";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import tree from "../../services/tree";
import { logError } from "../../services/ws";
import Button from "../react/Button";
import FormGroup from "../react/FormGroup.js";
import { refToJQuerySelector } from "../react/react_utils";
import FormRadioGroup from "../react/FormRadioGroup";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { refToJQuerySelector } from "../react/react_utils";
type LinkType = "reference-link" | "external-link" | "hyper-link";
@@ -26,6 +28,8 @@ export default function AddLinkDialog() {
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
const [ shown, setShown ] = useState(false);
const hasSubmittedRef = useRef(false);
const suggestionRef = useRef<Suggestion | null>(null);
const submitOnSelectionRef = useRef(false);
useTriliumEvent("showAddLinkDialog", opts => {
setOpts(opts);
@@ -85,15 +89,44 @@ export default function AddLinkDialog() {
.trigger("select");
}
function onSubmit() {
hasSubmittedRef.current = true;
function submitSelectedLink(selectedSuggestion: Suggestion | null) {
submitOnSelectionRef.current = false;
hasSubmittedRef.current = Boolean(selectedSuggestion);
if (suggestion) {
// Insertion logic in onHidden because it needs focus.
setShown(false);
} else {
if (!selectedSuggestion) {
logError("No link to add.");
return;
}
// Insertion logic in onHidden because it needs focus.
setShown(false);
}
function onSuggestionChange(nextSuggestion: Suggestion | null) {
suggestionRef.current = nextSuggestion;
setSuggestion(nextSuggestion);
if (submitOnSelectionRef.current && nextSuggestion) {
submitSelectedLink(nextSuggestion);
}
}
function onAutocompleteKeyDownCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
if (e.key !== "Enter" || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing) {
return;
}
submitOnSelectionRef.current = true;
}
function onAutocompleteKeyUpCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
submitOnSelectionRef.current = false;
}
}
function onSubmit() {
submitSelectedLink(suggestionRef.current);
}
const autocompleteRef = useRef<HTMLInputElement>(null);
@@ -109,19 +142,22 @@ export default function AddLinkDialog() {
onSubmit={onSubmit}
onShown={onShown}
onHidden={() => {
submitOnSelectionRef.current = false;
// Insert the link.
if (hasSubmittedRef.current && suggestion && opts) {
if (hasSubmittedRef.current && suggestionRef.current && opts) {
hasSubmittedRef.current = false;
if (suggestion.notePath) {
if (suggestionRef.current.notePath) {
// Handle note link
opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
} else if (suggestion.externalLink) {
opts.addLink(suggestionRef.current.notePath, linkType === "reference-link" ? null : linkTitle);
} else if (suggestionRef.current.externalLink) {
// Handle external link
opts.addLink(suggestion.externalLink, linkTitle, true);
opts.addLink(suggestionRef.current.externalLink, linkTitle, true);
}
}
suggestionRef.current = null;
setSuggestion(null);
setShown(false);
}}
@@ -130,7 +166,9 @@ export default function AddLinkDialog() {
<FormGroup label={t("add_link.note")} name="note">
<NoteAutocomplete
inputRef={autocompleteRef}
onChange={setSuggestion}
onChange={onSuggestionChange}
onKeyDownCapture={onAutocompleteKeyDownCapture}
onKeyUpCapture={onAutocompleteKeyUpCapture}
opts={{
allowExternalLinks: true,
allowCreatingNotes: true

View File

@@ -108,4 +108,4 @@ async function cloneNotesTo(notePath: string, clonedNoteIds: string[], prefix?:
toast.showMessage(t("clone_to.note_cloned", { clonedTitle: clonedNote.title, targetTitle: targetNote.title }));
}
}
}

View File

@@ -1,14 +1,15 @@
import Modal from "../react/Modal";
import Button from "../react/Button";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { t } from "../../services/i18n";
import { useRef, useState } from "preact/hooks";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import appContext from "../../components/app_context";
import commandRegistry from "../../services/command_registry";
import { refToJQuerySelector } from "../react/react_utils";
import { useTriliumEvent } from "../react/hooks";
import { t } from "../../services/i18n";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import shortcutService from "../../services/shortcuts";
import Button from "../react/Button";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { refToJQuerySelector } from "../react/react_utils";
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
@@ -23,14 +24,14 @@ export default function JumpToNoteDialogComponent() {
const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : "");
const actualText = useRef<string>(initialText);
const [ shown, setShown ] = useState(false);
async function openDialog(commandMode: boolean) {
async function openDialog(commandMode: boolean) {
let newMode: Mode;
let initialText = "";
if (commandMode) {
newMode = "commands";
initialText = ">";
initialText = ">";
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
// if you open the Jump To dialog soon after using it previously, it can often mean that you
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
@@ -58,7 +59,7 @@ export default function JumpToNoteDialogComponent() {
if (!suggestion) {
return;
}
setShown(false);
if (suggestion.notePath) {
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
@@ -83,7 +84,7 @@ export default function JumpToNoteDialogComponent() {
$autoComplete
.trigger("focus")
.trigger("select");
// Add keyboard shortcut for full search
shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => {
if (!isCommandMode) {
@@ -91,7 +92,7 @@ export default function JumpToNoteDialogComponent() {
}
});
}
async function showInFullSearch() {
try {
setShown(false);
@@ -126,18 +127,18 @@ export default function JumpToNoteDialogComponent() {
setIsCommandMode(text.startsWith(">"));
}}
onChange={onItemSelected}
/>}
/>}
onShown={onShown}
onHidden={() => setShown(false)}
footer={!isCommandMode && <Button
className="show-in-full-text-button"
text={t("jump_to_note.search_button")}
footer={!isCommandMode && <Button
className="show-in-full-text-button"
text={t("jump_to_note.search_button")}
keyboardShortcut="Ctrl+Enter"
onClick={showInFullSearch}
/>}
show={shown}
>
<div className="algolia-autocomplete-container jump-to-note-results" ref={containerRef}></div>
<div className="jump-to-note-results" ref={containerRef} />
</Modal>
);
}

View File

@@ -75,4 +75,4 @@ async function moveNotesTo(movedBranchIds: string[] | undefined, parentBranchId:
const parentNote = await parentBranch?.getNote();
toast.showMessage(`${t("move_to.move_success_message")} ${parentNote?.title}`);
}
}

View File

@@ -1,11 +1,12 @@
import { useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import Button from "../react/Button";
import Modal from "../react/Modal";
import FormTextBox from "../react/FormTextBox";
import FormGroup from "../react/FormGroup";
import { refToJQuerySelector } from "../react/react_utils";
import FormTextBox from "../react/FormTextBox";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import { refToJQuerySelector } from "../react/react_utils";
// JQuery here is maintained for compatibility with existing code.
interface ShownCallbackData {
@@ -40,7 +41,7 @@ export default function PromptDialog() {
opts.current = newOpts;
setValue(newOpts.defaultValue ?? "");
setShown(true);
})
});
return (
<Modal
@@ -60,7 +61,7 @@ export default function PromptDialog() {
answerRef.current?.select();
}}
onSubmit={() => {
submitValue.current = value;
submitValue.current = answerRef.current?.value || value;
setShown(false);
}}
onHidden={() => {

View File

@@ -1,7 +1,6 @@
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";
@@ -13,7 +12,6 @@ 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";
@@ -100,8 +98,6 @@ 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

@@ -1,24 +0,0 @@
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,7 +5,6 @@ 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";
@@ -29,7 +28,6 @@ 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" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole";
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined);
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
@@ -147,11 +147,5 @@ 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,27 +5,16 @@ 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, titleSuffixProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList<T>) {
export default function FormDropdownList<T>({ values, keyProperty, titleProperty, 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 ? renderTitle(currentValueData) : ""} {...restProps}>
<Dropdown text={currentValueData?.[titleProperty] ?? ""} {...restProps}>
{values.map(item => (
<FormListItem
onClick={() => onChange(item[keyProperty] as string)}
@@ -33,9 +22,9 @@ export default function FormDropdownList<T>({ values, keyProperty, titleProperty
description={descriptionProperty && item[descriptionProperty] as string}
selected={currentValue === item[keyProperty]}
>
{renderTitle(item)}
{item[titleProperty] as string}
</FormListItem>
))}
</Dropdown>
)
}
}

View File

@@ -0,0 +1,152 @@
import $ from "jquery";
import { render } from "preact";
import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
initNoteAutocompleteSpy,
setTextSpy,
clearTextSpy,
destroyAutocompleteSpy
} = vi.hoisted(() => ({
initNoteAutocompleteSpy: vi.fn(($el) => $el),
setTextSpy: vi.fn(),
clearTextSpy: vi.fn(),
destroyAutocompleteSpy: vi.fn()
}));
vi.mock("../../services/i18n", () => ({
t: (key: string) => key
}));
vi.mock("../../services/note_autocomplete", () => ({
__esModule: true,
default: {
initNoteAutocomplete: initNoteAutocompleteSpy,
setText: setTextSpy,
clearText: clearTextSpy,
destroyAutocomplete: destroyAutocompleteSpy
}
}));
import NoteAutocomplete from "./NoteAutocomplete";
describe("NoteAutocomplete", () => {
let container: HTMLDivElement;
let setNoteSpy: ReturnType<typeof vi.fn>;
let getSelectedNoteIdSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement("div");
document.body.appendChild(container);
setNoteSpy = vi.fn(() => Promise.resolve());
getSelectedNoteIdSpy = vi.fn(() => "selected-note-id");
($.fn as any).setNote = setNoteSpy;
($.fn as any).getSelectedNoteId = getSelectedNoteIdSpy;
});
afterEach(() => {
act(() => {
render(null, container);
});
container.remove();
});
it("syncs text props through the headless helper functions", () => {
act(() => {
render(<NoteAutocomplete text="hello" />, container);
});
const input = container.querySelector("input") as HTMLInputElement;
expect(initNoteAutocompleteSpy).toHaveBeenCalledTimes(1);
expect(initNoteAutocompleteSpy.mock.calls[0][0][0]).toBe(input);
expect(setTextSpy).toHaveBeenCalledTimes(1);
expect(setTextSpy.mock.calls[0][0][0]).toBe(input);
expect(setTextSpy).toHaveBeenCalledWith(expect.anything(), "hello");
act(() => {
render(<NoteAutocomplete text="" />, container);
});
expect(clearTextSpy).toHaveBeenCalled();
});
it("syncs noteId props through the jQuery setNote extension", () => {
act(() => {
render(<NoteAutocomplete noteId="note-123" />, container);
});
expect(setNoteSpy).toHaveBeenCalledWith("note-123");
expect(clearTextSpy).not.toHaveBeenCalled();
});
it("forwards autocomplete selection and clear events to consumers", () => {
const onChange = vi.fn();
const noteIdChanged = vi.fn();
act(() => {
render(<NoteAutocomplete onChange={onChange} noteIdChanged={noteIdChanged} />, container);
});
const input = container.querySelector("input") as HTMLInputElement;
const $input = $(input);
const suggestion = { notePath: "root/child-note", noteTitle: "Child note" };
$input.trigger("autocomplete:noteselected", [suggestion]);
expect(onChange).toHaveBeenCalledWith(suggestion);
expect(noteIdChanged).toHaveBeenCalledWith("child-note");
input.value = "";
$input.trigger("change");
expect(onChange).toHaveBeenCalledWith(null);
});
it("forwards onTextChange, onKeyDown and onBlur events", () => {
const onTextChange = vi.fn();
const onKeyDown = vi.fn();
const onBlur = vi.fn();
act(() => {
render(
<NoteAutocomplete
onTextChange={onTextChange}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>,
container
);
});
const input = container.querySelector("input") as HTMLInputElement;
const $input = $(input);
input.value = "typed text";
$input.trigger("input");
$input.trigger($.Event("keydown", { originalEvent: new KeyboardEvent("keydown", { key: "Enter" }) }));
$input.trigger("blur");
expect(onTextChange).toHaveBeenCalledWith("typed text");
expect(onKeyDown).toHaveBeenCalledWith(expect.any(KeyboardEvent));
expect(onBlur).toHaveBeenCalledWith("selected-note-id");
});
it("destroys the autocomplete instance on unmount", () => {
act(() => {
render(<NoteAutocomplete />, container);
});
const input = container.querySelector("input") as HTMLInputElement;
act(() => {
render(null, container);
});
expect(destroyAutocompleteSpy).toHaveBeenCalledWith(input);
});
});

View File

@@ -1,8 +1,9 @@
import { t } from "../../services/i18n";
import { useEffect } from "preact/hooks";
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
import type { RefObject } from "preact";
import type { JSX,RefObject } from "preact";
import type { CSSProperties } from "preact/compat";
import { useEffect } from "preact/hooks";
import { t } from "../../services/i18n";
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
import { useSyncedRef } from "./hooks";
interface NoteAutocompleteProps {
@@ -16,85 +17,118 @@ interface NoteAutocompleteProps {
onChange?: (suggestion: Suggestion | null) => void;
onTextChange?: (text: string) => void;
onKeyDown?: (e: KeyboardEvent) => void;
onKeyDownCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
onKeyUpCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
onBlur?: (newValue: string) => void;
noteIdChanged?: (noteId: string) => void;
noteId?: string;
}
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onBlur }: NoteAutocompleteProps) {
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onKeyDownCapture, onKeyUpCapture, onBlur }: NoteAutocompleteProps) {
const ref = useSyncedRef<HTMLInputElement>(externalInputRef);
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
// clear any event listener added in previous invocation of this function
$autoComplete
.off("autocomplete:noteselected")
.off("autocomplete:commandselected")
const inputEl = ref.current;
const $autoComplete = $(inputEl);
// The headless autocomplete keeps internal state while the user types, so
// initialize it once per mount and drive updates through the helper methods below.
note_autocomplete.initNoteAutocomplete($autoComplete, {
...opts,
container: container?.current
});
if (onTextChange) {
$autoComplete.on("input", () => onTextChange($autoComplete[0].value));
}
if (onKeyDown) {
$autoComplete.on("keydown", (e) => e.originalEvent && onKeyDown(e.originalEvent));
}
if (onBlur) {
$autoComplete.on("blur", () => onBlur($autoComplete.getSelectedNoteId() ?? ""));
}
}, [opts, container?.current]);
// On change event handlers.
return () => {
note_autocomplete.destroyAutocomplete(inputEl);
};
}, []);
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
const inputListener = () => onTextChange?.($autoComplete[0].value);
const keyDownListener = (e) => e.originalEvent && onKeyDown?.(e.originalEvent);
const blurListener = () => onBlur?.($autoComplete.getSelectedNoteId() ?? "");
if (onChange || noteIdChanged) {
const autoCompleteListener = (_e, suggestion) => {
onChange?.(suggestion);
if (noteIdChanged) {
const noteId = suggestion?.notePath?.split("/")?.at(-1);
noteIdChanged(noteId);
}
};
const changeListener = (e) => {
if (!ref.current?.value) {
autoCompleteListener(e, null);
}
};
$autoComplete
.on("autocomplete:noteselected", autoCompleteListener)
.on("autocomplete:externallinkselected", autoCompleteListener)
.on("autocomplete:commandselected", autoCompleteListener)
.on("change", changeListener);
return () => {
$autoComplete
.off("autocomplete:noteselected", autoCompleteListener)
.off("autocomplete:externallinkselected", autoCompleteListener)
.off("autocomplete:commandselected", autoCompleteListener)
.off("change", changeListener);
};
if (onTextChange) {
$autoComplete.on("input", inputListener);
}
}, [opts, container?.current, onChange, noteIdChanged])
if (onKeyDown) {
$autoComplete.on("keydown", keyDownListener);
}
if (onBlur) {
$autoComplete.on("blur", blurListener);
}
return () => {
if (onTextChange) {
$autoComplete.off("input", inputListener);
}
if (onKeyDown) {
$autoComplete.off("keydown", keyDownListener);
}
if (onBlur) {
$autoComplete.off("blur", blurListener);
}
};
}, [onBlur, onKeyDown, onTextChange]);
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
if (!(onChange || noteIdChanged)) {
return;
}
const autoCompleteListener = (_e, suggestion) => {
onChange?.(suggestion);
if (noteIdChanged) {
const noteId = suggestion?.notePath?.split("/")?.at(-1);
noteIdChanged(noteId);
}
};
const changeListener = (e) => {
if (!ref.current?.value) {
autoCompleteListener(e, null);
}
};
$autoComplete
.on("autocomplete:noteselected", autoCompleteListener)
.on("autocomplete:externallinkselected", autoCompleteListener)
.on("autocomplete:commandselected", autoCompleteListener)
.on("change", changeListener);
return () => {
$autoComplete
.off("autocomplete:noteselected", autoCompleteListener)
.off("autocomplete:externallinkselected", autoCompleteListener)
.off("autocomplete:commandselected", autoCompleteListener)
.off("change", changeListener);
};
}, [onChange, noteIdChanged]);
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
if (noteId) {
$autoComplete.setNote(noteId);
} else if (text) {
note_autocomplete.setText($autoComplete, text);
} else {
$autoComplete.setSelectedNotePath("");
$autoComplete.autocomplete("val", "");
ref.current.value = "";
void $autoComplete.setNote(noteId);
return;
}
if (text !== undefined) {
if (text) {
note_autocomplete.setText($autoComplete, text);
} else {
note_autocomplete.clearText($autoComplete);
}
return;
}
note_autocomplete.clearText($autoComplete);
}, [text, noteId]);
return (
@@ -103,6 +137,8 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text,
id={id}
ref={ref}
className="note-autocomplete form-control"
onKeyDownCapture={onKeyDownCapture}
onKeyUpCapture={onKeyUpCapture}
placeholder={placeholder ?? t("add_link.search_note")} />
</div>
);

View File

@@ -1,4 +1,3 @@
import DOMPurify from "dompurify";
import type { CSSProperties, HTMLProps, RefObject } from "preact/compat";
type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
@@ -15,16 +14,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>) {
@@ -40,19 +39,3 @@ 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 | null | undefined,
note: FNote,
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, or if note is not yet available (e.g. lazy creation)
if (data === undefined || !note || note.type !== noteType) return;
// for read only notes
if (data === undefined || 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 || !note) return;
if (!blob) return;
noteSavedDataStore.set(note.noteId, blob.content);
spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content));
}, [ blob ]);

View File

@@ -7,7 +7,6 @@ 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";
@@ -73,7 +72,7 @@ export function NoteTypeDropdownContent({ currentNoteType, currentNoteMime, note
noCodeNotes?: boolean;
}) {
const mimeTypes = useMimeTypes();
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static && (nt.type !== "llmChat" || isExperimentalFeatureEnabled("llm"))), []);
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static), []);
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", "llmChat"].includes(noteType);
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet"].includes(noteType);
const isSearchOrBook = ["search", "book"].includes(noteType);
const isHelpPage = note.noteId.startsWith("_help");
const [syncServerHost] = useTriliumOption("syncServerHost");

View File

@@ -7,7 +7,6 @@ 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";
@@ -20,7 +19,6 @@ 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;
@@ -93,11 +91,6 @@ 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" onClick={e => e.stopPropagation()}>
<div class="card-header-buttons">
{buttons}
{contextMenuItems && (
<ActionButton

View File

@@ -1,113 +0,0 @@
/* 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

@@ -1,335 +0,0 @@
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, useNote, useNoteProperty, useSpacedUpdate } 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.
*
* Unlike the LlmChat type widget which receives a valid FNote from the
* framework, the sidebar creates notes lazily. We use useSpacedUpdate with
* a direct server.put (using the string noteId) instead of useEditorSpacedUpdate
* (which requires an FNote and silently no-ops when it's null).
*/
export default function SidebarChat() {
const [chatNoteId, setChatNoteId] = useState<string | null>(null);
const [recentChats, setRecentChats] = useState<RecentLlmChat[]>([]);
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");
// Refs for stable access in the spaced update callback
const chatNoteIdRef = useRef(chatNoteId);
chatNoteIdRef.current = chatNoteId;
// Use shared chat hook with sidebar-specific options
const chat = useLlmChat(
// onMessagesChange - trigger save
() => spacedUpdate.scheduleUpdate(),
{ defaultEnableNoteTools: true, supportsExtendedThinking: true }
);
const chatRef = useRef(chat);
chatRef.current = chat;
// Save directly via server.put using the string noteId.
// This avoids the FNote dependency that useEditorSpacedUpdate requires.
const spacedUpdate = useSpacedUpdate(async () => {
const noteId = chatNoteIdRef.current;
if (!noteId) return;
const content = chatRef.current.getContent();
try {
await server.put(`notes/${noteId}/data`, {
content: JSON.stringify(content)
});
} catch (err) {
console.error("Failed to save chat:", err);
}
});
// 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);
// Load content
try {
const blob = await server.get<{ content: string }>(`notes/${existingChat.noteId}/blob`);
if (!cancelled && blob?.content) {
const parsed: LlmChatContent = JSON.parse(blob.content);
chatRef.current.loadFromContent(parsed);
}
} catch (err) {
console.error("Failed to load chat content:", err);
}
} else {
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();
// Load the selected chat's content
try {
const blob = await server.get<{ content: string }>(`notes/${noteId}/blob`);
if (blob?.content) {
const parsed: LlmChatContent = JSON.parse(blob.content);
setChatNoteId(noteId);
chatRef.current.loadFromContent(parsed);
}
} catch (err) {
console.error("Failed to load selected chat:", err);
}
}, [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,12 +14,11 @@ 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" | "_optionsLlm";
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetProps) => JSX.Element> = {
_optionsAppearance: AppearanceSettings,
@@ -36,7 +35,6 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetPro
_optionsOther: OtherSettings,
_optionsLocalization: InternationalizationOptions,
_optionsAdvanced: AdvancedSettings,
_optionsLlm: LlmSettings,
_backendLog: BackendLog
}

View File

@@ -50,9 +50,8 @@ body.desktop {
border-radius: 8px;
}
.note-detail-empty-results .aa-dropdown-menu {
border: var(--bs-border-width) solid var(--bs-border-color);
border-top: 0;
.note-detail-empty-results .aa-core-panel--contained {
border: 0;
}
.empty-tab-search label {

View File

@@ -25,8 +25,9 @@ function NoteSearch({ ntxId }: { ntxId: string | null }) {
// Show recent notes.
useEffect(() => {
const $autoComplete = refToJQuerySelector(autocompleteRef);
note_autocomplete.showRecentNotes($autoComplete);
queueMicrotask(() => {
note_autocomplete.showRecentNotes(refToJQuerySelector(autocompleteRef));
});
}, []);
return (

View File

@@ -4,10 +4,9 @@ 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 { 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 { DARK_THEME, default as VanillaMindElixir, MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME } from "mind-elixir";
import { HTMLAttributes, RefObject } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
@@ -26,22 +25,27 @@ interface MindElixirProps {
onChange?: () => void;
}
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")
};
}
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
};
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
const apiRef = useRef<MindElixirInstance>(null);
@@ -157,8 +161,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

@@ -1,238 +0,0 @@
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

@@ -1,315 +0,0 @@
import "./LlmChat.css";
import { Marked } from "marked";
import { useMemo } from "preact/hooks";
import { Trans } from "react-i18next";
import { t } from "../../../services/i18n.js";
import utils from "../../../services/utils.js";
import { NewNoteLink } from "../../react/NoteLink.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;
}
interface ToolCallContext {
/** The primary note the tool operates on or created. */
noteId: string | null;
/** The parent note, shown as "in <parent>" for creation tools. */
parentNoteId: string | null;
/** Plain-text detail (e.g. skill name, search query) when no note ref is available. */
detailText: string | null;
}
/** Try to extract a noteId from the tool call's result JSON. */
function parseResultNoteId(toolCall: ToolCall): string | null {
if (!toolCall.result) return null;
try {
const result = typeof toolCall.result === "string"
? JSON.parse(toolCall.result)
: toolCall.result;
return result?.noteId || null;
} catch {
return null;
}
}
/** Extract contextual info from a tool call for display in the summary. */
function getToolCallContext(toolCall: ToolCall): ToolCallContext {
const input = toolCall.input;
const parentNoteId = (input?.parentNoteId as string) || null;
// For creation tools, the created note ID is in the result.
if (parentNoteId) {
const createdNoteId = parseResultNoteId(toolCall);
if (createdNoteId) {
return { noteId: createdNoteId, parentNoteId, detailText: null };
}
}
const noteId = (input?.noteId as string) || parentNoteId || parseResultNoteId(toolCall);
if (noteId) {
return { noteId, parentNoteId: null, detailText: null };
}
const detailText = (input?.name ?? input?.query) as string | undefined;
return { noteId: null, parentNoteId: null, detailText: detailText || null };
}
function toolCallIcon(toolCall: ToolCall): string {
if (toolCall.isError) return "bx bx-error-circle";
if (toolCall.result) return "bx bx-check";
return "bx bx-loader-alt bx-spin";
}
function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
const classes = [
"llm-chat-tool-call-inline",
toolCall.isError && "llm-chat-tool-call-error"
].filter(Boolean).join(" ");
const { noteId: refNoteId, parentNoteId: refParentId, detailText } = getToolCallContext(toolCall);
return (
<details className={classes}>
<summary className="llm-chat-tool-call-inline-summary">
<span className={toolCallIcon(toolCall)} />
{t(`llm.tools.${toolCall.toolName}`, { defaultValue: toolCall.toolName })}
{detailText && (
<span className="llm-chat-tool-call-detail">{detailText}</span>
)}
{refNoteId && (
<span className="llm-chat-tool-call-note-ref">
{refParentId ? (
<Trans
i18nKey="llm.tools.note_in_parent"
components={{
Note: <NewNoteLink notePath={refNoteId} showNoteIcon noPreview />,
Parent: <NewNoteLink notePath={refParentId} showNoteIcon noPreview />
} as any}
/>
) : (
<NewNoteLink notePath={refNoteId} showNoteIcon noPreview />
)}
</span>
)}
{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

@@ -1,737 +0,0 @@
.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);
}
.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 */
.llm-chat-tool-call-inline {
margin: 0.5rem 0;
border: 1px solid var(--main-border-color);
border-radius: 8px;
font-size: 0.85rem;
}
.llm-chat-tool-call-inline-summary {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
list-style: none;
font-weight: 500;
color: var(--muted-text-color);
}
.llm-chat-tool-call-inline-summary::-webkit-details-marker {
display: none;
}
.llm-chat-tool-call-inline-summary::after {
content: "▾";
margin-left: auto;
font-size: 1em;
transition: transform 0.2s ease;
}
.llm-chat-tool-call-inline[open] .llm-chat-tool-call-inline-summary::after {
transform: rotate(180deg);
}
.llm-chat-tool-call-inline-summary > .bx {
font-size: 1rem;
margin-right: 0.15rem;
}
.llm-chat-tool-call-detail,
.llm-chat-tool-call-note-ref {
font-weight: 400;
color: var(--main-text-color);
}
.llm-chat-tool-call-detail::before,
.llm-chat-tool-call-note-ref::before {
content: "—";
margin-right: 0.35rem;
color: var(--muted-text-color);
}
.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-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;
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

@@ -1,109 +0,0 @@
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.streamingBlocks.length > 0 && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: chat.streamingBlocks,
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

@@ -1,80 +0,0 @@
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

@@ -1,415 +0,0 @@
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;
streamingBlocks: ContentBlock[];
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 [streamingBlocks, setStreamingBlocks] = useState<ContentBlock[]>([]);
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("");
setStreamingBlocks([]);
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(""));
setStreamingBlocks([...contentBlocks]);
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
}
});
setStreamingBlocks([...contentBlocks]);
},
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;
}
}
setStreamingBlocks([...contentBlocks]);
},
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("");
setStreamingBlocks([]);
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("");
setStreamingBlocks([]);
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,
streamingBlocks,
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

@@ -1,125 +0,0 @@
import { useCallback, useMemo, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import Button from "../../react/Button";
import FormCheckbox from "../../react/FormCheckbox";
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, useTriliumOptionBool } 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 className="form-text">{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>
<McpSettings />
</>
);
}
function McpSettings() {
const [mcpEnabled, setMcpEnabled] = useTriliumOptionBool("mcpEnabled");
return (
<OptionsSection title={t("llm.mcp_title")}>
<p className="form-text">{t("llm.mcp_enabled_description")}</p>
<FormCheckbox
name="mcp-enabled"
label={t("llm.mcp_enabled")}
currentValue={mcpEnabled}
onChange={setMcpEnabled}
/>
</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

@@ -1,108 +0,0 @@
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

@@ -19,15 +19,15 @@ if (isDev) {
plugins = [
viteStaticCopy({
targets: assets.map((asset) => ({
src: `src/${asset}/**/*`,
dest: asset,
rename: { stripBase: 2 }
src: `src/${asset}/*`,
dest: asset
}))
}),
viteStaticCopy({
structured: true,
targets: [
{
src: "../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/**/*",
src: "../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
dest: "",
}
]

View File

@@ -15,7 +15,6 @@
"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",
@@ -36,7 +35,7 @@
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "14.0.0",
"electron": "41.1.0",
"electron": "41.0.4",
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-dmg": "7.11.1",

View File

@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "41.1.0",
"electron": "41.0.4",
"fs-extra": "11.3.4"
},
"scripts": {

View File

@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { expect,test } from "@playwright/test";
import App from "../support/app";
const TEXT_NOTE_TITLE = "Text notes";
@@ -32,8 +33,7 @@ test("Open the note in the correct split pane", async ({ page, context }) => {
await noteContent.focus();
// Click the search result in the second split.
await resultsSelector.locator(".aa-suggestion", { hasText: CODE_NOTE_TITLE })
.nth(1).click();
await app.getNoteAutocompleteSuggestion(resultsSelector, CODE_NOTE_TITLE).click();
await expect(split2).toContainText(CODE_NOTE_TITLE);
});
@@ -69,4 +69,4 @@ test("Can directly focus the autocomplete input within the split", async ({ page
await page.waitForTimeout(100);
await expect(autocomplete).toBeFocused();
});
});

View File

@@ -27,6 +27,7 @@ export default class App {
readonly currentNoteSplitContent: Locator;
readonly sidebar: Locator;
private isMobile: boolean = false;
private readonly noteAutocompleteSuggestionSelector = ".aa-suggestion:not(.create-note-action):not(.search-notes-action):not(.command-action):not(.external-link-action)";
constructor(page: Page, context: BrowserContext) {
this.page = page;
@@ -76,12 +77,19 @@ export default class App {
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
await expect(resultsSelector).toContainText(noteTitle);
const suggestionSelector = resultsSelector.locator(".aa-suggestion")
.nth(1); // Select the second one (best candidate), as the first one is "Create a new note"
const suggestionSelector = resultsSelector
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
.first();
await expect(suggestionSelector).toContainText(noteTitle);
await suggestionSelector.click();
}
getNoteAutocompleteSuggestion(resultsContainer: Locator, noteTitle: string) {
return resultsContainer
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
.first();
}
async goToSettings() {
await this.page.locator(".launcher-button.bx-cog").click();
}

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-bullseye-slim AS builder
FROM node:24.14.0-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-bullseye-slim
FROM node:24.14.0-bullseye-slim
# Install only runtime dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-alpine AS builder
FROM node:24.14.0-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-alpine
FROM node:24.14.0-alpine
# Install runtime dependencies
RUN apk add --no-cache su-exec shadow

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-alpine AS builder
FROM node:24.14.0-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-alpine
FROM node:24.14.0-alpine
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-bullseye-slim AS builder
FROM node:24.14.0-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-bullseye-slim
FROM node:24.14.0-bullseye-slim
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -30,11 +30,6 @@
"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",
"@modelcontextprotocol/sdk": "^1.12.1",
"ai": "6.0.142",
"better-sqlite3": "12.8.0",
"html-to-text": "9.0.5",
"node-html-parser": "7.1.0",
@@ -75,7 +70,7 @@
"@types/xml2js": "0.4.14",
"archiver": "7.0.1",
"async-mutex": "0.5.0",
"axios": "1.14.0",
"axios": "1.13.6",
"bindings": "1.5.0",
"bootstrap": "5.3.8",
"chardet": "2.1.1",
@@ -88,14 +83,14 @@
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "5.0.1",
"electron": "41.1.0",
"electron": "41.0.4",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"express": "5.2.1",
"express-http-proxy": "2.1.2",
"express-openid-connect": "2.20.1",
"express-rate-limit": "8.3.2",
"express-openid-connect": "2.20.0",
"express-rate-limit": "8.3.1",
"express-session": "1.19.0",
"file-uri-to-path": "2.0.0",
"fs-extra": "11.3.4",
@@ -131,7 +126,7 @@
"tmp": "0.2.5",
"turnish": "1.8.0",
"unescape": "1.0.1",
"vite": "8.0.3",
"vite": "8.0.2",
"ws": "8.20.0",
"xml2js": "0.6.2",
"yauzl": "3.2.1"

View File

@@ -1,160 +0,0 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
import becca from "../../src/becca/becca.js";
import optionService from "../../src/services/options.js";
import cls from "../../src/services/cls.js";
let app: Application;
let token: string;
const USER = "etapi";
const MCP_ACCEPT = "application/json, text/event-stream";
/** Builds a JSON-RPC 2.0 request body for MCP. */
function jsonRpc(method: string, params?: Record<string, unknown>, id: number = 1) {
return { jsonrpc: "2.0", id, method, params };
}
/** Parses the JSON-RPC response from an SSE response text. */
function parseSseResponse(text: string) {
const dataLine = text.split("\n").find(line => line.startsWith("data: "));
if (!dataLine) {
throw new Error(`No SSE data line found in response: ${text}`);
}
return JSON.parse(dataLine.slice("data: ".length));
}
function mcpPost(app: Application) {
return supertest(app)
.post("/mcp")
.set("Accept", MCP_ACCEPT)
.set("Content-Type", "application/json");
}
function setOption(name: Parameters<typeof optionService.setOption>[0], value: string) {
cls.init(() => optionService.setOption(name, value));
}
describe("mcp", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
describe("option gate", () => {
it("rejects requests when mcpEnabled is false", async () => {
setOption("mcpEnabled", "false");
const response = await mcpPost(app)
.send(jsonRpc("initialize"))
.expect(403);
expect(response.body.error).toContain("disabled");
});
it("rejects requests when mcpEnabled option does not exist", async () => {
const saved = becca.options["mcpEnabled"];
delete becca.options["mcpEnabled"];
try {
const response = await mcpPost(app)
.send(jsonRpc("initialize"))
.expect(403);
expect(response.body.error).toContain("disabled");
} finally {
becca.options["mcpEnabled"] = saved;
}
});
it("accepts requests when mcpEnabled is true", async () => {
setOption("mcpEnabled", "true");
const response = await mcpPost(app)
.send(jsonRpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" }
}));
expect(response.status).not.toBe(403);
});
});
describe("protocol", () => {
beforeAll(() => {
setOption("mcpEnabled", "true");
});
it("initializes and returns server capabilities", async () => {
const response = await mcpPost(app)
.send(jsonRpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" }
}))
.expect(200);
const body = parseSseResponse(response.text);
expect(body.result.serverInfo.name).toBe("trilium-notes");
expect(body.result.capabilities.tools).toBeDefined();
});
it("lists available tools", async () => {
const response = await mcpPost(app)
.send(jsonRpc("tools/list"))
.expect(200);
const body = parseSseResponse(response.text);
const toolNames: string[] = body.result.tools.map((t: { name: string }) => t.name);
expect(toolNames).toContain("search_notes");
expect(toolNames).toContain("read_note");
expect(toolNames).toContain("create_note");
expect(toolNames).not.toContain("get_current_note");
});
});
describe("tools", () => {
let noteId: string;
beforeAll(async () => {
setOption("mcpEnabled", "true");
noteId = await createNote(app, token, "MCP test note content");
});
it("searches for notes", async () => {
const response = await mcpPost(app)
.send(jsonRpc("tools/call", {
name: "search_notes",
arguments: { query: "MCP test note content" }
}))
.expect(200);
const body = parseSseResponse(response.text);
expect(body.result).toBeDefined();
const content = body.result.content;
expect(content.length).toBeGreaterThan(0);
expect(content[0].text).toContain(noteId);
});
it("reads a note by ID", async () => {
const response = await mcpPost(app)
.send(jsonRpc("tools/call", {
name: "read_note",
arguments: { noteId }
}))
.expect(200);
const body = parseSseResponse(response.text);
expect(body.result).toBeDefined();
const parsed = JSON.parse(body.result.content[0].text);
expect(parsed.noteId).toBe(noteId);
expect(parsed.content).toContain("MCP test note content");
});
});
});

View File

@@ -14,7 +14,6 @@ import favicon from "serve-favicon";
import assets from "./routes/assets.js";
import custom from "./routes/custom.js";
import error_handlers from "./routes/error_handlers.js";
import mcpRoutes from "./routes/mcp.js";
import routes from "./routes/routes.js";
import config from "./services/config.js";
import { startScheduledCleanup } from "./services/erase.js";
@@ -56,16 +55,7 @@ export default async function buildApp() {
});
if (!utils.isElectron) {
app.use(compression({
// Skip compression for SSE endpoints to enable real-time streaming
filter: (req, res) => {
// Skip compression for SSE-capable endpoints
if (req.path === "/api/llm-chat/stream" || req.path === "/mcp") {
return false;
}
return compression.filter(req, res);
}
}));
app.use(compression()); // HTTP compression
}
let resourcePolicy = config["Network"]["corsResourcePolicy"] as 'same-origin' | 'same-site' | 'cross-origin' | undefined;
@@ -91,10 +81,6 @@ export default async function buildApp() {
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
// MCP is registered before session/auth middleware — it uses its own
// localhost-only guard and does not require Trilium authentication.
mcpRoutes.register(app);
app.use(express.static(path.join(publicDir, "root")));
app.use(`/manifest.webmanifest`, express.static(path.join(publicAssetsDir, "manifest.webmanifest")));
app.use(`/robots.txt`, express.static(path.join(publicAssetsDir, "robots.txt")));

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_isDeleted_notePosition ON branches (parentNoteId, isDeleted, notePosition);
CREATE INDEX IDX_branches_parentNoteId ON branches (parentNoteId);
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,13 +146,6 @@ 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

@@ -1,156 +0,0 @@
# 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

@@ -1,240 +0,0 @@
# 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

@@ -1,50 +0,0 @@
# 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

@@ -297,8 +297,7 @@
},
"quarterNumber": "Quarter {quarterNumber}",
"special_notes": {
"search_prefix": "Search:",
"llm_chat_prefix": "Chat:"
"search_prefix": "Search:"
},
"test_sync": {
"not-configured": "Sync server host is not configured. Please configure sync first.",
@@ -309,7 +308,6 @@
"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",
@@ -353,13 +351,11 @@
"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",
"sidebar-chat-title": "AI Chat"
"tab-switcher-title": "Tab Switcher"
},
"notes": {
"new-note": "New note",

View File

@@ -1,445 +1,440 @@
{
"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"
}
"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 :"
},
"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"
}
}

View File

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

View File

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

View File

@@ -17,11 +17,6 @@ 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

@@ -6,27 +6,6 @@
// 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

@@ -1,104 +0,0 @@
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

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

View File

@@ -86,29 +86,6 @@ function createSearchNote(req: Request) {
return specialNotesService.createSearchNote(searchString, ancestorNoteId);
}
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) {
return specialNotesService.saveLlmChat(req.body.llmChatNoteId);
}
function getHoistedNote() {
return becca.getNote(cls.getHoistedNoteId());
}
@@ -142,11 +119,6 @@ export default {
saveSqlConsole,
createSearchNote,
saveSearchNote,
createLlmChat,
getMostRecentLlmChat,
getOrCreateLlmChat,
getRecentLlmChats,
saveLlmChat,
createLauncher,
resetLauncher,
createOrUpdateScriptLauncherFromApi

View File

@@ -115,7 +115,6 @@ 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

@@ -1,62 +0,0 @@
/**
* MCP (Model Context Protocol) HTTP route handler.
*
* Mounts the Streamable HTTP transport at `/mcp` with a localhost-only guard.
* No authentication is required — access is restricted to loopback addresses.
*/
import type express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createMcpServer } from "../services/mcp/mcp_server.js";
import log from "../services/log.js";
import optionService from "../services/options.js";
const LOCALHOST_ADDRESSES = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
function mcpGuard(req: express.Request, res: express.Response, next: express.NextFunction) {
if (optionService.getOptionOrNull("mcpEnabled") !== "true") {
res.status(403).json({ error: "MCP server is disabled. Enable it in Options > AI / LLM." });
return;
}
if (!LOCALHOST_ADDRESSES.has(req.socket.remoteAddress ?? "")) {
res.status(403).json({ error: "MCP is only available from localhost" });
return;
}
next();
}
async function handleMcpRequest(req: express.Request, res: express.Response) {
try {
const server = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined // stateless
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
log.error(`MCP request error: ${err}`);
if (!res.headersSent) {
res.status(500).json({ error: "Internal MCP error" });
}
}
}
export function register(app: express.Application) {
app.post("/mcp", mcpGuard, handleMcpRequest);
app.get("/mcp", mcpGuard, handleMcpRequest);
app.delete("/mcp", mcpGuard, handleMcpRequest);
log.info("MCP server registered at /mcp (localhost only)");
}
export default { register };

View File

@@ -145,7 +145,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.triliumResponseHandled) {
if ((res as any).triliumResponseHandled) {
// Just log the request without additional processing
log.request(req, res, Date.now() - start, 0);
return;
@@ -161,7 +161,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.triliumResponseHandled || res.headersSent) {
if ((res as unknown as { triliumResponseHandled?: boolean }).triliumResponseHandled || res.headersSent) {
return;
}

View File

@@ -34,7 +34,6 @@ import fontsRoute from "./api/fonts.js";
import imageRoute from "./api/image.js";
import importRoute from "./api/import.js";
import keysRoute from "./api/keys.js";
import llmChatRoute from "./api/llm_chat.js";
import loginApiRoute from "./api/login.js";
import metricsRoute from "./api/metrics.js";
import noteMapRoute from "./api/note_map.js";
@@ -292,11 +291,6 @@ function register(app: express.Application) {
asyncApiRoute(PST, "/api/special-notes/save-sql-console", specialNotesRoute.saveSqlConsole);
apiRoute(PST, "/api/special-notes/search-note", specialNotesRoute.createSearchNote);
apiRoute(PST, "/api/special-notes/save-search-note", specialNotesRoute.saveSearchNote);
apiRoute(PST, "/api/special-notes/llm-chat", specialNotesRoute.createLlmChat);
apiRoute(GET, "/api/special-notes/most-recent-llm-chat", specialNotesRoute.getMostRecentLlmChat);
apiRoute(GET, "/api/special-notes/get-or-create-llm-chat", specialNotesRoute.getOrCreateLlmChat);
apiRoute(GET, "/api/special-notes/recent-llm-chats", specialNotesRoute.getRecentLlmChats);
apiRoute(PST, "/api/special-notes/save-llm-chat", specialNotesRoute.saveLlmChat);
apiRoute(PST, "/api/special-notes/launchers/:noteId/reset", specialNotesRoute.resetLauncher);
apiRoute(PST, "/api/special-notes/launchers/:parentNoteId/:launcherType", specialNotesRoute.createLauncher);
apiRoute(PUT, "/api/special-notes/api-script-launcher", specialNotesRoute.createOrUpdateScriptLauncherFromApi);
@@ -329,10 +323,6 @@ function register(app: express.Application) {
apiRoute(PST, "/api/script/bundle/:noteId", scriptRoute.getBundle);
apiRoute(GET, "/api/script/relation/:noteId/:relationName", scriptRoute.getRelationBundles);
// 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

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

View File

@@ -71,6 +71,27 @@ function getAttributeNames(type: string, nameLike: string) {
[type, `%${nameLike}%`]
);
// Also include attribute definitions (e.g. 'relation:*' or 'label:*') which are saved as type='label'
if (type === "relation" || type === "label") {
const prefix = `${type}:`;
const defNames = sql.getColumn<string>(
/*sql*/`SELECT DISTINCT name
FROM attributes
WHERE isDeleted = 0
AND type = 'label'
AND name LIKE ?`,
[`${prefix}%${nameLike}%`]
);
for (const dn of defNames) {
if (dn.startsWith(prefix)) {
const stripped = dn.substring(prefix.length);
if (!names.includes(stripped)) {
names.push(stripped);
}
}
}
}
for (const attr of BUILTIN_ATTRIBUTES) {
if (attr.type === type && attr.name.toLowerCase().includes(nameLike) && !names.includes(attr.name)) {
names.push(attr.name);

Some files were not shown because too many files have changed in this diff Show More