mirror of
https://github.com/zadam/trilium.git
synced 2026-04-02 10:10:22 +02:00
Compare commits
321 Commits
experiment
...
feature/mc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c93cd06e | ||
|
|
14e0507689 | ||
|
|
393b90f7be | ||
|
|
47ee5c1d84 | ||
|
|
1cb6f2d351 | ||
|
|
bb72b0cdfc | ||
|
|
ab2467b074 | ||
|
|
2d652523bb | ||
|
|
55df50253f | ||
|
|
d009914ff9 | ||
|
|
5e97222206 | ||
|
|
038705483b | ||
|
|
10c9ba5783 | ||
|
|
a1d008688b | ||
|
|
78a043c536 | ||
|
|
acdc840f17 | ||
|
|
63d4b8894b | ||
|
|
23ccbf9642 | ||
|
|
a5793ff768 | ||
|
|
a84e2f72c3 | ||
|
|
ba90a1c396 | ||
|
|
465927e730 | ||
|
|
74f3c14a62 | ||
|
|
2eb40c7b42 | ||
|
|
457c5f85af | ||
|
|
c6ef3d774a | ||
|
|
8b5b32fecb | ||
|
|
819c9a7506 | ||
|
|
4b3ef50d4b | ||
|
|
bc945c5196 | ||
|
|
57ea3c576e | ||
|
|
450e15f558 | ||
|
|
a66ef977a0 | ||
|
|
96a474adc1 | ||
|
|
1fe22aeef1 | ||
|
|
a97897527e | ||
|
|
86bbb4d885 | ||
|
|
041f8314ab | ||
|
|
dffdeff798 | ||
|
|
6f08dc3ada | ||
|
|
07e1b86586 | ||
|
|
2deda8947e | ||
|
|
adb9532d1b | ||
|
|
a2959342a9 | ||
|
|
f528833232 | ||
|
|
a6b8785341 | ||
|
|
6e7a14fb3e | ||
|
|
708180a037 | ||
|
|
04efa2742c | ||
|
|
0e2c96d544 | ||
|
|
a45c1818a5 | ||
|
|
f04f47d17a | ||
|
|
cabce14a49 | ||
|
|
5f669684c4 | ||
|
|
4d169809bd | ||
|
|
2929d64fa0 | ||
|
|
20311d31f6 | ||
|
|
c13b68ef42 | ||
|
|
8eff623b67 | ||
|
|
f4b9207379 | ||
|
|
90930e19e7 | ||
|
|
8c0dacd6d7 | ||
|
|
c617bea45a | ||
|
|
bac25c9173 | ||
|
|
acfc3f617e | ||
|
|
4c6aa3baf1 | ||
|
|
ed2d72c008 | ||
|
|
3cb82c58a1 | ||
|
|
d87e3cb24d | ||
|
|
8a4c46c40b | ||
|
|
5f3dcdb7e5 | ||
|
|
8964c316b8 | ||
|
|
230f682a27 | ||
|
|
8f25d048df | ||
|
|
90fcf3153c | ||
|
|
069c4cf5c4 | ||
|
|
f10e55ad71 | ||
|
|
a934c7842b | ||
|
|
a2b6bc0493 | ||
|
|
24e418bf7c | ||
|
|
3fc3ef4ea8 | ||
|
|
952d6b9851 | ||
|
|
841c58ca8c | ||
|
|
41164add15 | ||
|
|
f4858d3684 | ||
|
|
be60479122 | ||
|
|
948f160d14 | ||
|
|
768c733f92 | ||
|
|
1a02be7c91 | ||
|
|
ac75f6f7a6 | ||
|
|
b2befb4feb | ||
|
|
3e49399f82 | ||
|
|
eaaaf3effd | ||
|
|
f2cd1be3af | ||
|
|
b4fcf41420 | ||
|
|
5feccae2a0 | ||
|
|
d28318005d | ||
|
|
fcf39d7786 | ||
|
|
5e9fc614d7 | ||
|
|
a860803cc4 | ||
|
|
c40f5953fa | ||
|
|
241282296e | ||
|
|
8a8143167f | ||
|
|
12797293f0 | ||
|
|
af0eb9551a | ||
|
|
8a492450da | ||
|
|
f3cb356b2b | ||
|
|
8ea1b7afba | ||
|
|
911c1bdd0c | ||
|
|
41f3274c7e | ||
|
|
0fc62dda78 | ||
|
|
e482c911c4 | ||
|
|
0e59126c52 | ||
|
|
abbe6437a9 | ||
|
|
f2d67d4128 | ||
|
|
7c9e02996e | ||
|
|
dc560edb7c | ||
|
|
f7bbcee386 | ||
|
|
2182d4b440 | ||
|
|
c43e10c4af | ||
|
|
25037324ab | ||
|
|
b8f9916d13 | ||
|
|
ed8b9cc943 | ||
|
|
efbe7e0a21 | ||
|
|
46dd500d37 | ||
|
|
261c95fb06 | ||
|
|
41a122f722 | ||
|
|
490406e12a | ||
|
|
d12677094d | ||
|
|
3c69792744 | ||
|
|
395e79adbf | ||
|
|
d5e56d8e29 | ||
|
|
e4c4873aa7 | ||
|
|
293da1d4ef | ||
|
|
d1c206a05a | ||
|
|
37b370511f | ||
|
|
734ef5533a | ||
|
|
0eb9b9fdac | ||
|
|
7817890cfe | ||
|
|
23dbedd139 | ||
|
|
2c8e2251fa | ||
|
|
4c27ed9997 | ||
|
|
d2fd1362c0 | ||
|
|
45e57f0d5e | ||
|
|
660facea96 | ||
|
|
9fa2e940d6 | ||
|
|
0ffcfb8f43 | ||
|
|
ad1b3df74e | ||
|
|
0ccf10bbbb | ||
|
|
59c007e801 | ||
|
|
0654bc1049 | ||
|
|
9fabefc847 | ||
|
|
e70ded0be1 | ||
|
|
16806275e0 | ||
|
|
e8214c3aae | ||
|
|
3a8e148301 | ||
|
|
a0b546614f | ||
|
|
5fcea86b94 | ||
|
|
d8c00ed6c0 | ||
|
|
863e68ec88 | ||
|
|
046ee343dc | ||
|
|
2db9e376d5 | ||
|
|
9458128ad6 | ||
|
|
89638e3f56 | ||
|
|
8d492d7d4b | ||
|
|
246c561b64 | ||
|
|
88295f2462 | ||
|
|
d2d4e1cbac | ||
|
|
261e5b59e0 | ||
|
|
fa7ec01329 | ||
|
|
4c4a29f9cf | ||
|
|
9ddcaf4552 | ||
|
|
c806a99fbc | ||
|
|
ad91d360ce | ||
|
|
cf8d7cd71f | ||
|
|
f370799b1d | ||
|
|
f8655b5de4 | ||
|
|
ed3a5778d0 | ||
|
|
19d213059f | ||
|
|
276a802ab2 | ||
|
|
e756ded89f | ||
|
|
b551f0fe2d | ||
|
|
f6e8bdb0fd | ||
|
|
9029ea8085 | ||
|
|
d61ade9fe9 | ||
|
|
aa1fe549c7 | ||
|
|
e3701bbcb4 | ||
|
|
fb7fc4bf0c | ||
|
|
dc50ca157d | ||
|
|
ff2e775b5e | ||
|
|
584d48c5ab | ||
|
|
25df43b0be | ||
|
|
1af1fcd148 | ||
|
|
516f9aad45 | ||
|
|
79a420de0f | ||
|
|
ac213b6664 | ||
|
|
ff2d74029a | ||
|
|
31ac1d3f2d | ||
|
|
2c32382ca6 | ||
|
|
0d94c20deb | ||
|
|
9904df1611 | ||
|
|
2d945d4fb2 | ||
|
|
c1f9a22bf3 | ||
|
|
22e2e2339e | ||
|
|
b6435bbfc9 | ||
|
|
63387cb958 | ||
|
|
a8d104ec57 | ||
|
|
10377b527f | ||
|
|
4413566e14 | ||
|
|
6c295611cc | ||
|
|
c1c98a6955 | ||
|
|
6e222bb901 | ||
|
|
82b8601e0b | ||
|
|
47e515bc77 | ||
|
|
eef35c3a5f | ||
|
|
a18d0484c5 | ||
|
|
4eaa3d7ac1 | ||
|
|
ad24cf9ab9 | ||
|
|
5467d7719d | ||
|
|
875b3a3f9a | ||
|
|
4ab6a66c75 | ||
|
|
53e157567d | ||
|
|
5725680d3a | ||
|
|
07fe884fd8 | ||
|
|
8d57a593d8 | ||
|
|
fb9f33b9ff | ||
|
|
2c690d4dd2 | ||
|
|
7db7dc287f | ||
|
|
dece273c2b | ||
|
|
bf7449bc90 | ||
|
|
6f3c9e2883 | ||
|
|
49248a636a | ||
|
|
f51b0eb4de | ||
|
|
f0d06815ec | ||
|
|
070701ee9e | ||
|
|
57fefaae1d | ||
|
|
1d109f592b | ||
|
|
29b01c3fe6 | ||
|
|
6cd263a897 | ||
|
|
c9ca1de271 | ||
|
|
c369ba416c | ||
|
|
4b3d923d29 | ||
|
|
64c3d0b36d | ||
|
|
0fdc3590dc | ||
|
|
26fd6a573d | ||
|
|
59d8961111 | ||
|
|
9b733849a9 | ||
|
|
133b847b15 | ||
|
|
ecdbed6bac | ||
|
|
d1deccc23c | ||
|
|
c71d8a87b9 | ||
|
|
0614d92597 | ||
|
|
9ab7e8e2b7 | ||
|
|
0a5543cc72 | ||
|
|
6d000d7b7c | ||
|
|
ac4ca16e85 | ||
|
|
e248d93e29 | ||
|
|
acd786da67 | ||
|
|
ef19d6260c | ||
|
|
638e1ebd1d | ||
|
|
0c5efc3dcb | ||
|
|
a774218429 | ||
|
|
e305be9e75 | ||
|
|
f267dd5fc1 | ||
|
|
6ba736b83f | ||
|
|
5eb8715295 | ||
|
|
7654be5132 | ||
|
|
3f4358a422 | ||
|
|
b3ca412bbd | ||
|
|
d1f60840a2 | ||
|
|
a337ace856 | ||
|
|
0b6f6dee7f | ||
|
|
93f1743432 | ||
|
|
3fb4ab1a31 | ||
|
|
8970d02404 | ||
|
|
b671aa6204 | ||
|
|
7ffb8b0202 | ||
|
|
6564ea2738 | ||
|
|
0a673d2f1b | ||
|
|
05eea0d1f1 | ||
|
|
1215fbf3e1 | ||
|
|
ea206116cb | ||
|
|
7d87c89668 | ||
|
|
b0431f2338 | ||
|
|
81f02209ea | ||
|
|
124d456c60 | ||
|
|
76fc9eaeb0 | ||
|
|
a4b7f54c64 | ||
|
|
53192d202d | ||
|
|
6896ed2c70 | ||
|
|
5a96b9c48d | ||
|
|
6113bfc57f | ||
|
|
9d7bc20f26 | ||
|
|
79788937b9 | ||
|
|
66873f16f2 | ||
|
|
532e001ef0 | ||
|
|
17991bf31f | ||
|
|
2b21b1f75e | ||
|
|
dae1f9302c | ||
|
|
33365cdaf1 | ||
|
|
3ac66ffe72 | ||
|
|
81baf13720 | ||
|
|
e0e96350d6 | ||
|
|
c539c21ced | ||
|
|
3f7f6cf982 | ||
|
|
271d87ae33 | ||
|
|
533a77e606 | ||
|
|
77cf2d4dd9 | ||
|
|
890cb247c1 | ||
|
|
8d7f4dd0fa | ||
|
|
00c4933344 | ||
|
|
cd9b46e1c7 | ||
|
|
b356b355ca | ||
|
|
d1aebb7bb0 | ||
|
|
6cbb595ae8 | ||
|
|
fcf238bc35 | ||
|
|
8c82468ecc | ||
|
|
965905ce00 | ||
|
|
ed280775bd | ||
|
|
8834899012 | ||
|
|
1f0fa57218 |
15
.github/copilot-instructions.md
vendored
15
.github/copilot-instructions.md
vendored
@@ -186,6 +186,14 @@ 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`
|
||||
@@ -213,6 +221,12 @@ When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), main
|
||||
|
||||
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`)
|
||||
@@ -299,6 +313,7 @@ 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
|
||||
|
||||
|
||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"trilium": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:8080/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
32
CLAUDE.md
32
CLAUDE.md
@@ -118,6 +118,9 @@ 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
|
||||
@@ -125,6 +128,15 @@ 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
|
||||
@@ -140,10 +152,30 @@ 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
|
||||
|
||||
@@ -14,15 +14,15 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.24.0",
|
||||
"@redocly/cli": "2.25.3",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.4",
|
||||
"js-yaml": "4.1.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"typedoc": "0.28.17",
|
||||
"typedoc": "0.28.18",
|
||||
"typedoc-plugin-missing-exports": "4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,53 +24,52 @@
|
||||
"@fullcalendar/multimonth": "6.1.20",
|
||||
"@fullcalendar/rrule": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@lexical/react": "0.42.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.2.1",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.8.2",
|
||||
"@preact/signals": "2.9.0",
|
||||
"@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.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",
|
||||
"@univerjs/preset-sheets-conditional-formatting": "0.19.0",
|
||||
"@univerjs/preset-sheets-core": "0.19.0",
|
||||
"@univerjs/preset-sheets-data-validation": "0.19.0",
|
||||
"@univerjs/preset-sheets-filter": "0.19.0",
|
||||
"@univerjs/preset-sheets-find-replace": "0.19.0",
|
||||
"@univerjs/preset-sheets-note": "0.19.0",
|
||||
"@univerjs/preset-sheets-sort": "0.19.0",
|
||||
"@univerjs/presets": "0.19.0",
|
||||
"@zumer/snapdom": "2.7.0",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
"clsx": "2.1.1",
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"dompurify": "3.3.3",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.2",
|
||||
"globals": "17.4.0",
|
||||
"i18next": "25.8.18",
|
||||
"i18next": "25.10.10",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.39",
|
||||
"katex": "0.16.44",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"lexical": "0.42.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.4",
|
||||
"marked": "17.0.5",
|
||||
"mermaid": "11.13.0",
|
||||
"mind-elixir": "5.9.3",
|
||||
"mind-elixir": "5.10.0",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"panzoom": "9.4.4",
|
||||
"preact": "10.29.0",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-i18next": "17.0.1",
|
||||
"react-window": "2.2.7",
|
||||
"reveal.js": "6.0.0",
|
||||
"rrule": "2.8.1",
|
||||
@@ -88,9 +87,9 @@
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"happy-dom": "20.8.4",
|
||||
"happy-dom": "20.8.9",
|
||||
"lightningcss": "1.32.0",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.3.0"
|
||||
"vite-plugin-static-copy": "4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@ type EventMappings = {
|
||||
contentSafeMarginChanged: {
|
||||
top: number;
|
||||
noteContext: NoteContext;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export type EventListener<T extends EventNames> = {
|
||||
|
||||
@@ -18,7 +18,7 @@ const RELATION = "relation";
|
||||
* end user. Those types should be used only for checking against, they are
|
||||
* not for direct use.
|
||||
*/
|
||||
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet";
|
||||
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet" | "llmChat";
|
||||
|
||||
export interface NotePathRecord {
|
||||
isArchived: boolean;
|
||||
|
||||
@@ -39,7 +39,6 @@ export interface MenuCommandItem<T> {
|
||||
title: string;
|
||||
command?: T;
|
||||
type?: string;
|
||||
mime?: string;
|
||||
/**
|
||||
* The icon to display in the menu item.
|
||||
*
|
||||
|
||||
@@ -288,7 +288,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
return items.filter((row) => row !== null) as MenuItem<TreeCommandNames>[];
|
||||
}
|
||||
|
||||
async selectMenuItemHandler({ command, type, mime, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
|
||||
async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
|
||||
const notePath = treeService.getNotePath(this.node);
|
||||
|
||||
if (utils.isMobile()) {
|
||||
@@ -305,7 +305,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
target: "after",
|
||||
targetBranchId: this.node.data.branchId,
|
||||
type,
|
||||
mime,
|
||||
isProtected,
|
||||
templateNoteId
|
||||
});
|
||||
@@ -314,7 +313,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
|
||||
noteCreateService.createNote(parentNotePath, {
|
||||
type,
|
||||
mime,
|
||||
isProtected: this.node.data.isProtected,
|
||||
templateNoteId
|
||||
});
|
||||
|
||||
@@ -84,6 +84,55 @@ async function createSearchNote(opts = {}) {
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
async function createLlmChat() {
|
||||
const note = await server.post<FNoteRow>("special-notes/llm-chat");
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the most recently modified LLM chat.
|
||||
* Returns null if no chat exists.
|
||||
*/
|
||||
async function getMostRecentLlmChat() {
|
||||
const note = await server.get<FNoteRow | null>("special-notes/most-recent-llm-chat");
|
||||
|
||||
if (!note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the most recent LLM chat, or creates a new one if none exists.
|
||||
* Used by sidebar chat for persistent conversations across page refreshes.
|
||||
*/
|
||||
async function getOrCreateLlmChat() {
|
||||
const note = await server.get<FNoteRow>("special-notes/get-or-create-llm-chat");
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
export interface RecentLlmChat {
|
||||
noteId: string;
|
||||
title: string;
|
||||
dateModified: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of recent LLM chats for the history popup.
|
||||
*/
|
||||
async function getRecentLlmChats(limit: number = 10): Promise<RecentLlmChat[]> {
|
||||
return await server.get<RecentLlmChat[]>(`special-notes/recent-llm-chats?limit=${limit}`);
|
||||
}
|
||||
|
||||
export default {
|
||||
getInboxNote,
|
||||
getTodayNote,
|
||||
@@ -94,5 +143,9 @@ export default {
|
||||
getMonthNote,
|
||||
getYearNote,
|
||||
createSqlConsole,
|
||||
createSearchNote
|
||||
createSearchNote,
|
||||
createLlmChat,
|
||||
getMostRecentLlmChat,
|
||||
getOrCreateLlmChat,
|
||||
getRecentLlmChats
|
||||
};
|
||||
|
||||
@@ -13,6 +13,11 @@ export const experimentalFeatures = [
|
||||
id: "new-layout",
|
||||
name: t("experimental_features.new_layout_name"),
|
||||
description: t("experimental_features.new_layout_description"),
|
||||
},
|
||||
{
|
||||
id: "llm",
|
||||
name: t("experimental_features.llm_name"),
|
||||
description: t("experimental_features.llm_description"),
|
||||
}
|
||||
] as const satisfies ExperimentalFeature[];
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
|
||||
search: null,
|
||||
text: null,
|
||||
webView: null,
|
||||
spreadsheet: null
|
||||
spreadsheet: null,
|
||||
llmChat: null
|
||||
};
|
||||
|
||||
export const byBookType: Record<ViewTypeOptions, string | null> = {
|
||||
|
||||
110
apps/client/src/services/llm_chat.ts
Normal file
110
apps/client/src/services/llm_chat.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { LlmChatConfig, LlmCitation, LlmMessage, LlmModelInfo,LlmUsage } from "@triliumnext/commons";
|
||||
|
||||
import server from "./server.js";
|
||||
|
||||
/**
|
||||
* Fetch available models from all configured providers.
|
||||
*/
|
||||
export async function getAvailableModels(): Promise<LlmModelInfo[]> {
|
||||
const response = await server.get<{ models?: LlmModelInfo[] }>("llm-chat/models");
|
||||
return response.models ?? [];
|
||||
}
|
||||
|
||||
export interface StreamCallbacks {
|
||||
onChunk: (text: string) => void;
|
||||
onThinking?: (text: string) => void;
|
||||
onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
|
||||
onToolResult?: (toolName: string, result: string, isError?: boolean) => void;
|
||||
onCitation?: (citation: LlmCitation) => void;
|
||||
onUsage?: (usage: LlmUsage) => void;
|
||||
onError: (error: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a chat completion from the LLM API using Server-Sent Events.
|
||||
*/
|
||||
export async function streamChatCompletion(
|
||||
messages: LlmMessage[],
|
||||
config: LlmChatConfig,
|
||||
callbacks: StreamCallbacks
|
||||
): Promise<void> {
|
||||
const headers = await server.getHeaders();
|
||||
|
||||
const response = await fetch(`${window.glob.baseApiUrl}llm-chat/stream`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "application/json"
|
||||
} as HeadersInit,
|
||||
body: JSON.stringify({ messages, config })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
callbacks.onError(`HTTP ${response.status}: ${response.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
callbacks.onError("No response body");
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
switch (data.type) {
|
||||
case "text":
|
||||
callbacks.onChunk(data.content);
|
||||
break;
|
||||
case "thinking":
|
||||
callbacks.onThinking?.(data.content);
|
||||
break;
|
||||
case "tool_use":
|
||||
callbacks.onToolUse?.(data.toolName, data.toolInput);
|
||||
break;
|
||||
case "tool_result":
|
||||
callbacks.onToolResult?.(data.toolName, data.result, data.isError);
|
||||
break;
|
||||
case "citation":
|
||||
if (data.citation) {
|
||||
callbacks.onCitation?.(data.citation);
|
||||
}
|
||||
break;
|
||||
case "usage":
|
||||
if (data.usage) {
|
||||
callbacks.onUsage?.(data.usage);
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
callbacks.onError(data.error);
|
||||
break;
|
||||
case "done":
|
||||
callbacks.onDone();
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse SSE data line:", line, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NoteType } from "../entities/fnote.js";
|
||||
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js";
|
||||
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
|
||||
import { isExperimentalFeatureEnabled } from "./experimental_features.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
import server from "./server.js";
|
||||
@@ -26,7 +27,6 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
|
||||
|
||||
// The default note type (always the first item)
|
||||
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
|
||||
{ type: "text", mime: "application/json", title: "Text (Lexical)", icon: "bx-note" },
|
||||
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true },
|
||||
|
||||
// Text notes group
|
||||
@@ -42,6 +42,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
|
||||
{ type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), icon: "bxs-network-chart" },
|
||||
|
||||
// Misc note types
|
||||
{ type: "llmChat", mime: "application/json", title: t("note_types.llm-chat"), icon: "bx-message-square-dots", isBeta: true },
|
||||
{ type: "render", mime: "", title: t("note_types.render-note"), icon: "bx-extension" },
|
||||
{ type: "search", title: t("note_types.saved-search"), icon: "bx-file-find", static: true },
|
||||
{ type: "webView", mime: "", title: t("note_types.web-view"), icon: "bx-globe-alt" },
|
||||
@@ -93,12 +94,12 @@ 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,
|
||||
command,
|
||||
type: nt.type,
|
||||
mime: nt.mime,
|
||||
uiIcon: `bx ${nt.icon}`,
|
||||
badges: []
|
||||
};
|
||||
|
||||
@@ -922,6 +922,7 @@ export default {
|
||||
parseDate,
|
||||
formatDateISO,
|
||||
formatDateTime,
|
||||
formatTime,
|
||||
formatTimeInterval,
|
||||
formatSize,
|
||||
localNowDateTime,
|
||||
|
||||
@@ -1750,10 +1750,13 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-text-color) !important;
|
||||
}
|
||||
|
||||
#right-pane .card-header-title {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#right-pane .card-header-buttons {
|
||||
display: flex;
|
||||
transform: scale(0.9);
|
||||
|
||||
@@ -93,7 +93,10 @@
|
||||
"digits": "dígits",
|
||||
"inheritable": "Heretable",
|
||||
"delete": "Suprimeix",
|
||||
"color_type": "Color"
|
||||
"color_type": "Color",
|
||||
"textarea": "Text multi linia",
|
||||
"date_time": "Data i hora",
|
||||
"precision_title": "Quants dígits han d'estar disponibles per a coma flotant a la interfície de configuració."
|
||||
},
|
||||
"rename_label": {
|
||||
"to": "Per"
|
||||
|
||||
@@ -446,7 +446,8 @@
|
||||
"and_more": "... 以及另外 {{count}} 个。",
|
||||
"print_landscape": "导出为 PDF 时,将页面方向更改为横向而不是纵向。",
|
||||
"print_page_size": "导出为 PDF 时,更改页面大小。支持的值:<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": "颜色"
|
||||
"color_type": "颜色",
|
||||
"textarea": "多行文本"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "要添加标签,只需输入例如 <code>#rock</code> 或者如果您还想添加值,则例如 <code>#year = 2020</code>",
|
||||
@@ -2167,5 +2168,52 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "了解更多"
|
||||
},
|
||||
"media": {
|
||||
"play": "播放(空格)",
|
||||
"pause": "暂停(空格)",
|
||||
"back-10s": "后退10秒(左箭头键)",
|
||||
"forward-30s": "前进30秒",
|
||||
"mute": "静音(M)",
|
||||
"unmute": "取消静音(M)",
|
||||
"playback-speed": "播放速度",
|
||||
"loop": "循环播放",
|
||||
"disable-loop": "禁用循环播放",
|
||||
"rotate": "旋转",
|
||||
"picture-in-picture": "画中画",
|
||||
"exit-picture-in-picture": "退出画中画",
|
||||
"fullscreen": "全屏(F)",
|
||||
"exit-fullscreen": "退出全屏",
|
||||
"unsupported-format": "此文件格式不支持媒体预览:\n{{mime}}",
|
||||
"zoom-to-fit": "缩放以填充",
|
||||
"zoom-reset": "重置缩放以填充"
|
||||
},
|
||||
"mermaid": {
|
||||
"sample_diagrams": "示例图:",
|
||||
"sample_flowchart": "流程图",
|
||||
"sample_class": "类图",
|
||||
"sample_sequence": "时序图",
|
||||
"sample_entity_relationship": "实体关系图",
|
||||
"sample_state": "状态图",
|
||||
"sample_mindmap": "思维导图",
|
||||
"sample_architecture": "架构图",
|
||||
"sample_block": "模块图",
|
||||
"sample_c4": "C4 图",
|
||||
"sample_gantt": "甘特图",
|
||||
"sample_git": "Git 流程图",
|
||||
"sample_kanban": "看板图",
|
||||
"sample_packet": "数据包图",
|
||||
"sample_pie": "饼图",
|
||||
"sample_quadrant": "象限图",
|
||||
"sample_radar": "雷达图",
|
||||
"sample_requirement": "需求图",
|
||||
"sample_sankey": "桑基图",
|
||||
"sample_timeline": "时间轴图",
|
||||
"sample_treemap": "树形图",
|
||||
"sample_user_journey": "用户旅程图",
|
||||
"sample_xy": "散点图",
|
||||
"sample_venn": "韦恩图",
|
||||
"sample_ishikawa": "鱼骨图",
|
||||
"placeholder": "输入你的美人鱼图的内容,或者使用下面的示例图之一。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +446,8 @@
|
||||
"and_more": "... und {{count}} mehr.",
|
||||
"print_landscape": "Beim Export als PDF, wird die Seitenausrichtung Querformat anstatt Hochformat verwendet.",
|
||||
"print_page_size": "Beim Export als PDF, wird die Größe der Seite angepasst. Unterstützte Größen: <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": "Farbe"
|
||||
"color_type": "Farbe",
|
||||
"textarea": "Mehrzeilen-Text"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "Um ein Label hinzuzufügen, gebe einfach z.B. ein. <code>#rock</code> oder wenn du auch einen Wert hinzufügen möchten, dann z.B. <code>#year = 2024</code>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Πληροφορίες για το Trilium Notes",
|
||||
"title": "Σχετικά με το Trilium Notes",
|
||||
"homepage": "Αρχική Σελίδα:",
|
||||
"app_version": "Έκδοση εφαρμογής:",
|
||||
"db_version": "Έκδοση βάσης δεδομένων:",
|
||||
|
||||
@@ -1157,7 +1157,9 @@
|
||||
"title": "Experimental Options",
|
||||
"disclaimer": "These options are experimental and may cause instability. Use with caution.",
|
||||
"new_layout_name": "New Layout",
|
||||
"new_layout_description": "Try out the new layout for a more modern look and improved usability. Subject to heavy change in the upcoming releases."
|
||||
"new_layout_description": "Try out the new layout for a more modern look and improved usability. Subject to heavy change in the upcoming releases.",
|
||||
"llm_name": "AI / LLM Chat",
|
||||
"llm_description": "Enable the AI chat sidebar and LLM chat notes powered by large language models."
|
||||
},
|
||||
"fonts": {
|
||||
"theme_defined": "Theme defined",
|
||||
@@ -1599,6 +1601,7 @@
|
||||
"geo-map": "Geo Map",
|
||||
"beta-feature": "Beta",
|
||||
"ai-chat": "AI Chat",
|
||||
"llm-chat": "AI Chat",
|
||||
"task-list": "Task List",
|
||||
"new-feature": "New",
|
||||
"collections": "Collections",
|
||||
@@ -1610,6 +1613,49 @@
|
||||
"toggle-on-hint": "Note is not protected, click to make it protected",
|
||||
"toggle-off-hint": "Note is protected, click to make it unprotected"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "Type a message...",
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"empty_state": "Start a conversation by typing a message below.",
|
||||
"searching_web": "Searching the web...",
|
||||
"web_search": "Web search",
|
||||
"note_tools": "Note access",
|
||||
"sources": "Sources",
|
||||
"extended_thinking": "Extended thinking",
|
||||
"legacy_models": "Legacy models",
|
||||
"thinking": "Thinking...",
|
||||
"thought_process": "Thought process",
|
||||
"tool_calls": "{{count}} tool call(s)",
|
||||
"input": "Input",
|
||||
"result": "Result",
|
||||
"error": "Error",
|
||||
"tool_error": "failed",
|
||||
"total_tokens": "{{total}} tokens",
|
||||
"tokens_detail": "{{prompt}} prompt + {{completion}} completion",
|
||||
"tokens_used": "{{prompt}} prompt + {{completion}} completion = {{total}} tokens",
|
||||
"tokens_used_with_cost": "{{prompt}} prompt + {{completion}} completion = {{total}} tokens (~${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} prompt + {{completion}} completion = {{total}} tokens",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} prompt + {{completion}} completion = {{total}} tokens (~${{cost}})",
|
||||
"tokens": "tokens",
|
||||
"context_used": "{{percentage}}% used",
|
||||
"note_context_enabled": "Click to disable note context: {{title}}",
|
||||
"note_context_disabled": "Click to include current note in context",
|
||||
"no_provider_message": "No AI provider configured. Add one to start chatting.",
|
||||
"add_provider": "Add AI Provider",
|
||||
"role_user": "You",
|
||||
"role_assistant": "Assistant"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "AI Chat",
|
||||
"launcher_title": "Open AI Chat",
|
||||
"new_chat": "Start new chat",
|
||||
"save_chat": "Save chat to notes",
|
||||
"empty_state": "Start a conversation",
|
||||
"history": "Chat history",
|
||||
"recent_chats": "Recent chats",
|
||||
"no_chats": "No previous chats"
|
||||
},
|
||||
"shared_switch": {
|
||||
"shared": "Shared",
|
||||
"toggle-on-title": "Share the note",
|
||||
@@ -2230,5 +2276,55 @@
|
||||
"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/>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Rendu impossible d'un widget React custom"
|
||||
}
|
||||
},
|
||||
"widget-missing-parent": "Le widget personnalisé ne possède pas la propriété obligatoire '{{property}}'.\n\nSi ce script est destiné à être exécuté sans élément d’interface utilisateur, utilisez plutôt '#run=frontendStartup'.",
|
||||
"open-script-note": "Ouvrir la note du script",
|
||||
"scripting-error": "Erreur de script personnalisée: {{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Ajouter un lien",
|
||||
@@ -443,7 +446,8 @@
|
||||
"and_more": "... et {{count}} plus.",
|
||||
"print_landscape": "Lors de l'exportation en PDF, change l'orientation de la page en paysage au lieu de portrait.",
|
||||
"print_page_size": "Lors de l'exportation en PDF, change la taille de la page. Valeurs supportées : <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
|
||||
"color_type": "Couleur"
|
||||
"color_type": "Couleur",
|
||||
"textarea": "Texte multiligne"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "Pour ajouter un label, tapez simplement par ex. <code>#rock</code>, ou si vous souhaitez également ajouter une valeur, tapez par ex. <code>#année = 2020</code>",
|
||||
@@ -659,7 +663,8 @@
|
||||
"show-cheatsheet": "Afficher l'aide rapide",
|
||||
"toggle-zen-mode": "Zen Mode",
|
||||
"new-version-available": "Nouvelle mise à jour disponible",
|
||||
"download-update": "Obtenir la version {{latestVersion}}"
|
||||
"download-update": "Obtenir la version {{latestVersion}}",
|
||||
"search_notes": "Rechercher notes"
|
||||
},
|
||||
"zen_mode": {
|
||||
"button_exit": "Sortir du Zen mode"
|
||||
@@ -703,7 +708,8 @@
|
||||
"advanced": "Avancé",
|
||||
"export_as_image": "Exporter en tant qu'image",
|
||||
"export_as_image_png": "PNG",
|
||||
"export_as_image_svg": "SVG (vectoriel)"
|
||||
"export_as_image_svg": "SVG (vectoriel)",
|
||||
"note_map": "Note Carte"
|
||||
},
|
||||
"onclick_button": {
|
||||
"no_click_handler": "Le widget bouton '{{componentId}}' n'a pas de gestionnaire de clic défini"
|
||||
@@ -741,7 +747,7 @@
|
||||
"button_title": "Exporter le diagramme au format SVG"
|
||||
},
|
||||
"relation_map_buttons": {
|
||||
"create_child_note_title": "Créer une nouvelle note enfant et l'ajouter à cette carte de relation",
|
||||
"create_child_note_title": "Créer une note enfant et l'ajouter à la carte",
|
||||
"reset_pan_zoom_title": "Réinitialiser le panoramique et le zoom aux coordonnées et à la position initiales",
|
||||
"zoom_in_title": "Zoomer",
|
||||
"zoom_out_title": "Zoom arrière"
|
||||
@@ -757,7 +763,9 @@
|
||||
"delete_this_note": "Supprimer cette note",
|
||||
"error_cannot_get_branch_id": "Impossible d'obtenir branchId pour notePath '{{notePath}}'",
|
||||
"error_unrecognized_command": "Commande non reconnue {{command}}",
|
||||
"note_revisions": "Révision de la note"
|
||||
"note_revisions": "Révision de la note",
|
||||
"backlinks": "Rétro-liens",
|
||||
"content_language_switcher": "Langue du contenu: {{language}}"
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Changer l'icône de note",
|
||||
@@ -766,7 +774,12 @@
|
||||
"filter": "Filtre",
|
||||
"filter-none": "Toutes les icônes",
|
||||
"filter-default": "Icônes par défaut",
|
||||
"icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}"
|
||||
"icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}",
|
||||
"no_results": "Aucune icône trouvée.",
|
||||
"search_placeholder_one": "Rechercher {{number}} icônes dans {{count}} packs",
|
||||
"search_placeholder_many": "Rechercher {{number}} icônes dans {{count}} packs",
|
||||
"search_placeholder_other": "Rechercher les icônes {{number}} dans les paquets {{count}}",
|
||||
"search_placeholder_filtered": "Rechercher {{number}} icônes dans {{name}}"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Type de note",
|
||||
@@ -793,7 +806,8 @@
|
||||
"expand_tooltip": "Développe les éléments enfants directs de cette collection (à un niveau). Pour plus d'options, appuyez sur la flèche à droite.",
|
||||
"expand_first_level": "Développer les enfants directs",
|
||||
"expand_nth_level": "Développer sur {{depth}} niveaux",
|
||||
"expand_all_levels": "Développer tous les niveaux"
|
||||
"expand_all_levels": "Développer tous les niveaux",
|
||||
"hide_child_notes": "Masquer les notes enfants dans l’arborescence"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "Aucune note modifiée ce jour-là...",
|
||||
@@ -806,7 +820,7 @@
|
||||
"file_type": "Type de fichier",
|
||||
"file_size": "Taille du fichier",
|
||||
"download": "Télécharger",
|
||||
"open": "Ouvrir",
|
||||
"open": "Ouvrir dans une nouvelle fenêtre",
|
||||
"upload_new_revision": "Téléverser une nouvelle version",
|
||||
"upload_success": "Une nouvelle version de fichier a été téléversée.",
|
||||
"upload_failed": "Le téléversement d'une nouvelle version de fichier a échoué.",
|
||||
@@ -826,7 +840,8 @@
|
||||
},
|
||||
"inherited_attribute_list": {
|
||||
"title": "Attributs hérités",
|
||||
"no_inherited_attributes": "Aucun attribut hérité."
|
||||
"no_inherited_attributes": "Aucun attribut hérité.",
|
||||
"none": "aucun"
|
||||
},
|
||||
"note_info_widget": {
|
||||
"note_id": "Identifiant de la note",
|
||||
@@ -903,7 +918,8 @@
|
||||
"unknown_search_option": "Option de recherche inconnue {{searchOptionName}}",
|
||||
"search_note_saved": "La note de recherche a été enregistrée dans {{- notePathTitle}}",
|
||||
"actions_executed": "Les actions ont été exécutées.",
|
||||
"view_options": "Afficher les options:"
|
||||
"view_options": "Afficher les options:",
|
||||
"option": "option"
|
||||
},
|
||||
"similar_notes": {
|
||||
"title": "Notes similaires",
|
||||
@@ -997,7 +1013,7 @@
|
||||
"no_attachments": "Cette note ne contient aucune pièce jointe."
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "Cette note de type Livre n'a aucune note enfant, donc il n'y a rien à afficher. Consultez le <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> pour plus de détails.",
|
||||
"no_children_help": "Cette collection ne contient pas de notes enfants, il n'y a donc rien à afficher.",
|
||||
"drag_locked_title": "Edition verrouillée",
|
||||
"drag_locked_message": "Le glisser-déposer n'est pas autorisé car l'édition de cette collection est verrouillé."
|
||||
},
|
||||
@@ -1367,7 +1383,8 @@
|
||||
"description": "Description",
|
||||
"reload_app": "Recharger l'application pour appliquer les modifications",
|
||||
"set_all_to_default": "Réinitialiser aux valeurs par défaut",
|
||||
"confirm_reset": "Voulez-vous vraiment réinitialiser tous les raccourcis clavier par défaut ?"
|
||||
"confirm_reset": "Voulez-vous vraiment réinitialiser tous les raccourcis clavier par défaut ?",
|
||||
"no_results": "Aucun raccourci correspondant à '{{filter}}'"
|
||||
},
|
||||
"spellcheck": {
|
||||
"title": "Vérification orthographique",
|
||||
@@ -1402,7 +1419,7 @@
|
||||
"will_be_deleted_in": "Cette pièce jointe sera automatiquement supprimée dans {{time}}",
|
||||
"will_be_deleted_soon": "Cette pièce jointe sera bientôt supprimée automatiquement",
|
||||
"deletion_reason": ", car la pièce jointe n'est pas liée dans le contenu de la note. Pour empêcher la suppression, ajoutez à nouveau le lien de la pièce jointe dans le contenu d'une note ou convertissez la pièce jointe en note.",
|
||||
"role_and_size": "Rôle : {{role}}, Taille : {{size}}",
|
||||
"role_and_size": "Rôle : {{role}}, Taille : {{size}}, MIME: {{- mimeType}}",
|
||||
"link_copied": "Lien de pièce jointe copié dans le presse-papiers.",
|
||||
"unrecognized_role": "Rôle de pièce jointe « {{role}} » non reconnu."
|
||||
},
|
||||
@@ -1453,10 +1470,13 @@
|
||||
"import-into-note": "Importer dans la note",
|
||||
"apply-bulk-actions": "Appliquer des Actions groupées",
|
||||
"converted-to-attachments": "Les notes {{count}} ont été converties en pièces jointes.",
|
||||
"convert-to-attachment-confirm": "Êtes-vous sûr de vouloir convertir les notes sélectionnées en pièces jointes de leurs notes parentes ?",
|
||||
"convert-to-attachment-confirm": "Êtes-vous sûr de vouloir convertir les notes sélectionnées en pièces jointes de leurs notes parentales ? Cette opération s'applique uniquement aux notes d'image, les autres notes seront ignorées.",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Désarchiver",
|
||||
"open-in-popup": "Modification rapide"
|
||||
"open-in-popup": "Modification rapide",
|
||||
"open-in-a-new-window": "Ouvrir dans une nouvelle fenêtre",
|
||||
"hide-subtree": "Masquer le sous-arbre",
|
||||
"show-subtree": "Afficher le sous-arbre"
|
||||
},
|
||||
"shared_info": {
|
||||
"shared_publicly": "Cette note est partagée publiquement sur {{- link}}.",
|
||||
@@ -1485,7 +1505,10 @@
|
||||
"task-list": "Liste de tâches",
|
||||
"book": "Collection",
|
||||
"new-feature": "Nouveau",
|
||||
"collections": "Collections"
|
||||
"collections": "Collections",
|
||||
"ai-chat": "Chat IA",
|
||||
"llm-chat": "Chat AI",
|
||||
"spreadsheet": "Feuille de calcul"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Protéger la note",
|
||||
@@ -1834,7 +1857,7 @@
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "Masquer les week-ends",
|
||||
"display-week-numbers": "Afficher les numéros de semaine",
|
||||
"map-style": "Style de carte :",
|
||||
"map-style": "Style de carte",
|
||||
"max-nesting-depth": "Profondeur d'imbrication maximale :",
|
||||
"raster": "Trame",
|
||||
"vector_light": "Vecteur (clair)",
|
||||
@@ -1973,7 +1996,9 @@
|
||||
"title": "Options expérimentales",
|
||||
"disclaimer": "Ces options sont expérimentales et peuvent provoquer une instabilité. Utilisez avec prudence.",
|
||||
"new_layout_name": "Nouvelle mise en page",
|
||||
"new_layout_description": "Essayez la nouvelle mise en page pour un look plus moderne et un usage améliorée. Sous réserve de changements importants dans les prochaines versions."
|
||||
"new_layout_description": "Essayez la nouvelle mise en page pour un look plus moderne et un usage améliorée. Sous réserve de changements importants dans les prochaines versions.",
|
||||
"llm_name": "AI / LLM Chat",
|
||||
"llm_description": "Activer la barre de chat AI et les notes de chat LLM alimentées par de grands modèles de langage."
|
||||
},
|
||||
"read-only-info": {
|
||||
"read-only-note": "Vous consultez actuellement une note en lecture seule.",
|
||||
@@ -1982,5 +2007,57 @@
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "Effacer la note..."
|
||||
},
|
||||
"media": {
|
||||
"play": "Lire (Espace)",
|
||||
"pause": "Pause (Espace)",
|
||||
"back-10s": "Retour arrière 10s (flèche gauche)",
|
||||
"forward-30s": "Avance 30s",
|
||||
"mute": "Silence (M)",
|
||||
"unmute": "Réactiver le son (M)",
|
||||
"playback-speed": "Vitesse de lecture",
|
||||
"loop": "Boucle",
|
||||
"disable-loop": "Désactiver la boucle",
|
||||
"rotate": "Rotation",
|
||||
"picture-in-picture": "Image dans l'image",
|
||||
"exit-picture-in-picture": "Sortir de Image dans l'image",
|
||||
"fullscreen": "Plein-écran (F)",
|
||||
"exit-fullscreen": "Sortir du mode plein-écran",
|
||||
"unsupported-format": "L'aperçu multimédia n'est pas disponible pour ce format de fichier:\n{{mime}}",
|
||||
"zoom-to-fit": "Zoom pour remplir",
|
||||
"zoom-reset": "Annuler zoom pour remplir"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "Afficher du HTML personnalisé ou Preact JSX dans cette note",
|
||||
"setup_create_sample_preact": "Créer un exemple de note avec Preact",
|
||||
"setup_create_sample_html": "Créer un exemple de note avec HTML",
|
||||
"setup_sample_created": "Un exemple de note a été créé en tant que note enfant.",
|
||||
"disabled_description": "Ces notes de rendu proviennent d'une source externe. Pour vous protéger de contenu malveillant, elle n'est pas activée par défaut. Assurez-vous de faire confiance à la source avant de l’activer.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +477,8 @@
|
||||
"and_more": "... agus {{count}} eile.",
|
||||
"print_landscape": "Agus é á onnmhairiú go PDF, athraítear treoshuíomh an leathanaigh go tírdhreach seachas portráid.",
|
||||
"print_page_size": "Agus é á easpórtáil go PDF, athraítear méid an leathanaigh. Luachanna tacaithe: <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": "Dath"
|
||||
"color_type": "Dath",
|
||||
"textarea": "Téacs Il-líne"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "Chun lipéad a chur leis, clóscríobh m.sh. <code>#rock</code> nó más mian leat luach a chur leis freisin ansin m.sh. <code>#year = 2020</code>",
|
||||
|
||||
@@ -520,7 +520,7 @@
|
||||
"custom_name_label": "Nome del motore di ricerca personalizzato",
|
||||
"custom_name_placeholder": "Personalizza il nome del motore di ricerca",
|
||||
"custom_url_label": "L'URL del motore di ricerca personalizzato deve includere {keyword} come segnaposto per il termine di ricerca.",
|
||||
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
|
||||
"custom_url_placeholder": "Personalizza indirizzo url del motore di ricerca"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
@@ -917,7 +917,8 @@
|
||||
"print_landscape": "Quando si esporta in PDF, cambia l'orientamento della pagina da verticale a orizzontale.",
|
||||
"print_page_size": "Quando si esporta in PDF, modifica le dimensioni della pagina. Valori supportati: <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": "Colore",
|
||||
"share_root": "segna la nota che viene servita su /share root."
|
||||
"share_root": "segna la nota che viene servita su /share root.",
|
||||
"textarea": "Testo su più righe"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "Per aggiungere un'etichetta, basta digitare ad esempio <code>#rock</code> oppure, se si desidera aggiungere anche un valore, ad esempio <code>#year = 2020</code>",
|
||||
@@ -1717,7 +1718,8 @@
|
||||
"new-feature": "Nuovo",
|
||||
"collections": "Collezioni",
|
||||
"ai-chat": "Chat con IA",
|
||||
"spreadsheet": "Foglio di calcolo"
|
||||
"spreadsheet": "Foglio di calcolo",
|
||||
"llm-chat": "Chat con IA"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Proteggi la nota",
|
||||
@@ -2050,7 +2052,9 @@
|
||||
"title": "Opzioni sperimentali",
|
||||
"disclaimer": "Queste opzioni sono sperimentali e potrebbero causare instabilità. Usare con cautela.",
|
||||
"new_layout_name": "Nuovo layout",
|
||||
"new_layout_description": "Prova il nuovo layout per un look più moderno e una maggiore usabilità. Soggetto a modifiche significative nelle prossime versioni."
|
||||
"new_layout_description": "Prova il nuovo layout per un look più moderno e una maggiore usabilità. Soggetto a modifiche significative nelle prossime versioni.",
|
||||
"llm_name": "Chat con IA / LLM",
|
||||
"llm_description": "Attiva la barra laterale della chat con IA e le note della chat LLM basate su modelli linguistici di grandi dimensioni."
|
||||
},
|
||||
"server": {
|
||||
"unknown_http_error_title": "Errore di comunicazione con il server",
|
||||
@@ -2197,5 +2201,111 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Per saperne di più"
|
||||
},
|
||||
"media": {
|
||||
"play": "Gioca (Barra spaziatrice)",
|
||||
"pause": "Pausa (Barra spaziatrice)",
|
||||
"back-10s": "Indietro di 10 (tasto freccia sinistra)",
|
||||
"forward-30s": "Avanti 30s",
|
||||
"mute": "Muto (M)",
|
||||
"unmute": "Riattiva audio (M)",
|
||||
"playback-speed": "Velocità di riproduzione",
|
||||
"loop": "Ciclo",
|
||||
"disable-loop": "Disattiva il ciclo",
|
||||
"rotate": "Ruota",
|
||||
"picture-in-picture": "Immagine nell'immagine",
|
||||
"exit-picture-in-picture": "Esci dalla modalità picture-in-picture",
|
||||
"fullscreen": "Schermo intero (F)",
|
||||
"exit-fullscreen": "Esci dalla modalità a schermo intero",
|
||||
"unsupported-format": "Per questo formato di file non è disponibile l'anteprima multimediale:\n{{mime}}",
|
||||
"zoom-to-fit": "Ingrandisci per riempire",
|
||||
"zoom-reset": "Ripristina lo zoom a schermo intero"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "Digita il contenuto del tuo diagramma Mermaid oppure utilizza uno dei diagrammi di esempio riportati di seguito.",
|
||||
"sample_diagrams": "Esempi di diagrammi:",
|
||||
"sample_flowchart": "Diagramma di flusso",
|
||||
"sample_class": "Classe",
|
||||
"sample_sequence": "Sequenza",
|
||||
"sample_entity_relationship": "Relazioni tra entità",
|
||||
"sample_state": "Stato",
|
||||
"sample_mindmap": "Mappa mentale",
|
||||
"sample_architecture": "Architettura",
|
||||
"sample_block": "Blocco",
|
||||
"sample_c4": "C4",
|
||||
"sample_gantt": "Gantt",
|
||||
"sample_git": "Git",
|
||||
"sample_kanban": "Kanban",
|
||||
"sample_packet": "Packet",
|
||||
"sample_pie": "Torta",
|
||||
"sample_quadrant": "Quadrante",
|
||||
"sample_radar": "Radar",
|
||||
"sample_requirement": "Requisito",
|
||||
"sample_sankey": "Chiave",
|
||||
"sample_timeline": "Cronologia",
|
||||
"sample_treemap": "Treemap",
|
||||
"sample_user_journey": "Percorso dell'utente",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,7 +1180,8 @@
|
||||
"is_owned_by_note": "ノートによって所有されています",
|
||||
"and_more": "...その他 {{count}} 件。",
|
||||
"print_landscape": "PDF にエクスポートするときに、ページの向きを縦向きではなく横向きに変更します。",
|
||||
"print_page_size": "PDF にエクスポートするときに、ページのサイズを変更します。サポートされる値: <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>。"
|
||||
"print_page_size": "PDF にエクスポートするときに、ページのサイズを変更します。サポートされる値: <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>。",
|
||||
"textarea": "複数行テキスト"
|
||||
},
|
||||
"link_context_menu": {
|
||||
"open_note_in_popup": "クイック編集",
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
"no_path_to_clone_to": "Brak ścieżki do sklonowania.",
|
||||
"note_cloned": "Notatka \"{{clonedTitle}}\" została sklonowana do \"{{targetTitle}}\"",
|
||||
"help_on_links": "Pomoc dotycząca linków",
|
||||
"target_parent_note": "Docelowa notatka nadrzędna"
|
||||
"target_parent_note": "Docelowa notatka pierwotna"
|
||||
},
|
||||
"help": {
|
||||
"title": "Ściągawka",
|
||||
@@ -126,7 +126,7 @@
|
||||
"collapseExpand": "zwiń/rozwiń węzeł",
|
||||
"notSet": "nie ustawiono",
|
||||
"goBackForwards": "idź wstecz / do przodu w historii",
|
||||
"showJumpToNoteDialog": "pokaż okno <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Przejdź do\"</a>",
|
||||
"showJumpToNoteDialog": "pokaż <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Przejdź do\"</a>",
|
||||
"scrollToActiveNote": "przewiń do aktywnej notatki",
|
||||
"jumpToParentNote": "przejdź do notatki nadrzędnej",
|
||||
"collapseWholeTree": "zwiń całe drzewo notatek",
|
||||
@@ -402,7 +402,8 @@
|
||||
"and_more": "... i {{count}} więcej.",
|
||||
"print_landscape": "Podczas eksportowania do PDF zmienia orientację strony na poziomą zamiast pionowej.",
|
||||
"print_page_size": "Podczas eksportowania do PDF zmienia rozmiar strony. Obsługiwane wartości: <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": "Kolor"
|
||||
"color_type": "Kolor",
|
||||
"textarea": "Wiele linii tekstu"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Importuj do notatki",
|
||||
@@ -1613,7 +1614,7 @@
|
||||
"password_changed_success": "Hasło zostało zmienione. Trilium zostanie przeładowane po naciśnięciu OK."
|
||||
},
|
||||
"multi_factor_authentication": {
|
||||
"title": "Uwierzytelnianie wieloskładnikowe (MFA)",
|
||||
"title": "Uwierzytelnianie wieloskładnikowe",
|
||||
"description": "Uwierzytelnianie wieloskładnikowe (MFA) dodaje dodatkową warstwę zabezpieczeń do Twojego konta. Zamiast tylko wpisywać hasło do logowania, MFA wymaga podania jednego lub więcej dodatkowych dowodów tożsamości. W ten sposób, nawet jeśli ktoś zdobędzie Twoje hasło, nadal nie będzie mógł uzyskać dostępu do Twojego konta bez drugiej informacji. To jak dodanie dodatkowego zamka do drzwi, utrudniającego włamanie.<br><br>Proszę postępować zgodnie z poniższymi instrukcjami, aby włączyć MFA. Jeśli nie skonfigurujesz poprawnie, logowanie powróci do samego hasła.",
|
||||
"mfa_enabled": "Włącz uwierzytelnianie wieloskładnikowe",
|
||||
"mfa_method": "Metoda MFA",
|
||||
@@ -1628,7 +1629,7 @@
|
||||
"totp_secret_generated": "Sekret TOTP wygenerowany",
|
||||
"totp_secret_warning": "Proszę zapisać wygenerowany sekret w bezpiecznym miejscu. Nie zostanie pokazany ponownie.",
|
||||
"totp_secret_regenerate_confirm": "Czy na pewno chcesz ponownie wygenerować sekret TOTP? To unieważni poprzedni sekret TOTP i wszystkie istniejące kody odzyskiwania.",
|
||||
"recovery_keys_title": "Klucze odzyskiwania logowania jednokrotnego (SSO)",
|
||||
"recovery_keys_title": "Klucze odzyskiwania logowania jednokrotnego",
|
||||
"recovery_keys_description": "Klucze odzyskiwania logowania jednokrotnego służą do logowania w przypadku braku dostępu do kodów Authenticator.",
|
||||
"recovery_keys_description_warning": "Klucze odzyskiwania nie zostaną pokazane ponownie po opuszczeniu strony, przechowuj je w bezpiecznym miejscu.<br>Po użyciu klucza odzyskiwania nie można go użyć ponownie.",
|
||||
"recovery_keys_error": "Błąd generowania kodów odzyskiwania",
|
||||
@@ -1766,7 +1767,7 @@
|
||||
"book": "Kolekcja",
|
||||
"mermaid-diagram": "Diagram Mermaid",
|
||||
"canvas": "Płótno",
|
||||
"web-view": "Widok WWW",
|
||||
"web-view": "Widok strony web",
|
||||
"mind-map": "Mapa myśli",
|
||||
"file": "Plik",
|
||||
"image": "Obraz",
|
||||
@@ -1815,9 +1816,9 @@
|
||||
"modal_title": "Konfiguracja listy wyróżnień",
|
||||
"menu_configure": "Konfiguracja listy wyróżnień...",
|
||||
"no_highlights": "Nie znaleziono wyróżnień.",
|
||||
"title_with_count_one": "{{count}} podświetlenie",
|
||||
"title_with_count_few": "{{count}} podświetlenia",
|
||||
"title_with_count_many": "{{count}} podświetleń"
|
||||
"title_with_count_one": "{{count}} wyróżnienie",
|
||||
"title_with_count_few": "{{count}} wyróżnienia",
|
||||
"title_with_count_many": "{{count}} wyróżnień"
|
||||
},
|
||||
"quick-search": {
|
||||
"placeholder": "Szybkie wyszukiwanie",
|
||||
@@ -2070,7 +2071,7 @@
|
||||
"read_only_temporarily_disabled_description": "Ta notatka jest obecnie edytowalna, ale normalnie jest tylko do odczytu. Notatka powróci do trybu tylko do odczytu, gdy tylko przejdziesz do innej notatki.\n\nKliknij, aby ponownie włączyć tryb tylko do odczytu.",
|
||||
"shared_publicly": "Udostępniona publicznie",
|
||||
"shared_locally": "Udostępniona lokalnie",
|
||||
"clipped_note": "Wycinek WWW",
|
||||
"clipped_note": "Wycinek z sieci",
|
||||
"clipped_note_description": "Ta notatka została pierwotnie pobrana z {{url}}.\n\nKliknij, aby przejść do źródłowej strony internetowej.",
|
||||
"execute_script": "Uruchom skrypt",
|
||||
"execute_script_description": "Ta notatka jest notatką skryptową. Kliknij, aby wykonać skrypt.",
|
||||
@@ -2236,7 +2237,7 @@
|
||||
"sample_c4": "C4",
|
||||
"sample_gantt": "Wykres Gantta",
|
||||
"sample_git": "Diagram Git",
|
||||
"sample_kanban": "Kanban",
|
||||
"sample_kanban": "Tablica Kanban",
|
||||
"sample_packet": "Diagram pakietów",
|
||||
"sample_pie": "Wykres kołowy",
|
||||
"sample_quadrant": "Diagram kwadrantowy",
|
||||
|
||||
@@ -875,7 +875,7 @@
|
||||
"print_note": "Imprimare notiță",
|
||||
"re_render_note": "Reinterpretare notiță",
|
||||
"save_revision": "Salvează o nouă revizie",
|
||||
"advanced": "Advansat",
|
||||
"advanced": "Avansat",
|
||||
"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.",
|
||||
|
||||
@@ -446,7 +446,8 @@
|
||||
"app_theme_base": "設定為 \"next\"、\"next-light \" 或 \"next-dark\",以使用相應的 TriliumNext 主題(自動、淺色或深色)作為自訂主題的基礎,而非傳統主題。",
|
||||
"print_landscape": "匯出為 PDF 時,將頁面方向更改為橫向而非縱向。",
|
||||
"print_page_size": "在匯出 PDF 時更改頁面大小。支援的值:<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": "顏色"
|
||||
"color_type": "顏色",
|
||||
"textarea": "多行文字"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "要新增標籤,只需輸入例如 <code>#rock</code> 或者如果您還想新增值,則例如 <code>#year = 2020</code>",
|
||||
@@ -2182,5 +2183,52 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "了解更多"
|
||||
},
|
||||
"media": {
|
||||
"play": "播放 (空白鍵)",
|
||||
"pause": "暫停 (空白鍵)",
|
||||
"back-10s": "往前 10 秒 (左方向鍵)",
|
||||
"forward-30s": "往後 30 秒",
|
||||
"mute": "靜音 (M)",
|
||||
"unmute": "解除靜音 (M)",
|
||||
"playback-speed": "播放速度",
|
||||
"loop": "循環",
|
||||
"disable-loop": "解除循環",
|
||||
"rotate": "旋轉",
|
||||
"picture-in-picture": "畫中畫",
|
||||
"exit-picture-in-picture": "退出畫中畫",
|
||||
"fullscreen": "全螢幕 (F)",
|
||||
"exit-fullscreen": "退出全螢幕",
|
||||
"unsupported-format": "此檔案格式不支援媒體預覽:\n{{mime}}",
|
||||
"zoom-to-fit": "放大至填滿畫面",
|
||||
"zoom-reset": "重設放大至填滿畫面"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "請輸入您的美人魚圖表內容,或選用下方其中一個範例圖表。",
|
||||
"sample_diagrams": "範例圖表:",
|
||||
"sample_flowchart": "流程圖",
|
||||
"sample_class": "階層圖",
|
||||
"sample_sequence": "時序圖",
|
||||
"sample_entity_relationship": "實體關係圖",
|
||||
"sample_state": "狀態圖",
|
||||
"sample_mindmap": "心智圖",
|
||||
"sample_architecture": "架構圖",
|
||||
"sample_block": "區塊圖",
|
||||
"sample_c4": "C4 圖",
|
||||
"sample_gantt": "甘特圖",
|
||||
"sample_git": "Git 分支圖",
|
||||
"sample_kanban": "看板圖",
|
||||
"sample_packet": "數據包圖",
|
||||
"sample_pie": "圓餅圖",
|
||||
"sample_quadrant": "象限圖",
|
||||
"sample_radar": "雷達圖",
|
||||
"sample_requirement": "需求圖",
|
||||
"sample_sankey": "桑基圖",
|
||||
"sample_timeline": "時間軸",
|
||||
"sample_treemap": "樹狀圖",
|
||||
"sample_user_journey": "使用者旅程",
|
||||
"sample_xy": "XY 圖表",
|
||||
"sample_venn": "韋恩圖",
|
||||
"sample_ishikawa": "魚骨圖"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
|
||||
import type { Attribute } from "../../services/attribute_parser.js";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import server from "../../services/server.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import SpacedUpdate from "../../services/spaced_update.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import type { Attribute } from "../../services/attribute_parser.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="attr-detail tn-tool-dialog">
|
||||
@@ -29,6 +29,7 @@ const TPL = /*html*/`
|
||||
max-height: 600px;
|
||||
overflow: auto;
|
||||
box-shadow: 10px 10px 93px -25px black;
|
||||
contain: none;
|
||||
}
|
||||
|
||||
.attr-help td {
|
||||
@@ -343,6 +344,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
private $relatedNotesList!: JQuery<HTMLElement>;
|
||||
private $relatedNotesMoreNotes!: JQuery<HTMLElement>;
|
||||
private $attrHelp!: JQuery<HTMLElement>;
|
||||
private $statusBar?: JQuery<HTMLElement>;
|
||||
|
||||
private relatedNotesSpacedUpdate!: SpacedUpdate;
|
||||
private attribute!: Attribute;
|
||||
@@ -577,17 +579,24 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$widget
|
||||
.css("left", detPosition.left)
|
||||
.css("right", detPosition.right)
|
||||
.css("top", y - offset.top + 70)
|
||||
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
|
||||
|
||||
if (isNewLayout) {
|
||||
if (!this.$statusBar) {
|
||||
this.$statusBar = $(document.body).find(".component.status-bar");
|
||||
}
|
||||
|
||||
const statusBarHeight = this.$statusBar.outerHeight() ?? 0;
|
||||
const maxHeight = document.body.clientHeight - statusBarHeight;
|
||||
this.$widget
|
||||
.css("left", offset.left + (typeof detPosition.left === "number" ? detPosition.left : 0))
|
||||
.css("top", "unset")
|
||||
.css("bottom", 70)
|
||||
.css("max-height", "80vh");
|
||||
.css("bottom", statusBarHeight ?? 0)
|
||||
.css("max-height", maxHeight);
|
||||
} else {
|
||||
this.$widget
|
||||
.css("left", detPosition.left)
|
||||
.css("right", detPosition.right)
|
||||
.css("top", y - offset.top + 70)
|
||||
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
|
||||
}
|
||||
|
||||
if (focus === "name") {
|
||||
@@ -695,14 +704,14 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
return "label-definition";
|
||||
} else if (attribute.name.startsWith("relation:")) {
|
||||
return "relation-definition";
|
||||
} else {
|
||||
return "label";
|
||||
}
|
||||
return "label";
|
||||
|
||||
} else if (attribute.type === "relation") {
|
||||
return "relation";
|
||||
} else {
|
||||
this.$title.text("");
|
||||
}
|
||||
this.$title.text("");
|
||||
|
||||
}
|
||||
|
||||
updateAttributeInEditor() {
|
||||
|
||||
@@ -364,23 +364,19 @@
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
}
|
||||
|
||||
|
||||
.ck-content p {
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.ck-content figure.image {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.ck-content .table {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
overflow-x: scroll;
|
||||
--scrollbar-thickness: 0;
|
||||
scrollbar-width: none;
|
||||
|
||||
|
||||
table {
|
||||
width: max-content;
|
||||
table-layout: auto;
|
||||
@@ -435,4 +431,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
/* #endregion */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useLayoutEffect, useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
|
||||
import froca from "../../services/froca";
|
||||
import { isDesktop, isMobile } from "../../services/utils";
|
||||
import TabSwitcher from "../mobile_widgets/TabSwitcher";
|
||||
@@ -12,6 +13,7 @@ import HistoryNavigationButton from "./HistoryNavigation";
|
||||
import { LaunchBarContext } from "./launch_bar_widgets";
|
||||
import { CommandButton, CustomWidget, NoteLauncher, QuickSearchLauncherWidget, ScriptLauncher, TodayLauncher } from "./LauncherDefinitions";
|
||||
import ProtectedSessionStatusWidget from "./ProtectedSessionStatusWidget";
|
||||
import SidebarChatButton from "./SidebarChatButton";
|
||||
import SpacerWidget from "./SpacerWidget";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
|
||||
@@ -98,6 +100,8 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
|
||||
return <QuickSearchLauncherWidget />;
|
||||
case "mobileTabSwitcher":
|
||||
return <TabSwitcher />;
|
||||
case "sidebarChat":
|
||||
return isExperimentalFeatureEnabled("llm") ? <SidebarChatButton /> : undefined;
|
||||
default:
|
||||
console.warn(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
|
||||
}
|
||||
|
||||
24
apps/client/src/widgets/launch_bar/SidebarChatButton.tsx
Normal file
24
apps/client/src/widgets/launch_bar/SidebarChatButton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useCallback } from "preact/hooks";
|
||||
|
||||
import appContext from "../../components/app_context";
|
||||
import { t } from "../../services/i18n";
|
||||
import { LaunchBarActionButton } from "./launch_bar_widgets";
|
||||
|
||||
/**
|
||||
* Launcher button to open the sidebar (which contains the chat).
|
||||
* The chat widget is always visible in the sidebar for non-chat notes.
|
||||
*/
|
||||
export default function SidebarChatButton() {
|
||||
const handleClick = useCallback(() => {
|
||||
// Open right pane if hidden, or toggle it if visible
|
||||
appContext.triggerEvent("toggleRightPane", {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LaunchBarActionButton
|
||||
icon="bx bx-message-square-dots"
|
||||
text={t("sidebar_chat.launcher_title")}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
|
||||
import froca from "../../services/froca";
|
||||
import { t } from "../../services/i18n";
|
||||
import { NOTE_TYPES, NoteTypeMapping } from "../../services/note_types";
|
||||
@@ -28,6 +29,7 @@ export default function NoteTypeSwitcher() {
|
||||
const restNoteTypes: NoteTypeMapping[] = [];
|
||||
for (const noteType of NOTE_TYPES) {
|
||||
if (noteType.reserved || noteType.static || noteType.type === "book") continue;
|
||||
if (noteType.type === "llmChat" && !isExperimentalFeatureEnabled("llm")) continue;
|
||||
if (SWITCHER_PINNED_NOTE_TYPES.has(noteType.type)) {
|
||||
pinnedNoteTypes.push(noteType);
|
||||
} else {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { TypeWidgetProps } from "./type_widgets/type_widget";
|
||||
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
|
||||
* for protected session or attachment information.
|
||||
*/
|
||||
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole";
|
||||
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
|
||||
|
||||
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined);
|
||||
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
|
||||
@@ -147,5 +147,11 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
|
||||
className: "note-detail-spreadsheet",
|
||||
printable: true,
|
||||
isFullHeight: true
|
||||
},
|
||||
llmChat: {
|
||||
view: () => import("./type_widgets/llm_chat/LlmChat"),
|
||||
className: "note-detail-llm-chat",
|
||||
printable: true,
|
||||
isFullHeight: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,16 +5,27 @@ interface FormDropdownList<T> extends Omit<DropdownProps, "children"> {
|
||||
values: T[];
|
||||
keyProperty: keyof T;
|
||||
titleProperty: keyof T;
|
||||
/** Property to show as a small suffix next to the title */
|
||||
titleSuffixProperty?: keyof T;
|
||||
descriptionProperty?: keyof T;
|
||||
currentValue: string;
|
||||
onChange(newValue: string): void;
|
||||
}
|
||||
|
||||
export default function FormDropdownList<T>({ values, keyProperty, titleProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList<T>) {
|
||||
export default function FormDropdownList<T>({ values, keyProperty, titleProperty, titleSuffixProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList<T>) {
|
||||
const currentValueData = values.find(value => value[keyProperty] === currentValue);
|
||||
|
||||
const renderTitle = (item: T) => {
|
||||
const title = item[titleProperty] as string;
|
||||
const suffix = titleSuffixProperty ? item[titleSuffixProperty] as string : null;
|
||||
if (suffix) {
|
||||
return <>{title} <small>{suffix}</small></>;
|
||||
}
|
||||
return title;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown text={currentValueData?.[titleProperty] ?? ""} {...restProps}>
|
||||
<Dropdown text={currentValueData ? renderTitle(currentValueData) : ""} {...restProps}>
|
||||
{values.map(item => (
|
||||
<FormListItem
|
||||
onClick={() => onChange(item[keyProperty] as string)}
|
||||
@@ -22,9 +33,9 @@ export default function FormDropdownList<T>({ values, keyProperty, titleProperty
|
||||
description={descriptionProperty && item[descriptionProperty] as string}
|
||||
selected={currentValue === item[keyProperty]}
|
||||
>
|
||||
{item[titleProperty] as string}
|
||||
{renderTitle(item)}
|
||||
</FormListItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import type { CSSProperties, HTMLProps, RefObject } from "preact/compat";
|
||||
|
||||
type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
|
||||
@@ -14,16 +15,16 @@ export default function RawHtml({containerRef, ...props}: RawHtmlProps & { conta
|
||||
}
|
||||
|
||||
export function RawHtmlBlock({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject<HTMLDivElement>}) {
|
||||
return <div ref={containerRef} {...getProps(props)} />
|
||||
return <div ref={containerRef} {...getProps(props)} />;
|
||||
}
|
||||
|
||||
function getProps({ className, html, style, onClick }: RawHtmlProps) {
|
||||
return {
|
||||
className: className,
|
||||
className,
|
||||
dangerouslySetInnerHTML: getHtml(html ?? ""),
|
||||
style,
|
||||
onClick
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
||||
@@ -39,3 +40,19 @@ export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
||||
__html: html as string
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders HTML content sanitized via DOMPurify to prevent XSS.
|
||||
* Use this instead of {@link RawHtml} when the HTML originates from
|
||||
* untrusted sources (e.g. LLM responses, user-generated markdown).
|
||||
*/
|
||||
export function SanitizedHtml({ className, html, style }: { className?: string; html: string; style?: CSSProperties }) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export interface SavedData {
|
||||
|
||||
export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, onContentChange, dataSaved, updateInterval }: {
|
||||
noteType: NoteType;
|
||||
note: FNote,
|
||||
note: FNote | null | undefined,
|
||||
noteContext: NoteContext | null | undefined,
|
||||
getData: () => Promise<SavedData | undefined> | SavedData | undefined,
|
||||
onContentChange: (newContent: string) => void,
|
||||
@@ -118,8 +118,8 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on
|
||||
return async () => {
|
||||
const data = await getData();
|
||||
|
||||
// for read only notes
|
||||
if (data === undefined || note.type !== noteType) return;
|
||||
// for read only notes, or if note is not yet available (e.g. lazy creation)
|
||||
if (data === undefined || !note || note.type !== noteType) return;
|
||||
|
||||
protected_session_holder.touchProtectedSessionIfNecessary(note);
|
||||
|
||||
@@ -138,7 +138,7 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on
|
||||
|
||||
// React to note/blob changes.
|
||||
useEffect(() => {
|
||||
if (!blob) return;
|
||||
if (!blob || !note) return;
|
||||
noteSavedDataStore.set(note.noteId, blob.content);
|
||||
spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content));
|
||||
}, [ blob ]);
|
||||
|
||||
@@ -7,6 +7,7 @@ import branches from "../../services/branches";
|
||||
import dialog from "../../services/dialog";
|
||||
import { getAvailableLocales, t } from "../../services/i18n";
|
||||
import mime_types from "../../services/mime_types";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
|
||||
import { NOTE_TYPES } from "../../services/note_types";
|
||||
import protected_session from "../../services/protected_session";
|
||||
import server from "../../services/server";
|
||||
@@ -72,7 +73,7 @@ export function NoteTypeDropdownContent({ currentNoteType, currentNoteMime, note
|
||||
noCodeNotes?: boolean;
|
||||
}) {
|
||||
const mimeTypes = useMimeTypes();
|
||||
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static), []);
|
||||
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static && (nt.type !== "llmChat" || isExperimentalFeatureEnabled("llm"))), []);
|
||||
const changeNoteType = useCallback(async (type: NoteType, mime?: string) => {
|
||||
if (!note || (type === currentNoteType && mime === currentNoteMime)) {
|
||||
return;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createPortal } from "preact/compat";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import FAttribute from "../../entities/fattribute";
|
||||
@@ -74,7 +75,7 @@ export default function InheritedAttributesTab({ note, componentId, emptyListStr
|
||||
)}
|
||||
</div>
|
||||
|
||||
{attributeDetailWidgetEl}
|
||||
{createPortal(attributeDetailWidgetEl, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
|
||||
);
|
||||
const isElectron = getIsElectron();
|
||||
const isMac = getIsMac();
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet"].includes(noteType);
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet", "llmChat"].includes(noteType);
|
||||
const isSearchOrBook = ["search", "book"].includes(noteType);
|
||||
const isHelpPage = note.noteId.startsWith("_help");
|
||||
const [syncServerHost] = useTriliumOption("syncServerHost");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AttributeEditor as CKEditorAttributeEditor, MentionFeed, ModelElement, ModelNode, ModelPosition } from "@triliumnext/ckeditor5";
|
||||
import { AttributeType } from "@triliumnext/commons";
|
||||
import { createPortal } from "preact/compat";
|
||||
import { MutableRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import type { CommandData, FilteredCommandNames } from "../../../components/app_context";
|
||||
@@ -336,7 +337,8 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
|
||||
let matchedAttr: Attribute | null = null;
|
||||
|
||||
for (const attr of parsedAttrs) {
|
||||
if (attr.startIndex && clickIndex > attr.startIndex && attr.endIndex && clickIndex <= attr.endIndex) {
|
||||
if (attr.startIndex !== undefined && clickIndex > attr.startIndex &&
|
||||
attr.endIndex !== undefined && clickIndex <= attr.endIndex) {
|
||||
matchedAttr = attr;
|
||||
break;
|
||||
}
|
||||
@@ -407,7 +409,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{attributeDetailWidgetEl}
|
||||
{createPortal(attributeDetailWidgetEl, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext from "../../components/app_context";
|
||||
import { WidgetsByParent } from "../../services/bundle";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
|
||||
import { t } from "../../services/i18n";
|
||||
import options from "../../services/options";
|
||||
import { DEFAULT_GUTTER_SIZE } from "../../services/resizer";
|
||||
@@ -19,6 +20,7 @@ import PdfAttachments from "./pdf/PdfAttachments";
|
||||
import PdfLayers from "./pdf/PdfLayers";
|
||||
import PdfPages from "./pdf/PdfPages";
|
||||
import RightPanelWidget from "./RightPanelWidget";
|
||||
import SidebarChat from "./SidebarChat";
|
||||
import TableOfContents from "./TableOfContents";
|
||||
|
||||
const MIN_WIDTH_PERCENT = 5;
|
||||
@@ -91,6 +93,11 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent) {
|
||||
el: <HighlightsList />,
|
||||
enabled: noteType === "text" && highlightsList.length > 0,
|
||||
},
|
||||
{
|
||||
el: <SidebarChat />,
|
||||
enabled: noteType !== "llmChat" && isExperimentalFeatureEnabled("llm"),
|
||||
position: 1000
|
||||
},
|
||||
...widgetsByParent.getLegacyWidgets("right-pane").map((widget) => ({
|
||||
el: <CustomLegacyWidget key={widget._noteId} originalWidget={widget as LegacyRightPanelWidget} />,
|
||||
enabled: true,
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function RightPanelWidget({ id, title, buttons, children, contain
|
||||
>
|
||||
<ActionButton icon="bx bx-chevron-down" text="" />
|
||||
<div class="card-header-title">{title}</div>
|
||||
<div class="card-header-buttons">
|
||||
<div class="card-header-buttons" onClick={e => e.stopPropagation()}>
|
||||
{buttons}
|
||||
{contextMenuItems && (
|
||||
<ActionButton
|
||||
|
||||
113
apps/client/src/widgets/sidebar/SidebarChat.css
Normal file
113
apps/client/src/widgets/sidebar/SidebarChat.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* Sidebar Chat Widget Styles */
|
||||
|
||||
.sidebar-chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0; /* Allow shrinking in flex context */
|
||||
overflow: hidden; /* Contain children within available space */
|
||||
}
|
||||
|
||||
.sidebar-chat-container .llm-chat-input-form {
|
||||
flex-shrink: 0; /* Keep input bar from shrinking */
|
||||
|
||||
.llm-chat-input {
|
||||
font-size: 0.9em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-chat-messages {
|
||||
flex: 1;
|
||||
min-height: 0; /* Allow flex shrinking for scroll containment */
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Reuse llm-chat-message styles but make them more compact */
|
||||
.sidebar-chat-messages .llm-chat-message-wrapper {
|
||||
margin-top: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-chat-messages .llm-chat-message {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sidebar-chat-messages .llm-chat-message-role {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-chat-messages .llm-chat-tool-activity {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
margin-bottom: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Make the sidebar chat widget grow to fill available space when expanded */
|
||||
#right-pane .widget.grow:not(.collapsed) {
|
||||
flex: 1;
|
||||
flex-shrink: 1; /* Override flex-shrink: 0 from main styles */
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#right-pane .widget.grow:not(.collapsed) .body-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* Override overflow: auto from main styles */
|
||||
}
|
||||
|
||||
#right-pane .widget.grow:not(.collapsed) .card-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden; /* Override overflow: auto - let child handle scrolling */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Compact markdown in sidebar */
|
||||
.sidebar-chat-messages .llm-chat-markdown {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sidebar-chat-messages .llm-chat-markdown p {
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
.sidebar-chat-messages .llm-chat-markdown pre {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebar-chat-messages .llm-chat-markdown code {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.sidebar-chat-history-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-chat-history-item-content span,
|
||||
.sidebar-chat-history-item-content strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-chat-history-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-text-color);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
335
apps/client/src/widgets/sidebar/SidebarChat.tsx
Normal file
335
apps/client/src/widgets/sidebar/SidebarChat.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,12 @@ import SyncOptions from "./options/sync";
|
||||
import OtherSettings from "./options/other";
|
||||
import InternationalizationOptions from "./options/i18n";
|
||||
import AdvancedSettings from "./options/advanced";
|
||||
import LlmSettings from "./options/llm";
|
||||
import "./ContentWidget.css";
|
||||
import { t } from "../../services/i18n";
|
||||
import BackendLog from "./code/BackendLog";
|
||||
|
||||
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
|
||||
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced" | "_optionsLlm";
|
||||
|
||||
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetProps) => JSX.Element> = {
|
||||
_optionsAppearance: AppearanceSettings,
|
||||
@@ -35,6 +36,7 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetPro
|
||||
_optionsOther: OtherSettings,
|
||||
_optionsLocalization: InternationalizationOptions,
|
||||
_optionsAdvanced: AdvancedSettings,
|
||||
_optionsLlm: LlmSettings,
|
||||
_backendLog: BackendLog
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ import "./MindMap.css";
|
||||
|
||||
// allow node-menu plugin css to be bundled by webpack
|
||||
import nodeMenu from "@mind-elixir/node-menu";
|
||||
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
||||
import { snapdom } from "@zumer/snapdom";
|
||||
import { DARK_THEME, default as VanillaMindElixir, MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME } from "mind-elixir";
|
||||
import { t } from "i18next";
|
||||
import { DARK_THEME, default as VanillaMindElixir, MindElixirData, MindElixirInstance, Operation, THEME as LIGHT_THEME } from "mind-elixir";
|
||||
import type { LangPack } from "mind-elixir/i18n";
|
||||
import { HTMLAttributes, RefObject } from "preact";
|
||||
import { useCallback, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
@@ -25,27 +26,22 @@ interface MindElixirProps {
|
||||
onChange?: () => void;
|
||||
}
|
||||
|
||||
const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, Options["locale"] | null> = {
|
||||
ar: null,
|
||||
cn: "zh_CN",
|
||||
de: null,
|
||||
en: "en",
|
||||
en_rtl: "en",
|
||||
"en-GB": "en",
|
||||
es: "es",
|
||||
fr: "fr",
|
||||
ga: null,
|
||||
it: "it",
|
||||
hi: null,
|
||||
ja: "ja",
|
||||
pt: "pt",
|
||||
pl: null,
|
||||
pt_br: "pt",
|
||||
ro: "ro",
|
||||
ru: "ru",
|
||||
tw: "zh_TW",
|
||||
uk: null
|
||||
};
|
||||
function buildMindElixirLangPack(): LangPack {
|
||||
return {
|
||||
addChild: t("mind-map.addChild"),
|
||||
addParent: t("mind-map.addParent"),
|
||||
addSibling: t("mind-map.addSibling"),
|
||||
removeNode: t("mind-map.removeNode"),
|
||||
focus: t("mind-map.focus"),
|
||||
cancelFocus: t("mind-map.cancelFocus"),
|
||||
moveUp: t("mind-map.moveUp"),
|
||||
moveDown: t("mind-map.moveDown"),
|
||||
link: t("mind-map.link"),
|
||||
linkBidirectional: t("mind-map.linkBidirectional"),
|
||||
clickTips: t("mind-map.clickTips"),
|
||||
summary: t("mind-map.summary")
|
||||
};
|
||||
}
|
||||
|
||||
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
|
||||
const apiRef = useRef<MindElixirInstance>(null);
|
||||
@@ -161,8 +157,8 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
|
||||
|
||||
const mind = new VanillaMindElixir({
|
||||
el: containerRef.current,
|
||||
locale: LOCALE_MAPPINGS[locale as DISPLAYABLE_LOCALE_IDS] ?? undefined,
|
||||
editable,
|
||||
contextMenu: { locale: buildMindElixirLangPack() },
|
||||
theme: defaultColorScheme.current === "dark" ? DARK_THEME : LIGHT_THEME
|
||||
});
|
||||
|
||||
|
||||
238
apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx
Normal file
238
apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { RefObject } from "preact";
|
||||
import { useState, useCallback } from "preact/hooks";
|
||||
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import ActionButton from "../../react/ActionButton.js";
|
||||
import Button from "../../react/Button.js";
|
||||
import Dropdown from "../../react/Dropdown.js";
|
||||
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../../react/FormList.js";
|
||||
import type { UseLlmChatReturn } from "./useLlmChat.js";
|
||||
import AddProviderModal, { type LlmProviderConfig } from "../options/llm/AddProviderModal.js";
|
||||
import options from "../../../services/options.js";
|
||||
|
||||
/** Format token count with thousands separators */
|
||||
function formatTokenCount(tokens: number): string {
|
||||
return tokens.toLocaleString();
|
||||
}
|
||||
|
||||
interface ChatInputBarProps {
|
||||
/** The chat hook result */
|
||||
chat: UseLlmChatReturn;
|
||||
/** Number of rows for the textarea (default: 3) */
|
||||
rows?: number;
|
||||
/** Current active note ID (for note context toggle) */
|
||||
activeNoteId?: string;
|
||||
/** Current active note title (for note context toggle) */
|
||||
activeNoteTitle?: string;
|
||||
/** Custom submit handler (overrides chat.handleSubmit) */
|
||||
onSubmit?: (e: Event) => void;
|
||||
/** Custom key down handler (overrides chat.handleKeyDown) */
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
/** Callback when web search toggle changes */
|
||||
onWebSearchChange?: () => void;
|
||||
/** Callback when note tools toggle changes */
|
||||
onNoteToolsChange?: () => void;
|
||||
/** Callback when extended thinking toggle changes */
|
||||
onExtendedThinkingChange?: () => void;
|
||||
/** Callback when model changes */
|
||||
onModelChange?: (model: string) => void;
|
||||
}
|
||||
|
||||
export default function ChatInputBar({
|
||||
chat,
|
||||
rows = 3,
|
||||
activeNoteId,
|
||||
activeNoteTitle,
|
||||
onSubmit,
|
||||
onKeyDown,
|
||||
onWebSearchChange,
|
||||
onNoteToolsChange,
|
||||
onExtendedThinkingChange,
|
||||
onModelChange
|
||||
}: ChatInputBarProps) {
|
||||
const [showAddProviderModal, setShowAddProviderModal] = useState(false);
|
||||
|
||||
const handleSubmit = onSubmit ?? chat.handleSubmit;
|
||||
const handleKeyDown = onKeyDown ?? chat.handleKeyDown;
|
||||
|
||||
const handleWebSearchToggle = (newValue: boolean) => {
|
||||
chat.setEnableWebSearch(newValue);
|
||||
onWebSearchChange?.();
|
||||
};
|
||||
|
||||
const handleNoteToolsToggle = (newValue: boolean) => {
|
||||
chat.setEnableNoteTools(newValue);
|
||||
onNoteToolsChange?.();
|
||||
};
|
||||
|
||||
const handleExtendedThinkingToggle = (newValue: boolean) => {
|
||||
chat.setEnableExtendedThinking(newValue);
|
||||
onExtendedThinkingChange?.();
|
||||
};
|
||||
|
||||
const handleModelSelect = (model: string) => {
|
||||
chat.setSelectedModel(model);
|
||||
onModelChange?.(model);
|
||||
};
|
||||
|
||||
const handleNoteContextToggle = () => {
|
||||
if (chat.contextNoteId) {
|
||||
chat.setContextNoteId(undefined);
|
||||
} else if (activeNoteId) {
|
||||
chat.setContextNoteId(activeNoteId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProvider = useCallback(async (provider: LlmProviderConfig) => {
|
||||
// Get current providers and add the new one
|
||||
const currentProviders = options.getJson("llmProviders") || [];
|
||||
const newProviders = [...currentProviders, provider];
|
||||
await options.save("llmProviders", JSON.stringify(newProviders));
|
||||
// Refresh models to pick up the new provider
|
||||
chat.refreshModels();
|
||||
}, [chat]);
|
||||
|
||||
const isNoteContextEnabled = !!chat.contextNoteId && !!activeNoteId;
|
||||
|
||||
const currentModel = chat.availableModels.find(m => m.id === chat.selectedModel);
|
||||
const currentModels = chat.availableModels.filter(m => !m.isLegacy);
|
||||
const legacyModels = chat.availableModels.filter(m => m.isLegacy);
|
||||
const contextWindow = currentModel?.contextWindow || 200000;
|
||||
const percentage = Math.min((chat.lastPromptTokens / contextWindow) * 100, 100);
|
||||
const isWarning = percentage > 75;
|
||||
const isCritical = percentage > 90;
|
||||
const pieColor = isCritical ? "var(--danger-color, #d9534f)" : isWarning ? "var(--warning-color, #f0ad4e)" : "var(--main-selection-color, #007bff)";
|
||||
|
||||
// Show setup prompt if no provider is configured
|
||||
if (!chat.isCheckingProvider && !chat.hasProvider) {
|
||||
return (
|
||||
<div className="llm-chat-no-provider">
|
||||
<div className="llm-chat-no-provider-content">
|
||||
<span className="bx bx-bot llm-chat-no-provider-icon" />
|
||||
<p>{t("llm_chat.no_provider_message")}</p>
|
||||
<Button
|
||||
text={t("llm_chat.add_provider")}
|
||||
icon="bx bx-plus"
|
||||
onClick={() => setShowAddProviderModal(true)}
|
||||
/>
|
||||
</div>
|
||||
<AddProviderModal
|
||||
show={showAddProviderModal}
|
||||
onHidden={() => setShowAddProviderModal(false)}
|
||||
onSave={handleAddProvider}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="llm-chat-input-form" onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
ref={chat.textareaRef as RefObject<HTMLTextAreaElement>}
|
||||
className="llm-chat-input"
|
||||
value={chat.input}
|
||||
onInput={(e) => chat.setInput((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder={t("llm_chat.placeholder")}
|
||||
disabled={chat.isStreaming}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={rows}
|
||||
/>
|
||||
<div className="llm-chat-options">
|
||||
<div className="llm-chat-model-selector">
|
||||
<span className="bx bx-chip" />
|
||||
<Dropdown
|
||||
text={<>{currentModel?.name}</>}
|
||||
disabled={chat.isStreaming}
|
||||
buttonClassName="llm-chat-model-select"
|
||||
>
|
||||
{currentModels.map(model => (
|
||||
<FormListItem
|
||||
key={model.id}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
checked={chat.selectedModel === model.id}
|
||||
>
|
||||
{model.name} <small>({model.costDescription})</small>
|
||||
</FormListItem>
|
||||
))}
|
||||
{legacyModels.length > 0 && (
|
||||
<>
|
||||
<FormDropdownDivider />
|
||||
<FormDropdownSubmenu
|
||||
icon="bx bx-history"
|
||||
title={t("llm_chat.legacy_models")}
|
||||
>
|
||||
{legacyModels.map(model => (
|
||||
<FormListItem
|
||||
key={model.id}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
checked={chat.selectedModel === model.id}
|
||||
>
|
||||
{model.name} <small>({model.costDescription})</small>
|
||||
</FormListItem>
|
||||
))}
|
||||
</FormDropdownSubmenu>
|
||||
</>
|
||||
)}
|
||||
<FormDropdownDivider />
|
||||
<FormListToggleableItem
|
||||
icon="bx bx-globe"
|
||||
title={t("llm_chat.web_search")}
|
||||
currentValue={chat.enableWebSearch}
|
||||
onChange={handleWebSearchToggle}
|
||||
disabled={chat.isStreaming}
|
||||
/>
|
||||
<FormListToggleableItem
|
||||
icon="bx bx-note"
|
||||
title={t("llm_chat.note_tools")}
|
||||
currentValue={chat.enableNoteTools}
|
||||
onChange={handleNoteToolsToggle}
|
||||
disabled={chat.isStreaming}
|
||||
/>
|
||||
<FormListToggleableItem
|
||||
icon="bx bx-brain"
|
||||
title={t("llm_chat.extended_thinking")}
|
||||
currentValue={chat.enableExtendedThinking}
|
||||
onChange={handleExtendedThinkingToggle}
|
||||
disabled={chat.isStreaming}
|
||||
/>
|
||||
</Dropdown>
|
||||
{activeNoteId && activeNoteTitle && (
|
||||
<Button
|
||||
text={activeNoteTitle}
|
||||
icon={isNoteContextEnabled ? "bx-file" : "bx-hide"}
|
||||
kind="lowProfile"
|
||||
size="micro"
|
||||
className={`llm-chat-note-context ${isNoteContextEnabled ? "active" : ""}`}
|
||||
onClick={handleNoteContextToggle}
|
||||
disabled={chat.isStreaming}
|
||||
title={isNoteContextEnabled
|
||||
? t("llm_chat.note_context_enabled", { title: activeNoteTitle })
|
||||
: t("llm_chat.note_context_disabled")}
|
||||
/>
|
||||
)}
|
||||
{chat.lastPromptTokens > 0 && (
|
||||
<div
|
||||
className="llm-chat-context-indicator"
|
||||
title={`${formatTokenCount(chat.lastPromptTokens)} / ${formatTokenCount(contextWindow)} ${t("llm_chat.tokens")}`}
|
||||
>
|
||||
<div
|
||||
className="llm-chat-context-pie"
|
||||
style={{
|
||||
background: `conic-gradient(${pieColor} ${percentage}%, var(--accented-background-color) ${percentage}%)`
|
||||
}}
|
||||
/>
|
||||
<span className="llm-chat-context-text">{t("llm_chat.context_used", { percentage: percentage.toFixed(0) })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={chat.isStreaming ? "bx bx-loader-alt bx-spin" : "bx bx-send"}
|
||||
text={chat.isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
|
||||
onClick={handleSubmit}
|
||||
disabled={chat.isStreaming || !chat.input.trim()}
|
||||
className="llm-chat-send-btn"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
315
apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx
Normal file
315
apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
737
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css
Normal file
737
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css
Normal file
@@ -0,0 +1,737 @@
|
||||
.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;
|
||||
}
|
||||
109
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx
Normal file
109
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import "./LlmChat.css";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import { useEditorSpacedUpdate } from "../../react/hooks.js";
|
||||
import NoItems from "../../react/NoItems.js";
|
||||
import { TypeWidgetProps } from "../type_widget.js";
|
||||
import ChatInputBar from "./ChatInputBar.js";
|
||||
import ChatMessage from "./ChatMessage.js";
|
||||
import type { LlmChatContent } from "./llm_chat_types.js";
|
||||
import { useLlmChat } from "./useLlmChat.js";
|
||||
|
||||
export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
|
||||
const spacedUpdateRef = useRef<{ scheduleUpdate: () => void }>(null);
|
||||
|
||||
const chat = useLlmChat(
|
||||
// onMessagesChange - trigger save
|
||||
() => spacedUpdateRef.current?.scheduleUpdate(),
|
||||
{ defaultEnableNoteTools: false, supportsExtendedThinking: true, chatNoteId: note?.noteId }
|
||||
);
|
||||
|
||||
// Keep chatNoteId in sync when the note changes
|
||||
useEffect(() => {
|
||||
chat.setChatNoteId(note?.noteId);
|
||||
}, [note?.noteId, chat.setChatNoteId]);
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
noteType: "llmChat",
|
||||
noteContext,
|
||||
getData: () => {
|
||||
const content = chat.getContent();
|
||||
return { content: JSON.stringify(content) };
|
||||
},
|
||||
onContentChange: (content) => {
|
||||
if (!content) {
|
||||
chat.clearMessages();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed: LlmChatContent = JSON.parse(content);
|
||||
chat.loadFromContent(parsed);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse LLM chat content:", e);
|
||||
chat.clearMessages();
|
||||
}
|
||||
}
|
||||
});
|
||||
spacedUpdateRef.current = spacedUpdate;
|
||||
|
||||
const triggerSave = useCallback(() => {
|
||||
spacedUpdateRef.current?.scheduleUpdate();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="llm-chat-container">
|
||||
<div className="llm-chat-messages">
|
||||
{chat.messages.length === 0 && !chat.isStreaming && (
|
||||
<NoItems
|
||||
icon="bx bx-conversation"
|
||||
text={t("llm_chat.empty_state")}
|
||||
/>
|
||||
)}
|
||||
{chat.messages.map(msg => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
{chat.toolActivity && !chat.streamingThinking && (
|
||||
<div className="llm-chat-tool-activity">
|
||||
<span className="llm-chat-tool-spinner" />
|
||||
{chat.toolActivity}
|
||||
</div>
|
||||
)}
|
||||
{chat.isStreaming && chat.streamingThinking && (
|
||||
<ChatMessage
|
||||
message={{
|
||||
id: "streaming-thinking",
|
||||
role: "assistant",
|
||||
content: chat.streamingThinking,
|
||||
createdAt: new Date().toISOString(),
|
||||
type: "thinking"
|
||||
}}
|
||||
isStreaming
|
||||
/>
|
||||
)}
|
||||
{chat.isStreaming && chat.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { LlmCitation, LlmUsage } from "@triliumnext/commons";
|
||||
|
||||
export type MessageType = "message" | "error" | "thinking";
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
/** A block of text content (rendered as Markdown for assistant messages). */
|
||||
export interface TextBlock {
|
||||
type: "text";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** A tool invocation block shown inline in the message timeline. */
|
||||
export interface ToolCallBlock {
|
||||
type: "tool_call";
|
||||
toolCall: ToolCall;
|
||||
}
|
||||
|
||||
/** An ordered content block in an assistant message. */
|
||||
export type ContentBlock = TextBlock | ToolCallBlock;
|
||||
|
||||
/**
|
||||
* Extract the plain text from message content (works for both legacy string and block formats).
|
||||
*/
|
||||
export function getMessageText(content: string | ContentBlock[]): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return content
|
||||
.filter((b): b is TextBlock => b.type === "text")
|
||||
.map(b => b.content)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool calls from message content blocks.
|
||||
*/
|
||||
export function getMessageToolCalls(message: StoredMessage): ToolCall[] {
|
||||
// Legacy format: tool calls stored in separate field
|
||||
if (message.toolCalls) {
|
||||
return message.toolCalls;
|
||||
}
|
||||
// Block format: extract from content blocks
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.filter((b): b is ToolCallBlock => b.type === "tool_call")
|
||||
.map(b => b.toolCall);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export interface StoredMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
/** Message content: plain string (user messages, legacy) or ordered content blocks (assistant). */
|
||||
content: string | ContentBlock[];
|
||||
createdAt: string;
|
||||
citations?: LlmCitation[];
|
||||
/** Message type for special rendering. Defaults to "message" if omitted. */
|
||||
type?: MessageType;
|
||||
/** @deprecated Tool calls are now inline in content blocks. Kept for backward compatibility. */
|
||||
toolCalls?: ToolCall[];
|
||||
/** Token usage for this response */
|
||||
usage?: LlmUsage;
|
||||
}
|
||||
|
||||
export interface LlmChatContent {
|
||||
version: 1;
|
||||
messages: StoredMessage[];
|
||||
selectedModel?: string;
|
||||
enableWebSearch?: boolean;
|
||||
enableNoteTools?: boolean;
|
||||
enableExtendedThinking?: boolean;
|
||||
}
|
||||
415
apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts
Normal file
415
apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
125
apps/client/src/widgets/type_widgets/options/llm.tsx
Normal file
125
apps/client/src/widgets/type_widgets/options/llm.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createPortal } from "preact/compat";
|
||||
import { useState, useRef } from "preact/hooks";
|
||||
import Modal from "../../../react/Modal";
|
||||
import FormGroup from "../../../react/FormGroup";
|
||||
import FormSelect from "../../../react/FormSelect";
|
||||
import FormTextBox from "../../../react/FormTextBox";
|
||||
import { t } from "../../../../services/i18n";
|
||||
|
||||
export interface LlmProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface ProviderType {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const PROVIDER_TYPES: ProviderType[] = [
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
{ id: "google", name: "Google Gemini" }
|
||||
];
|
||||
|
||||
interface AddProviderModalProps {
|
||||
show: boolean;
|
||||
onHidden: () => void;
|
||||
onSave: (provider: LlmProviderConfig) => void;
|
||||
}
|
||||
|
||||
export default function AddProviderModal({ show, onHidden, onSave }: AddProviderModalProps) {
|
||||
const [selectedProvider, setSelectedProvider] = useState(PROVIDER_TYPES[0].id);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
function handleSubmit() {
|
||||
if (!apiKey.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providerType = PROVIDER_TYPES.find(p => p.id === selectedProvider);
|
||||
const newProvider: LlmProviderConfig = {
|
||||
id: `${selectedProvider}_${Date.now()}`,
|
||||
name: providerType?.name || selectedProvider,
|
||||
provider: selectedProvider,
|
||||
apiKey: apiKey.trim()
|
||||
};
|
||||
|
||||
onSave(newProvider);
|
||||
resetForm();
|
||||
onHidden();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setSelectedProvider(PROVIDER_TYPES[0].id);
|
||||
setApiKey("");
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
resetForm();
|
||||
onHidden();
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<Modal
|
||||
show={show}
|
||||
onHidden={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
formRef={formRef}
|
||||
title={t("llm.add_provider_title")}
|
||||
className="add-provider-modal"
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleCancel}>
|
||||
{t("llm.cancel")}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={!apiKey.trim()}>
|
||||
{t("llm.add_provider")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormGroup name="provider-type" label={t("llm.provider_type")}>
|
||||
<FormSelect
|
||||
values={PROVIDER_TYPES}
|
||||
keyProperty="id"
|
||||
titleProperty="name"
|
||||
currentValue={selectedProvider}
|
||||
onChange={setSelectedProvider}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup name="api-key" label={t("llm.api_key")}>
|
||||
<FormTextBox
|
||||
type="password"
|
||||
currentValue={apiKey}
|
||||
onChange={setApiKey}
|
||||
placeholder={t("llm.api_key_placeholder")}
|
||||
autoFocus
|
||||
/>
|
||||
</FormGroup>
|
||||
</Modal>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,10 @@ import note_create from "../../../services/note_create";
|
||||
import options from "../../../services/options";
|
||||
import toast from "../../../services/toast";
|
||||
import utils, { hasTouchBar, isMobile } from "../../../services/utils";
|
||||
import { useEditorSpacedUpdate, useLegacyImperativeHandlers, useNoteLabel, useNoteProperty, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import { useEditorSpacedUpdate, useLegacyImperativeHandlers, useNoteLabel, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import TouchBar, { TouchBarButton, TouchBarGroup, TouchBarSegmentedControl } from "../../react/TouchBar";
|
||||
import { TypeWidgetProps } from "../type_widget";
|
||||
import CKEditorWithWatchdog, { CKEditorApi } from "./CKEditorWithWatchdog";
|
||||
import LexicalText from "./lexical";
|
||||
import getTemplates, { updateTemplateCache } from "./snippets.js";
|
||||
import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./utils";
|
||||
|
||||
@@ -28,15 +27,7 @@ import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./util
|
||||
* - Ballon block mode, in which there is a floating toolbar for the selected text, but another floating button for the entire block (i.e. paragraph).
|
||||
* - Decoupled mode, in which the editing toolbar is actually added on the client side (in {@link ClassicEditorToolbar}), see https://ckeditor.com/docs/ckeditor5/latest/examples/framework/bottom-toolbar-editor.html for an example on how the decoupled editor works.
|
||||
*/
|
||||
export default function EditableText(props: TypeWidgetProps) {
|
||||
const mime = useNoteProperty(props.note, "mime");
|
||||
if (mime === "application/json") {
|
||||
return <LexicalText {...props} />;
|
||||
}
|
||||
return <EditableTextCKEditor {...props} />;
|
||||
}
|
||||
|
||||
function EditableTextCKEditor({ note, parentComponent, ntxId, noteContext }: TypeWidgetProps) {
|
||||
export default function EditableText({ note, parentComponent, ntxId, noteContext }: TypeWidgetProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<string>("");
|
||||
const watchdogRef = useRef<EditorWatchdog>(null);
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
.note-detail-editable-text {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
margin-bottom: 1px;
|
||||
background: var(--classic-toolbar-vert-layout-background-color);
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
margin: 20px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.toolbar .divider {
|
||||
width: 1px;
|
||||
background-color: var(--main-border-color);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.toolbar .toolbar-item .text {
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
width: 200px;
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
color: #777;
|
||||
text-overflow: ellipsis;
|
||||
width: 70px;
|
||||
overflow: hidden;
|
||||
height: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toolbar .toolbar-item .icon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
user-select: none;
|
||||
margin-right: 8px;
|
||||
line-height: 16px;
|
||||
background-size: contain;
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import "./ToolbarPlugin.css";
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {mergeRegister} from '@lexical/utils';
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
CAN_REDO_COMMAND,
|
||||
CAN_UNDO_COMMAND,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
FORMAT_ELEMENT_COMMAND,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
REDO_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
UNDO_COMMAND,
|
||||
} from 'lexical';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import ActionButton, { ActionButtonProps } from "../../../react/ActionButton";
|
||||
|
||||
function Divider() {
|
||||
return <div className="divider" />;
|
||||
}
|
||||
|
||||
export default function ToolbarPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const toolbarRef = useRef(null);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [isItalic, setIsItalic] = useState(false);
|
||||
const [isUnderline, setIsUnderline] = useState(false);
|
||||
const [isStrikethrough, setIsStrikethrough] = useState(false);
|
||||
|
||||
const $updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
// Update text format
|
||||
setIsBold(selection.hasFormat('bold'));
|
||||
setIsItalic(selection.hasFormat('italic'));
|
||||
setIsUnderline(selection.hasFormat('underline'));
|
||||
setIsStrikethrough(selection.hasFormat('strikethrough'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({editorState}) => {
|
||||
editorState.read(
|
||||
() => {
|
||||
$updateToolbar();
|
||||
},
|
||||
{editor},
|
||||
);
|
||||
}),
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, _newEditor) => {
|
||||
$updateToolbar();
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
CAN_UNDO_COMMAND,
|
||||
(payload) => {
|
||||
setCanUndo(payload);
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
CAN_REDO_COMMAND,
|
||||
(payload) => {
|
||||
setCanRedo(payload);
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, $updateToolbar]);
|
||||
|
||||
return (
|
||||
<div className="toolbar" ref={toolbarRef}>
|
||||
<ToolbarButton
|
||||
disabled={!canUndo}
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(UNDO_COMMAND, undefined);
|
||||
}}
|
||||
text="Undo"
|
||||
icon="bx bx-undo"
|
||||
/>
|
||||
<ToolbarButton
|
||||
disabled={!canRedo}
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(REDO_COMMAND, undefined);
|
||||
}}
|
||||
text="Redo"
|
||||
icon="bx bx-redo"
|
||||
/>
|
||||
<Divider />
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
|
||||
}}
|
||||
active={isBold}
|
||||
text="Format Bold"
|
||||
icon="bx bx-bold"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
|
||||
}}
|
||||
active={isItalic}
|
||||
text="Format Italics"
|
||||
icon="bx bx-italic"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
|
||||
}}
|
||||
active={isUnderline}
|
||||
text="Format Underline"
|
||||
icon="bx bx-underline"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
|
||||
}}
|
||||
active={isStrikethrough}
|
||||
text="Format Strikethrough"
|
||||
icon="bx bx-strikethrough"
|
||||
/>
|
||||
<Divider />
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left');
|
||||
}}
|
||||
text="Left Align"
|
||||
icon="bx bx-align-left"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center');
|
||||
}}
|
||||
text="Center Align"
|
||||
icon="bx bx-align-middle"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right')}
|
||||
text="Right Align"
|
||||
icon="bx bx-align-right"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify')}
|
||||
text="Justify Align"
|
||||
icon="bx bx-align-justify"
|
||||
/>{' '}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton(props: Pick<ActionButtonProps, "icon" | "disabled" | "onClick" | "text">) {
|
||||
return (
|
||||
<ActionButton
|
||||
className="toolbar-item"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
.note-detail-editable-text .lexical-wrapper {
|
||||
color: var(--main-text-color);
|
||||
font-family: var(--main-font-family);
|
||||
font-size: var(--main-font-size);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
margin-inline: var(--content-margin-inline);
|
||||
|
||||
>div[contenteditable="true"] {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.lexical-placeholder {
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import "./index.css";
|
||||
|
||||
import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
|
||||
import {LexicalComposer} from '@lexical/react/LexicalComposer';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
|
||||
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
|
||||
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
||||
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
|
||||
import {$createRangeSelection, $getRoot, $setSelection, CLEAR_HISTORY_COMMAND} from 'lexical';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { useEditorSpacedUpdate, useTriliumEvent } from '../../../react/hooks';
|
||||
import { TypeWidgetProps } from "../../type_widget";
|
||||
import ToolbarPlugin from "./ToolbarPlugin";
|
||||
|
||||
const theme = {
|
||||
// Theme styling goes here
|
||||
//...
|
||||
};
|
||||
|
||||
// Catch any errors that occur during Lexical updates and log them
|
||||
// or throw them as needed. If you don't throw them, Lexical will
|
||||
// try to recover gracefully without losing user data.
|
||||
function onError(error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
export default function LexicalText(props: TypeWidgetProps) {
|
||||
const initialConfig = {
|
||||
namespace: 'MyEditor',
|
||||
theme,
|
||||
onError,
|
||||
};
|
||||
|
||||
const placeholder = (
|
||||
<div className="lexical-placeholder">
|
||||
Enter some text...
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<ToolbarPlugin />
|
||||
<div className="lexical-wrapper">
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable /> as never}
|
||||
placeholder={placeholder as never}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
</div>
|
||||
<HistoryPlugin />
|
||||
<AutoFocusPlugin />
|
||||
<ScrollToEndPlugin />
|
||||
<CustomEditorPersistencePlugin {...props} />
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomEditorPersistencePlugin({ note, noteContext }: TypeWidgetProps) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
noteContext,
|
||||
noteType: "text",
|
||||
getData() {
|
||||
return {
|
||||
content: JSON.stringify(editor.toJSON().editorState)
|
||||
};
|
||||
},
|
||||
onContentChange(newContent) {
|
||||
if (!newContent) {
|
||||
editor.update(() => {
|
||||
$getRoot().clear();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const editorState = editor.parseEditorState(newContent);
|
||||
editor.setEditorState(editorState);
|
||||
} catch (err) {
|
||||
console.error("Error parsing Lexical content", err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Clear the history whenever note changes.
|
||||
useEffect(() => {
|
||||
editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
|
||||
}, [ editor, note ]);
|
||||
|
||||
// Detect changes in content.
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(() => {
|
||||
spacedUpdate.scheduleUpdate();
|
||||
});
|
||||
}, [ spacedUpdate, editor ]);
|
||||
}
|
||||
|
||||
function ScrollToEndPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useTriliumEvent("scrollToEnd", () => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const lastChild = root.getLastDescendant();
|
||||
if (lastChild) {
|
||||
const selection = $createRangeSelection();
|
||||
selection.anchor.set(lastChild.getKey(), lastChild.getTextContentSize(), 'text');
|
||||
selection.focus.set(lastChild.getKey(), lastChild.getTextContentSize(), 'text');
|
||||
$setSelection(selection);
|
||||
}
|
||||
});
|
||||
editor.focus();
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -19,15 +19,15 @@ if (isDev) {
|
||||
plugins = [
|
||||
viteStaticCopy({
|
||||
targets: assets.map((asset) => ({
|
||||
src: `src/${asset}/*`,
|
||||
dest: asset
|
||||
src: `src/${asset}/**/*`,
|
||||
dest: asset,
|
||||
rename: { stripBase: 2 }
|
||||
}))
|
||||
}),
|
||||
viteStaticCopy({
|
||||
structured: true,
|
||||
targets: [
|
||||
{
|
||||
src: "../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
|
||||
src: "../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/**/*",
|
||||
dest: "",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Tool to compare content of Trilium databases. Useful for debugging sync problems.",
|
||||
"dependencies": {
|
||||
"colors": "1.4.0",
|
||||
"diff": "8.0.3",
|
||||
"diff": "8.0.4",
|
||||
"sqlite": "5.1.1",
|
||||
"sqlite3": "6.0.1"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"start-no-dir": "cross-env TRILIUM_PORT=37743 tsx ../../scripts/electron-start.mts src/main.ts",
|
||||
"build": "tsx scripts/build.ts",
|
||||
"start-prod": "pnpm build && cross-env TRILIUM_DATA_DIR=data TRILIUM_PORT=37841 ELECTRON_IS_DEV=0 electron dist",
|
||||
"start-prod-no-dir": "pnpm build && cross-env TRILIUM_PORT=37841 ELECTRON_IS_DEV=0 electron dist",
|
||||
"electron-forge:make": "pnpm build && electron-forge make dist",
|
||||
"electron-forge:make-flatpak": "pnpm build && DEBUG=* electron-forge make dist --targets=@electron-forge/maker-flatpak",
|
||||
"electron-forge:package": "pnpm build && electron-forge package dist",
|
||||
@@ -35,7 +36,7 @@
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "41.0.3",
|
||||
"electron": "41.1.0",
|
||||
"@electron-forge/cli": "7.11.1",
|
||||
"@electron-forge/maker-deb": "7.11.1",
|
||||
"@electron-forge/maker-dmg": "7.11.1",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.8.0",
|
||||
"mime-types": "3.0.2",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sanitize-filename": "1.6.4",
|
||||
"tsx": "4.21.0",
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "41.0.3",
|
||||
"electron": "41.1.0",
|
||||
"fs-extra": "11.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.14.0-bullseye-slim AS builder
|
||||
FROM node:24.14.1-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.0-bullseye-slim
|
||||
FROM node:24.14.1-bullseye-slim
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.14.0-alpine AS builder
|
||||
FROM node:24.14.1-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.0-alpine
|
||||
FROM node:24.14.1-alpine
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache su-exec shadow
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.14.0-alpine AS builder
|
||||
FROM node:24.14.1-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.0-alpine
|
||||
FROM node:24.14.1-alpine
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.14.0-bullseye-slim AS builder
|
||||
FROM node:24.14.1-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.0-bullseye-slim
|
||||
FROM node:24.14.1-bullseye-slim
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
"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",
|
||||
@@ -70,7 +75,7 @@
|
||||
"@types/xml2js": "0.4.14",
|
||||
"archiver": "7.0.1",
|
||||
"async-mutex": "0.5.0",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.14.0",
|
||||
"bindings": "1.5.0",
|
||||
"bootstrap": "5.3.8",
|
||||
"chardet": "2.1.1",
|
||||
@@ -83,14 +88,14 @@
|
||||
"debounce": "3.0.0",
|
||||
"debug": "4.4.3",
|
||||
"ejs": "5.0.1",
|
||||
"electron": "41.0.3",
|
||||
"electron": "41.1.0",
|
||||
"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.19.4",
|
||||
"express-rate-limit": "8.3.1",
|
||||
"express-openid-connect": "2.20.1",
|
||||
"express-rate-limit": "8.3.2",
|
||||
"express-session": "1.19.0",
|
||||
"file-uri-to-path": "2.0.0",
|
||||
"fs-extra": "11.3.4",
|
||||
@@ -99,21 +104,21 @@
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "8.0.0",
|
||||
"https-proxy-agent": "8.0.0",
|
||||
"i18next": "25.8.18",
|
||||
"i18next": "25.10.10",
|
||||
"i18next-fs-backend": "2.6.1",
|
||||
"image-type": "6.0.0",
|
||||
"image-type": "6.1.0",
|
||||
"ini": "6.0.0",
|
||||
"is-animated": "2.0.2",
|
||||
"is-svg": "6.1.0",
|
||||
"jimp": "1.6.0",
|
||||
"lorem-ipsum": "2.0.8",
|
||||
"marked": "17.0.4",
|
||||
"marked": "17.0.5",
|
||||
"mime-types": "3.0.2",
|
||||
"multer": "2.1.1",
|
||||
"normalize-strings": "1.1.1",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sanitize-filename": "1.6.4",
|
||||
"sanitize-html": "2.17.2",
|
||||
"sax": "1.6.0",
|
||||
"serve-favicon": "2.5.1",
|
||||
@@ -126,8 +131,8 @@
|
||||
"tmp": "0.2.5",
|
||||
"turnish": "1.8.0",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "8.0.1",
|
||||
"ws": "8.19.0",
|
||||
"vite": "8.0.3",
|
||||
"ws": "8.20.0",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.1"
|
||||
}
|
||||
|
||||
160
apps/server/spec/etapi/mcp.spec.ts
Normal file
160
apps/server/spec/etapi/mcp.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ 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";
|
||||
@@ -55,7 +56,16 @@ export default async function buildApp() {
|
||||
});
|
||||
|
||||
if (!utils.isElectron) {
|
||||
app.use(compression()); // HTTP compression
|
||||
app.use(compression({
|
||||
// Skip compression for SSE endpoints to enable real-time streaming
|
||||
filter: (req, res) => {
|
||||
// Skip compression for SSE-capable endpoints
|
||||
if (req.path === "/api/llm-chat/stream" || req.path === "/mcp") {
|
||||
return false;
|
||||
}
|
||||
return compression.filter(req, res);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let resourcePolicy = config["Network"]["corsResourcePolicy"] as 'same-origin' | 'same-site' | 'cross-origin' | undefined;
|
||||
@@ -81,6 +91,10 @@ 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")));
|
||||
|
||||
@@ -79,7 +79,7 @@ CREATE UNIQUE INDEX `IDX_entityChanges_entityName_entityId` ON "entity_changes"
|
||||
`entityId`
|
||||
);
|
||||
CREATE INDEX `IDX_branches_noteId_parentNoteId` ON `branches` (`noteId`,`parentNoteId`);
|
||||
CREATE INDEX IDX_branches_parentNoteId ON branches (parentNoteId);
|
||||
CREATE INDEX IDX_branches_parentNoteId_isDeleted_notePosition ON branches (parentNoteId, isDeleted, notePosition);
|
||||
CREATE INDEX `IDX_notes_title` ON `notes` (`title`);
|
||||
CREATE INDEX `IDX_notes_type` ON `notes` (`type`);
|
||||
CREATE INDEX `IDX_notes_dateCreated` ON `notes` (`dateCreated`);
|
||||
@@ -146,6 +146,13 @@ CREATE INDEX IDX_notes_blobId on notes (blobId);
|
||||
CREATE INDEX IDX_revisions_blobId on revisions (blobId);
|
||||
CREATE INDEX IDX_attachments_blobId on attachments (blobId);
|
||||
|
||||
CREATE INDEX IDX_entity_changes_isSynced_id ON entity_changes (isSynced, id);
|
||||
CREATE INDEX IDX_entity_changes_isErased_entityName ON entity_changes (isErased, entityName);
|
||||
CREATE INDEX IDX_notes_isDeleted_utcDateModified ON notes (isDeleted, utcDateModified);
|
||||
CREATE INDEX IDX_branches_isDeleted_utcDateModified ON branches (isDeleted, utcDateModified);
|
||||
CREATE INDEX IDX_attributes_isDeleted_utcDateModified ON attributes (isDeleted, utcDateModified);
|
||||
CREATE INDEX IDX_attachments_isDeleted_utcDateModified ON attachments (isDeleted, utcDateModified);
|
||||
CREATE INDEX IDX_attachments_utcDateScheduledForErasureSince ON attachments (utcDateScheduledForErasureSince);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
156
apps/server/src/assets/llm/skills/backend_scripting.md
Normal file
156
apps/server/src/assets/llm/skills/backend_scripting.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Trilium Backend Scripting
|
||||
|
||||
Backend scripts run in Node.js on the server. They have direct access to notes in memory and can interact with the system (files, processes).
|
||||
|
||||
## Creating a backend script
|
||||
|
||||
1. Create a Code note with language "JS backend".
|
||||
2. The script can be run manually (Execute button) or triggered automatically.
|
||||
|
||||
## Script API (`api` global)
|
||||
|
||||
### Note retrieval
|
||||
- `api.getNote(noteId)` - get note by ID
|
||||
- `api.searchForNotes(query, searchParams)` - search notes (returns array)
|
||||
- `api.searchForNote(query)` - search notes (returns first match)
|
||||
- `api.getNotesWithLabel(name, value?)` - find notes by label
|
||||
- `api.getNoteWithLabel(name, value?)` - find first note by label
|
||||
- `api.getBranch(branchId)` - get branch by ID
|
||||
- `api.getAttribute(attributeId)` - get attribute by ID
|
||||
|
||||
### Note creation
|
||||
- `api.createTextNote(parentNoteId, title, content)` - create text note
|
||||
- `api.createDataNote(parentNoteId, title, content)` - create JSON note
|
||||
- `api.createNewNote({ parentNoteId, title, content, type })` - create note with full options
|
||||
|
||||
### Branch management
|
||||
- `api.ensureNoteIsPresentInParent(noteId, parentNoteId, prefix?)` - create or reuse branch
|
||||
- `api.ensureNoteIsAbsentFromParent(noteId, parentNoteId)` - remove branch if exists
|
||||
- `api.toggleNoteInParent(present, noteId, parentNoteId, prefix?)` - toggle branch
|
||||
|
||||
### Calendar/date notes
|
||||
- `api.getTodayNote()` - get/create today's day note
|
||||
- `api.getDayNote(date)` - get/create day note (YYYY-MM-DD)
|
||||
- `api.getWeekNote(date)` - get/create week note
|
||||
- `api.getMonthNote(date)` - get/create month note (YYYY-MM)
|
||||
- `api.getYearNote(year)` - get/create year note (YYYY)
|
||||
|
||||
### Utilities
|
||||
- `api.log(message)` - log to Trilium logs and UI
|
||||
- `api.randomString(length)` - generate random string
|
||||
- `api.escapeHtml(string)` / `api.unescapeHtml(string)`
|
||||
- `api.getInstanceName()` - get instance name
|
||||
- `api.getAppInfo()` - get application info
|
||||
|
||||
### Libraries
|
||||
- `api.axios` - HTTP client
|
||||
- `api.dayjs` - date manipulation
|
||||
- `api.xml2js` - XML parser
|
||||
- `api.cheerio` - HTML/XML parser
|
||||
|
||||
### Advanced
|
||||
- `api.transactional(func)` - wrap code in a database transaction
|
||||
- `api.sql` - direct SQL access
|
||||
- `api.sortNotes(parentNoteId, sortConfig)` - sort child notes
|
||||
- `api.runOnFrontend(script, params)` - execute code on all connected frontends
|
||||
- `api.backupNow(backupName)` - create a backup
|
||||
- `api.exportSubtreeToZipFile(noteId, format, zipFilePath)` - export subtree (format: "markdown" or "html")
|
||||
- `api.duplicateSubtree(origNoteId, newParentNoteId)` - clone note and children
|
||||
|
||||
## BNote object
|
||||
|
||||
Available on notes returned from API methods (`api.getNote()`, `api.originEntity`, etc.).
|
||||
|
||||
### Content
|
||||
- `note.getContent()` / `note.setContent(content)`
|
||||
- `note.getJsonContent()` / `note.setJsonContent(obj)`
|
||||
- `note.getJsonContentSafely()` - returns null on parse error
|
||||
|
||||
### Properties
|
||||
- `note.noteId`, `note.title`, `note.type`, `note.mime`
|
||||
- `note.dateCreated`, `note.dateModified`
|
||||
- `note.isProtected`, `note.isArchived`
|
||||
|
||||
### Hierarchy
|
||||
- `note.getParentNotes()` / `note.getChildNotes()`
|
||||
- `note.getParentBranches()` / `note.getChildBranches()`
|
||||
- `note.hasChildren()`, `note.getAncestors()`
|
||||
- `note.getSubtreeNoteIds()` - all descendant IDs
|
||||
- `note.hasAncestor(ancestorNoteId)`
|
||||
|
||||
### Attributes (including inherited)
|
||||
- `note.getLabels(name?)` / `note.getLabelValue(name)`
|
||||
- `note.getRelations(name?)` / `note.getRelation(name)`
|
||||
- `note.hasLabel(name, value?)` / `note.hasRelation(name, value?)`
|
||||
|
||||
### Attribute modification
|
||||
- `note.setLabel(name, value?)` / `note.removeLabel(name, value?)`
|
||||
- `note.setRelation(name, targetNoteId)` / `note.removeRelation(name, value?)`
|
||||
- `note.addLabel(name, value?, isInheritable?)` / `note.addRelation(name, targetNoteId, isInheritable?)`
|
||||
- `note.toggleLabel(enabled, name, value?)`
|
||||
|
||||
### Operations
|
||||
- `note.save()` - persist changes
|
||||
- `note.deleteNote()` - soft delete
|
||||
- `note.cloneTo(parentNoteId)` - clone to another parent
|
||||
|
||||
### Type checks
|
||||
- `note.isJson()`, `note.isJavaScript()`, `note.isHtml()`, `note.isImage()`
|
||||
- `note.hasStringContent()` - true if not binary
|
||||
|
||||
## Events and triggers
|
||||
|
||||
### Global events (via `#run` label on the script note)
|
||||
- `#run=backendStartup` - run when server starts
|
||||
- `#run=hourly` - run once per hour (use `#runAtHour=N` to specify which hours)
|
||||
- `#run=daily` - run once per day
|
||||
|
||||
### Entity events (via relation from the entity to the script note)
|
||||
These are defined as relations. `api.originEntity` contains the entity that triggered the event.
|
||||
|
||||
| Relation | Trigger | originEntity |
|
||||
|---|---|---|
|
||||
| `~runOnNoteCreation` | note created | BNote |
|
||||
| `~runOnChildNoteCreation` | child note created under this note | BNote (child) |
|
||||
| `~runOnNoteTitleChange` | note title changed | BNote |
|
||||
| `~runOnNoteContentChange` | note content changed | BNote |
|
||||
| `~runOnNoteChange` | note metadata changed (not content) | BNote |
|
||||
| `~runOnNoteDeletion` | note deleted | BNote |
|
||||
| `~runOnBranchCreation` | branch created (clone/move) | BBranch |
|
||||
| `~runOnBranchChange` | branch updated | BBranch |
|
||||
| `~runOnBranchDeletion` | branch deleted | BBranch |
|
||||
| `~runOnAttributeCreation` | attribute created on this note | BAttribute |
|
||||
| `~runOnAttributeChange` | attribute changed/deleted on this note | BAttribute |
|
||||
|
||||
Relations can be inheritable — when set, they apply to all descendant notes.
|
||||
|
||||
## Example: auto-color notes by category
|
||||
|
||||
```javascript
|
||||
// Attach via ~runOnAttributeChange relation
|
||||
const attr = api.originEntity;
|
||||
if (attr.name !== "mycategory") return;
|
||||
const note = api.getNote(attr.noteId);
|
||||
if (attr.value === "Health") {
|
||||
note.setLabel("color", "green");
|
||||
} else {
|
||||
note.removeLabel("color");
|
||||
}
|
||||
```
|
||||
|
||||
## Example: create a daily summary
|
||||
|
||||
```javascript
|
||||
// Attach #run=daily label
|
||||
const today = api.getTodayNote();
|
||||
const tasks = api.searchForNotes('#task #!completed');
|
||||
let summary = "## Open Tasks\n";
|
||||
for (const task of tasks) {
|
||||
summary += `- ${task.title}\n`;
|
||||
}
|
||||
api.createTextNote(today.noteId, "Daily Summary", summary);
|
||||
```
|
||||
|
||||
## Module system
|
||||
|
||||
Child notes of a script act as modules. Export with `module.exports = ...` and import via function parameters matching the child note title, or use `require('noteName')`.
|
||||
240
apps/server/src/assets/llm/skills/frontend_scripting.md
Normal file
240
apps/server/src/assets/llm/skills/frontend_scripting.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Trilium Frontend Scripting
|
||||
|
||||
Frontend scripts run in the browser. They can manipulate the UI, navigate notes, show dialogs, and create custom widgets.
|
||||
|
||||
IMPORTANT: Always prefer Preact JSX widgets over legacy jQuery widgets. Use JSX code notes with `import`/`export` syntax.
|
||||
|
||||
CRITICAL: In JSX notes, always use top-level `import` statements (e.g. `import { useState } from "trilium:preact"`). NEVER use dynamic `await import()` for Preact imports — this will break hooks and components. Dynamic imports are not needed because JSX notes natively support ES module `import`/`export` syntax.
|
||||
|
||||
## Creating a frontend script
|
||||
|
||||
1. Create a Code note with language "JSX" (preferred) or "JS frontend" (legacy only).
|
||||
2. Add `#widget` label for widgets, or `#run=frontendStartup` for auto-run scripts.
|
||||
3. For mobile, use `#run=mobileStartup` instead.
|
||||
|
||||
## Script types
|
||||
|
||||
| Type | Language | Required attribute |
|
||||
|---|---|---|
|
||||
| Custom widget | JSX (preferred) | `#widget` |
|
||||
| Regular script | JS frontend | `#run=frontendStartup` (optional) |
|
||||
| Render note | JSX | None (used via `~renderNote` relation) |
|
||||
|
||||
## Custom widgets (Preact JSX) — preferred
|
||||
|
||||
### Basic widget
|
||||
|
||||
```jsx
|
||||
import { defineWidget } from "trilium:preact";
|
||||
import { useState } from "trilium:preact";
|
||||
|
||||
export default defineWidget({
|
||||
parent: "center-pane",
|
||||
position: 10,
|
||||
render: () => {
|
||||
const [count, setCount] = useState(0);
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setCount(c => c + 1)}>
|
||||
Clicked {count} times
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Note context aware widget (reacts to active note)
|
||||
|
||||
```jsx
|
||||
import { defineWidget, useNoteContext, useNoteProperty } from "trilium:preact";
|
||||
|
||||
export default defineWidget({
|
||||
parent: "note-detail-pane",
|
||||
position: 10,
|
||||
render: () => {
|
||||
const { note } = useNoteContext();
|
||||
const title = useNoteProperty(note, "title");
|
||||
return <span>Current note: {title}</span>;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Right panel widget (sidebar)
|
||||
|
||||
```jsx
|
||||
import { defineWidget, RightPanelWidget, useState, useEffect } from "trilium:preact";
|
||||
|
||||
export default defineWidget({
|
||||
parent: "right-pane",
|
||||
position: 1,
|
||||
render() {
|
||||
const [time, setTime] = useState();
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTime(new Date().toLocaleString());
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
return (
|
||||
<RightPanelWidget id="my-clock" title="Clock">
|
||||
<p>The time is: {time}</p>
|
||||
</RightPanelWidget>
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Widget locations (`parent` values)
|
||||
|
||||
| Value | Description | Notes |
|
||||
|---|---|---|
|
||||
| `left-pane` | Alongside the note tree | |
|
||||
| `center-pane` | Content area, spanning all splits | |
|
||||
| `note-detail-pane` | Inside a note, split-aware | Use `useNoteContext()` hook |
|
||||
| `right-pane` | Right sidebar section | Wrap in `<RightPanelWidget>` |
|
||||
|
||||
### Preact imports
|
||||
|
||||
```jsx
|
||||
// API methods
|
||||
import { showMessage, showError, getNote, searchForNotes, activateNote,
|
||||
runOnBackend, getActiveContextNote } from "trilium:api";
|
||||
|
||||
// Hooks and components
|
||||
import { defineWidget, defineLauncherWidget,
|
||||
useState, useEffect, useCallback, useMemo, useRef,
|
||||
useNoteContext, useActiveNoteContext, useNoteProperty,
|
||||
RightPanelWidget } from "trilium:preact";
|
||||
|
||||
// Built-in UI components
|
||||
import { ActionButton, Button, LinkButton, Modal,
|
||||
NoteAutocomplete, FormTextBox, FormToggle, FormCheckbox,
|
||||
FormDropdownList, FormGroup, FormText, FormTextArea,
|
||||
Icon, LoadingSpinner, Slider, Collapsible } from "trilium:preact";
|
||||
```
|
||||
|
||||
### Custom hooks
|
||||
|
||||
- `useNoteContext()` - returns `{ note }` for the current note context (use in `note-detail-pane`)
|
||||
- `useActiveNoteContext()` - returns `{ note, noteId }` for the active note (works from any widget location)
|
||||
- `useNoteProperty(note, propName)` - reactively watches a note property (e.g. "title", "type")
|
||||
|
||||
### Render notes (JSX)
|
||||
|
||||
For rendering custom content inside a note:
|
||||
1. Create a "render note" (type: Render Note) where you want the content to appear.
|
||||
2. Create a JSX code note **as a child** of the render note, exporting a default component.
|
||||
3. On the render note, add a `~renderNote` relation pointing to the child JSX note.
|
||||
|
||||
IMPORTANT: Always create the JSX code note as a child of the render note, not as a sibling or at the root. This keeps them organized together.
|
||||
|
||||
```jsx
|
||||
export default function MyRenderNote() {
|
||||
return (
|
||||
<>
|
||||
<h1>Custom rendered content</h1>
|
||||
<p>This appears inside the note.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Script API
|
||||
|
||||
In JSX, use `import { method } from "trilium:api"`. In JS frontend, use the `api` global.
|
||||
|
||||
### Navigation & tabs
|
||||
- `activateNote(notePath)` - navigate to a note
|
||||
- `activateNewNote(notePath)` - navigate and wait for sync
|
||||
- `openTabWithNote(notePath, activate?)` - open in new tab
|
||||
- `openSplitWithNote(notePath, activate?)` - open in new split
|
||||
- `getActiveContextNote()` - get currently active note
|
||||
- `getActiveContextNotePath()` - get path of active note
|
||||
- `setHoistedNoteId(noteId)` - hoist/unhoist note
|
||||
|
||||
### Note access & search
|
||||
- `getNote(noteId)` - get note by ID
|
||||
- `getNotes(noteIds)` - bulk fetch notes
|
||||
- `searchForNotes(searchString)` - search with full query syntax
|
||||
- `searchForNote(searchString)` - search returning first result
|
||||
|
||||
### Calendar/date notes
|
||||
- `getTodayNote()` - get/create today's note
|
||||
- `getDayNote(date)` / `getWeekNote(date)` / `getMonthNote(month)` / `getYearNote(year)`
|
||||
|
||||
### Editor access
|
||||
- `getActiveContextTextEditor()` - get CKEditor instance
|
||||
- `getActiveContextCodeEditor()` - get CodeMirror instance
|
||||
- `addTextToActiveContextEditor(text)` - insert text into active editor
|
||||
|
||||
### Dialogs & notifications
|
||||
- `showMessage(msg)` - info toast
|
||||
- `showError(msg)` - error toast
|
||||
- `showConfirmDialog(msg)` - confirm dialog (returns boolean)
|
||||
- `showPromptDialog(msg)` - prompt dialog (returns user input)
|
||||
|
||||
### Backend integration
|
||||
- `runOnBackend(func, params)` - execute a function on the backend
|
||||
|
||||
### UI interaction
|
||||
- `triggerCommand(name, data)` - trigger a command
|
||||
- `bindGlobalShortcut(shortcut, handler, namespace?)` - add keyboard shortcut
|
||||
|
||||
### Utilities
|
||||
- `formatDateISO(date)` - format as YYYY-MM-DD
|
||||
- `randomString(length)` - generate random string
|
||||
- `dayjs` - day.js library
|
||||
- `log(message)` - log to script log pane
|
||||
|
||||
## FNote object
|
||||
|
||||
Available via `getNote()`, `getActiveContextNote()`, `useNoteContext()`, etc.
|
||||
|
||||
### Properties
|
||||
- `note.noteId`, `note.title`, `note.type`, `note.mime`
|
||||
- `note.isProtected`, `note.isArchived`
|
||||
|
||||
### Content
|
||||
- `note.getContent()` - get note content
|
||||
- `note.getJsonContent()` - parse content as JSON
|
||||
|
||||
### Hierarchy
|
||||
- `note.getParentNotes()` / `note.getChildNotes()`
|
||||
- `note.hasChildren()`, `note.getSubtreeNoteIds()`
|
||||
|
||||
### Attributes
|
||||
- `note.getAttributes(type?, name?)` - all attributes (including inherited)
|
||||
- `note.getOwnedAttributes(type?, name?)` - only owned attributes
|
||||
- `note.hasAttribute(type, name)` - check for attribute
|
||||
|
||||
## Legacy jQuery widgets (avoid if possible)
|
||||
|
||||
Only use legacy widgets if you specifically need jQuery or cannot use JSX.
|
||||
|
||||
```javascript
|
||||
// Language: JS frontend, Label: #widget
|
||||
class MyWidget extends api.BasicWidget {
|
||||
get position() { return 1; }
|
||||
get parentWidget() { return "center-pane"; }
|
||||
|
||||
doRender() {
|
||||
this.$widget = $("<div>");
|
||||
this.$widget.append($("<button>Click me</button>")
|
||||
.on("click", () => api.showMessage("Hello!")));
|
||||
return this.$widget;
|
||||
}
|
||||
}
|
||||
module.exports = new MyWidget();
|
||||
```
|
||||
|
||||
Key differences from Preact:
|
||||
- Use `api.` global instead of imports
|
||||
- `get parentWidget()` instead of `parent` field
|
||||
- `module.exports = new MyWidget()` (instance) for most widgets
|
||||
- `module.exports = MyWidget` (class, no `new`) for `note-detail-pane`
|
||||
- Right pane: extend `api.RightPanelWidget`, override `doRenderBody()` instead of `doRender()`
|
||||
|
||||
## Module system
|
||||
|
||||
For JSX, use `import`/`export` syntax between notes. For JS frontend, use `module.exports` and function parameters matching child note titles.
|
||||
50
apps/server/src/assets/llm/skills/search_syntax.md
Normal file
50
apps/server/src/assets/llm/skills/search_syntax.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Trilium Search Syntax
|
||||
|
||||
## Full-text search
|
||||
- `rings tolkien` — notes containing both words
|
||||
- `"The Lord of the Rings"` — exact phrase match
|
||||
|
||||
## Label filters
|
||||
- `#book` — notes with the "book" label
|
||||
- `#!book` — notes WITHOUT the "book" label
|
||||
- `#publicationYear = 1954` — exact value
|
||||
- `#genre *=* fan` — contains substring
|
||||
- `#title =* The` — starts with
|
||||
- `#title *= Rings` — ends with
|
||||
- `#publicationYear >= 1950` — numeric comparison (>, >=, <, <=)
|
||||
- `#dateNote >= TODAY-30` — date keywords: NOW+-seconds, TODAY+-days, MONTH+-months, YEAR+-years
|
||||
- `#phone %= '\d{3}-\d{4}'` — regex match
|
||||
- `#title ~= trilim` — fuzzy exact match (tolerates typos, min 3 chars)
|
||||
- `#content ~* progra` — fuzzy contains match
|
||||
|
||||
## Relation filters
|
||||
- `~author` — notes with an "author" relation
|
||||
- `~author.title *=* Tolkien` — relation target's title contains "Tolkien"
|
||||
- `~author.relations.son.title = 'Christopher Tolkien'` — deep relation traversal
|
||||
|
||||
## Note properties
|
||||
Access via `note.` prefix: noteId, title, type, mime, text, content, rawContent, dateCreated, dateModified, isProtected, isArchived, parentCount, childrenCount, attributeCount, labelCount, relationCount, contentSize, revisionCount.
|
||||
- `note.type = code AND note.mime = 'application/json'`
|
||||
- `note.content *=* searchTerm`
|
||||
|
||||
## Hierarchy
|
||||
- `note.parents.title = 'Books'` — parent named "Books"
|
||||
- `note.ancestors.title = 'Books'` — any ancestor named "Books"
|
||||
- `note.children.title = 'sub-note'` — child named "sub-note"
|
||||
|
||||
## Boolean logic
|
||||
- AND: `#book AND #fantasy` (implicit between adjacent expressions)
|
||||
- OR: `#book OR #author`
|
||||
- NOT: `not(note.ancestors.title = 'Tolkien')`
|
||||
- Parentheses: `(#genre = "fantasy" AND #year >= 1950) OR #award`
|
||||
|
||||
## Combining full-text and attributes
|
||||
- `towers #book` — full-text "towers" AND has #book label
|
||||
- `tolkien #book or #author` — full-text with OR on labels
|
||||
|
||||
## Ordering and limiting
|
||||
- `#author=Tolkien orderBy #publicationDate desc, note.title limit 10`
|
||||
|
||||
## Escaping
|
||||
- `\#hash` — literal # in full-text
|
||||
- Three quote types: single, double, backtick
|
||||
@@ -297,7 +297,8 @@
|
||||
},
|
||||
"quarterNumber": "Quarter {quarterNumber}",
|
||||
"special_notes": {
|
||||
"search_prefix": "Search:"
|
||||
"search_prefix": "Search:",
|
||||
"llm_chat_prefix": "Chat:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "Sync server host is not configured. Please configure sync first.",
|
||||
@@ -308,6 +309,7 @@
|
||||
"search-history-title": "Search History",
|
||||
"note-map-title": "Note Map",
|
||||
"sql-console-history-title": "SQL Console History",
|
||||
"llm-chat-history-title": "AI Chat History",
|
||||
"shared-notes-title": "Shared Notes",
|
||||
"bulk-action-title": "Bulk Action",
|
||||
"backend-log-title": "Backend Log",
|
||||
@@ -351,11 +353,13 @@
|
||||
"sync-title": "Sync",
|
||||
"other": "Other",
|
||||
"advanced-title": "Advanced",
|
||||
"llm-title": "AI / LLM",
|
||||
"visible-launchers-title": "Visible Launchers",
|
||||
"user-guide": "User Guide",
|
||||
"localization": "Language & Region",
|
||||
"inbox-title": "Inbox",
|
||||
"tab-switcher-title": "Tab Switcher"
|
||||
"tab-switcher-title": "Tab Switcher",
|
||||
"sidebar-chat-title": "AI Chat"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "New note",
|
||||
|
||||
@@ -1,440 +1,445 @@
|
||||
{
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"",
|
||||
"search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active",
|
||||
"expand-subtree": "Développer le sous-arbre de la note actuelle",
|
||||
"collapse-tree": "Réduire toute l'arborescence des notes",
|
||||
"collapse-subtree": "Réduire le sous-arbre de la note actuelle",
|
||||
"sort-child-notes": "Trier les notes enfants",
|
||||
"creating-and-moving-notes": "Créer et déplacer des notes",
|
||||
"create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière",
|
||||
"delete-note": "Supprimer la note",
|
||||
"move-note-up": "Déplacer la note vers le haut",
|
||||
"move-note-down": "Déplacer la note vers le bas",
|
||||
"move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie",
|
||||
"move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie",
|
||||
"edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre",
|
||||
"edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche",
|
||||
"note-clipboard": "Note presse-papiers",
|
||||
"copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers",
|
||||
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active",
|
||||
"cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers",
|
||||
"select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active",
|
||||
"add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection",
|
||||
"add-note-below-to-selection": "Ajouter la note en dessous de la sélection",
|
||||
"duplicate-subtree": "Dupliquer le sous-arbre",
|
||||
"tabs-and-windows": "Onglets et fenêtres",
|
||||
"open-new-tab": "Ouvrir un nouvel onglet",
|
||||
"close-active-tab": "Fermer l'onglet actif",
|
||||
"reopen-last-tab": "Rouvrir le dernier onglet fermé",
|
||||
"activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif",
|
||||
"activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif",
|
||||
"open-new-window": "Ouvrir une nouvelle fenêtre vide",
|
||||
"toggle-tray": "Afficher/masquer l'application dans la barre des tâches",
|
||||
"first-tab": "Basculer vers le premier onglet dans la liste",
|
||||
"second-tab": "Basculer vers le deuxième onglet dans la liste",
|
||||
"third-tab": "Basculer vers le troisième onglet dans la liste",
|
||||
"fourth-tab": "Basculer vers le quatrième onglet dans la liste",
|
||||
"fifth-tab": "Basculer vers le cinquième onglet dans la liste",
|
||||
"sixth-tab": "Basculer vers le sixième onglet dans la liste",
|
||||
"seventh-tab": "Basculer vers le septième onglet dans la liste",
|
||||
"eight-tab": "Basculer vers le huitième onglet dans la liste",
|
||||
"ninth-tab": "Basculer vers le neuvième onglet dans la liste",
|
||||
"last-tab": "Basculer vers le dernier onglet dans la liste",
|
||||
"dialogs": "Boîtes de dialogue",
|
||||
"show-note-source": "Affiche la boîte de dialogue Source de la note",
|
||||
"show-options": "Afficher les Options",
|
||||
"show-revisions": "Afficher la boîte de dialogue Versions de la note",
|
||||
"show-recent-changes": "Afficher la boîte de dialogue Modifications récentes",
|
||||
"show-sql-console": "Afficher la boîte de dialogue Console SQL",
|
||||
"show-backend-log": "Afficher la boîte de dialogue Journal du backend",
|
||||
"text-note-operations": "Opérations sur les notes textuelles",
|
||||
"add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte",
|
||||
"follow-link-under-cursor": "Suivre le lien sous le curseur",
|
||||
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
|
||||
"paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers",
|
||||
"cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné",
|
||||
"add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note",
|
||||
"edit-readonly-note": "Éditer une note en lecture seule",
|
||||
"attributes-labels-and-relations": "Attributs (labels et relations)",
|
||||
"add-new-label": "Créer un nouveau label",
|
||||
"create-new-relation": "Créer une nouvelle relation",
|
||||
"ribbon-tabs": "Onglets du ruban",
|
||||
"toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note",
|
||||
"toggle-file-properties": "Afficher/masquer les Propriétés du fichier",
|
||||
"toggle-image-properties": "Afficher/masquer les Propriétés de l'image",
|
||||
"toggle-owned-attributes": "Afficher/masquer les Attributs propres",
|
||||
"toggle-inherited-attributes": "Afficher/masquer les Attributs hérités",
|
||||
"toggle-promoted-attributes": "Afficher/masquer les Attributs promus",
|
||||
"toggle-link-map": "Afficher/masquer la Carte de la note",
|
||||
"toggle-note-info": "Afficher/masquer les Informations de la note",
|
||||
"toggle-note-paths": "Afficher/masquer les Emplacements de la note",
|
||||
"toggle-similar-notes": "Afficher/masquer les Notes similaires",
|
||||
"other": "Autre",
|
||||
"toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations",
|
||||
"print-active-note": "Imprimer la note active",
|
||||
"open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut",
|
||||
"render-active-note": "Rendre (ou re-rendre) la note active",
|
||||
"run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active",
|
||||
"toggle-note-hoisting": "Activer le focus sur la note active",
|
||||
"unhoist": "Désactiver tout focus",
|
||||
"reload-frontend-app": "Recharger l'application",
|
||||
"open-dev-tools": "Ouvrir les outils de développement",
|
||||
"toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)",
|
||||
"toggle-full-screen": "Basculer en plein écran",
|
||||
"zoom-out": "Dézoomer",
|
||||
"zoom-in": "Zoomer",
|
||||
"note-navigation": "Navigation dans les notes",
|
||||
"reset-zoom-level": "Réinitialiser le niveau de zoom",
|
||||
"copy-without-formatting": "Copier le texte sélectionné sans mise en forme",
|
||||
"force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active",
|
||||
"show-help": "Affiche le guide de l'utilisateur intégré",
|
||||
"toggle-book-properties": "Afficher/masquer les Propriétés du Livre",
|
||||
"toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
|
||||
"export-as-pdf": "Exporte la note actuelle en PDF",
|
||||
"show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes",
|
||||
"toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)",
|
||||
"back-in-note-history": "Naviguer à la note précédente dans l'historique",
|
||||
"forward-in-note-history": "Naviguer a la note suivante dans l'historique",
|
||||
"open-command-palette": "Ouvrir la palette de commandes",
|
||||
"clone-notes-to": "Cloner les nœuds sélectionnés",
|
||||
"move-notes-to": "Déplacer les nœuds sélectionnés",
|
||||
"scroll-to-active-note": "Faire défiler l’arborescence 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 l’historique des notes",
|
||||
"forward-in-note-history": "Suivant dans l’historique 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 l’arborescence",
|
||||
"collapse-subtree": "Réduire la sous-arborescence",
|
||||
"sort-child-notes": "Trier les notes enfants",
|
||||
"create-note-after": "Créer une note après",
|
||||
"create-note-into": "Créer une note dans",
|
||||
"create-note-into-inbox": "Créer une note dans Inbox",
|
||||
"delete-notes": "Supprimer les notes",
|
||||
"move-note-up": "Remonter la note",
|
||||
"move-note-down": "Descendre la note",
|
||||
"move-note-up-in-hierarchy": "Monter la note dans la hiérarchie",
|
||||
"move-note-down-in-hierarchy": "Descendre la note dans la hiérarchie",
|
||||
"edit-note-title": "Modifier le titre de la note",
|
||||
"edit-branch-prefix": "Modifier le préfixe de la branche",
|
||||
"clone-notes-to": "Cloner les notes vers",
|
||||
"move-notes-to": "Déplacer les notes vers",
|
||||
"copy-notes-to-clipboard": "Copier les notes dans le presse-papiers",
|
||||
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers",
|
||||
"cut-notes-to-clipboard": "Couper les notes vers le presse-papier",
|
||||
"select-all-notes-in-parent": "Selectionner toutes les notes dans le parent",
|
||||
"add-note-above-to-selection": "Ajouter la note au-dessus à la selection",
|
||||
"add-note-below-to-selection": "Ajouter la note dessous à la selection",
|
||||
"duplicate-subtree": "Dupliquer la sous-arborescence",
|
||||
"open-new-tab": "Ouvrir un nouvel onglet",
|
||||
"close-active-tab": "Fermer l'onglet actif",
|
||||
"reopen-last-tab": "Réouvrir le dernier onglet",
|
||||
"activate-next-tab": "Activer l'onglet suivant",
|
||||
"activate-previous-tab": "Activer l'onglet précédent",
|
||||
"open-new-window": "Ouvrir une nouvelle fenêtre",
|
||||
"toggle-system-tray-icon": "Activer/Désactiver l'icone de la barre d'état",
|
||||
"toggle-zen-mode": "Activer/Désactiver le mode Zen",
|
||||
"switch-to-first-tab": "Aller au premier onglet",
|
||||
"switch-to-second-tab": "Aller au second onglet",
|
||||
"switch-to-third-tab": "Aller au troisième onglet",
|
||||
"switch-to-fourth-tab": "Aller au quatrième onglet",
|
||||
"switch-to-fifth-tab": "Aller au cinquième onglet",
|
||||
"switch-to-sixth-tab": "Aller au sixième onglet",
|
||||
"switch-to-seventh-tab": "Aller au septième onglet",
|
||||
"switch-to-eighth-tab": "Aller au huitième onglet",
|
||||
"switch-to-ninth-tab": "Aller au neuvième onglet",
|
||||
"switch-to-last-tab": "Aller au dernier onglet",
|
||||
"show-note-source": "Afficher la source de la note",
|
||||
"show-options": "Afficher les options",
|
||||
"show-revisions": "Afficher les révisions",
|
||||
"show-recent-changes": "Afficher les changements récents",
|
||||
"show-sql-console": "Afficher la console SQL",
|
||||
"show-backend-log": "Afficher le journal du backend",
|
||||
"show-help": "Afficher l'aide",
|
||||
"show-cheatsheet": "Afficher la fiche de triche",
|
||||
"add-link-to-text": "Ajouter un lien au texte",
|
||||
"follow-link-under-cursor": "Suivre le lien en dessous du curseur",
|
||||
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
|
||||
"paste-markdown-into-text": "Coller du Markdown dans le texte",
|
||||
"cut-into-note": "Couper dans une note",
|
||||
"add-include-note-to-text": "Ajouter une note inclusion au texte",
|
||||
"edit-read-only-note": "Modifier une note en lecture seule",
|
||||
"add-new-label": "Ajouter une nouvelle étiquette",
|
||||
"add-new-relation": "Ajouter une nouvelle relation",
|
||||
"toggle-ribbon-tab-classic-editor": "Basculer l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
|
||||
"toggle-ribbon-tab-basic-properties": "Afficher/masquer les Propriétés de base de la note",
|
||||
"toggle-ribbon-tab-book-properties": "Afficher/masquer les Propriétés du Livre",
|
||||
"toggle-ribbon-tab-file-properties": "Afficher/masquer les Propriétés du fichier",
|
||||
"toggle-ribbon-tab-image-properties": "Afficher/masquer les Propriétés de l'image",
|
||||
"toggle-ribbon-tab-owned-attributes": "Afficher/masquer les Attributs propres",
|
||||
"toggle-ribbon-tab-inherited-attributes": "Afficher/masquer les Attributs hérités",
|
||||
"toggle-right-pane": "Afficher le panneau de droite",
|
||||
"print-active-note": "Imprimer la note active",
|
||||
"export-active-note-as-pdf": "Exporter la note active en PDF",
|
||||
"open-note-externally": "Ouvrir la note à l'extérieur",
|
||||
"render-active-note": "Faire un rendu de la note active",
|
||||
"run-active-note": "Lancer la note active",
|
||||
"reload-frontend-app": "Recharger l'application Frontend",
|
||||
"open-developer-tools": "Ouvrir les outils développeur",
|
||||
"find-in-text": "Chercher un texte",
|
||||
"toggle-left-pane": "Afficher le panneau de gauche",
|
||||
"toggle-full-screen": "Passer en mode plein écran",
|
||||
"zoom-out": "Dézoomer",
|
||||
"zoom-in": "Zoomer",
|
||||
"reset-zoom-level": "Réinitilaliser le zoom",
|
||||
"copy-without-formatting": "Copier sans mise en forme",
|
||||
"force-save-revision": "Forcer la sauvegarde de la révision",
|
||||
"toggle-ribbon-tab-promoted-attributes": "Basculer les attributs promus de l'onglet du ruban",
|
||||
"toggle-ribbon-tab-note-map": "Basculer l'onglet du ruban Note Map",
|
||||
"toggle-ribbon-tab-note-info": "Basculer l'onglet du ruban Note Info",
|
||||
"toggle-ribbon-tab-note-paths": "Basculer les chemins de notes de l'onglet du ruban",
|
||||
"toggle-ribbon-tab-similar-notes": "Basculer l'onglet du ruban Notes similaires",
|
||||
"toggle-note-hoisting": "Activer la focalisation sur la note",
|
||||
"unhoist-note": "Désactiver la focalisation sur la note"
|
||||
},
|
||||
"sql_init": {
|
||||
"db_not_initialized_desktop": "Base de données non initialisée, merci de suivre les instructions à l'écran.",
|
||||
"db_not_initialized_server": "Base de données non initialisée, veuillez visitez - http://[your-server-host]:{{port}} pour consulter les instructions d'initialisation de Trilium."
|
||||
},
|
||||
"desktop": {
|
||||
"instance_already_running": "Une instance est déjà en cours d'execution, ouverture de cette instance à la place."
|
||||
},
|
||||
"weekdayNumber": "Semaine {weekNumber}",
|
||||
"quarterNumber": "Trimestre {quarterNumber}",
|
||||
"share_theme": {
|
||||
"site-theme": "Thème du site",
|
||||
"search_placeholder": "Recherche...",
|
||||
"image_alt": "Image de l'article",
|
||||
"last-updated": "Dernière mise à jour le {{- date}}",
|
||||
"subpages": "Sous-pages:",
|
||||
"on-this-page": "Sur cette page",
|
||||
"expand": "Développer"
|
||||
},
|
||||
"hidden_subtree_templates": {
|
||||
"text-snippet": "Extrait de texte",
|
||||
"description": "Description",
|
||||
"list-view": "Vue en liste",
|
||||
"grid-view": "Vue en grille",
|
||||
"calendar": "Calendrier",
|
||||
"table": "Tableau",
|
||||
"geo-map": "Carte géographique",
|
||||
"start-date": "Date de début",
|
||||
"end-date": "Date de fin",
|
||||
"start-time": "Heure de début",
|
||||
"end-time": "Heure de fin",
|
||||
"geolocation": "Géolocalisation",
|
||||
"built-in-templates": "Modèles intégrés",
|
||||
"board": "Tableau de bord",
|
||||
"status": "État",
|
||||
"board_note_first": "Première note",
|
||||
"board_note_second": "Deuxième note",
|
||||
"board_note_third": "Troisième note",
|
||||
"board_status_todo": "A faire",
|
||||
"board_status_progress": "En cours",
|
||||
"board_status_done": "Terminé",
|
||||
"presentation": "Présentation",
|
||||
"presentation_slide": "Diapositive de présentation",
|
||||
"presentation_slide_first": "Première diapositive",
|
||||
"presentation_slide_second": "Deuxième diapositive",
|
||||
"background": "Arrière-plan"
|
||||
}
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"",
|
||||
"search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active",
|
||||
"expand-subtree": "Développer le sous-arbre de la note actuelle",
|
||||
"collapse-tree": "Réduire toute l'arborescence des notes",
|
||||
"collapse-subtree": "Réduire le sous-arbre de la note actuelle",
|
||||
"sort-child-notes": "Trier les notes enfants",
|
||||
"creating-and-moving-notes": "Créer et déplacer des notes",
|
||||
"create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière",
|
||||
"delete-note": "Supprimer la note",
|
||||
"move-note-up": "Déplacer la note vers le haut",
|
||||
"move-note-down": "Déplacer la note vers le bas",
|
||||
"move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie",
|
||||
"move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie",
|
||||
"edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre",
|
||||
"edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche",
|
||||
"note-clipboard": "Note presse-papiers",
|
||||
"copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers",
|
||||
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active",
|
||||
"cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers",
|
||||
"select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active",
|
||||
"add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection",
|
||||
"add-note-below-to-selection": "Ajouter la note en dessous de la sélection",
|
||||
"duplicate-subtree": "Dupliquer le sous-arbre",
|
||||
"tabs-and-windows": "Onglets et fenêtres",
|
||||
"open-new-tab": "Ouvrir un nouvel onglet",
|
||||
"close-active-tab": "Fermer l'onglet actif",
|
||||
"reopen-last-tab": "Rouvrir le dernier onglet fermé",
|
||||
"activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif",
|
||||
"activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif",
|
||||
"open-new-window": "Ouvrir une nouvelle fenêtre vide",
|
||||
"toggle-tray": "Afficher/masquer l'application dans la barre des tâches",
|
||||
"first-tab": "Basculer vers le premier onglet dans la liste",
|
||||
"second-tab": "Basculer vers le deuxième onglet dans la liste",
|
||||
"third-tab": "Basculer vers le troisième onglet dans la liste",
|
||||
"fourth-tab": "Basculer vers le quatrième onglet dans la liste",
|
||||
"fifth-tab": "Basculer vers le cinquième onglet dans la liste",
|
||||
"sixth-tab": "Basculer vers le sixième onglet dans la liste",
|
||||
"seventh-tab": "Basculer vers le septième onglet dans la liste",
|
||||
"eight-tab": "Basculer vers le huitième onglet dans la liste",
|
||||
"ninth-tab": "Basculer vers le neuvième onglet dans la liste",
|
||||
"last-tab": "Basculer vers le dernier onglet dans la liste",
|
||||
"dialogs": "Boîtes de dialogue",
|
||||
"show-note-source": "Affiche la boîte de dialogue Source de la note",
|
||||
"show-options": "Afficher les Options",
|
||||
"show-revisions": "Afficher la boîte de dialogue Versions de la note",
|
||||
"show-recent-changes": "Afficher la boîte de dialogue Modifications récentes",
|
||||
"show-sql-console": "Afficher la boîte de dialogue Console SQL",
|
||||
"show-backend-log": "Afficher la boîte de dialogue Journal du backend",
|
||||
"text-note-operations": "Opérations sur les notes textuelles",
|
||||
"add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte",
|
||||
"follow-link-under-cursor": "Suivre le lien sous le curseur",
|
||||
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
|
||||
"paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers",
|
||||
"cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné",
|
||||
"add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note",
|
||||
"edit-readonly-note": "Éditer une note en lecture seule",
|
||||
"attributes-labels-and-relations": "Attributs (labels et relations)",
|
||||
"add-new-label": "Créer un nouveau label",
|
||||
"create-new-relation": "Créer une nouvelle relation",
|
||||
"ribbon-tabs": "Onglets du ruban",
|
||||
"toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note",
|
||||
"toggle-file-properties": "Afficher/masquer les Propriétés du fichier",
|
||||
"toggle-image-properties": "Afficher/masquer les Propriétés de l'image",
|
||||
"toggle-owned-attributes": "Afficher/masquer les Attributs propres",
|
||||
"toggle-inherited-attributes": "Afficher/masquer les Attributs hérités",
|
||||
"toggle-promoted-attributes": "Afficher/masquer les Attributs promus",
|
||||
"toggle-link-map": "Afficher/masquer la Carte de la note",
|
||||
"toggle-note-info": "Afficher/masquer les Informations de la note",
|
||||
"toggle-note-paths": "Afficher/masquer les Emplacements de la note",
|
||||
"toggle-similar-notes": "Afficher/masquer les Notes similaires",
|
||||
"other": "Autre",
|
||||
"toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations",
|
||||
"print-active-note": "Imprimer la note active",
|
||||
"open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut",
|
||||
"render-active-note": "Rendre (ou re-rendre) la note active",
|
||||
"run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active",
|
||||
"toggle-note-hoisting": "Activer le focus sur la note active",
|
||||
"unhoist": "Désactiver tout focus",
|
||||
"reload-frontend-app": "Recharger l'application",
|
||||
"open-dev-tools": "Ouvrir les outils de développement",
|
||||
"toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)",
|
||||
"toggle-full-screen": "Basculer en plein écran",
|
||||
"zoom-out": "Dézoomer",
|
||||
"zoom-in": "Zoomer",
|
||||
"note-navigation": "Navigation dans les notes",
|
||||
"reset-zoom-level": "Réinitialiser le niveau de zoom",
|
||||
"copy-without-formatting": "Copier le texte sélectionné sans mise en forme",
|
||||
"force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active",
|
||||
"show-help": "Affiche le guide de l'utilisateur intégré",
|
||||
"toggle-book-properties": "Afficher/masquer les Propriétés du Livre",
|
||||
"toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
|
||||
"export-as-pdf": "Exporte la note actuelle en PDF",
|
||||
"show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes",
|
||||
"toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)",
|
||||
"back-in-note-history": "Naviguer à la note précédente dans l'historique",
|
||||
"forward-in-note-history": "Naviguer a la note suivante dans l'historique",
|
||||
"open-command-palette": "Ouvrir la palette de commandes",
|
||||
"clone-notes-to": "Cloner les nœuds sélectionnés",
|
||||
"move-notes-to": "Déplacer les nœuds sélectionnés",
|
||||
"scroll-to-active-note": "Faire défiler l’arborescence 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 l’historique des notes",
|
||||
"forward-in-note-history": "Suivant dans l’historique 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 l’arborescence",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,10 @@
|
||||
"script-launcher-title": "Scorciatoie degli script",
|
||||
"command-palette": "Apri tavolozza comandi",
|
||||
"zen-mode": "Modalità Zen",
|
||||
"tab-switcher-title": "Selettore scheda"
|
||||
"tab-switcher-title": "Selettore scheda",
|
||||
"llm-chat-history-title": "Cronologia chat IA",
|
||||
"llm-title": "AI / LLM",
|
||||
"sidebar-chat-title": "Chat con IA"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Nuova nota",
|
||||
@@ -400,7 +403,8 @@
|
||||
},
|
||||
"quarterNumber": "Quadrimestre n. {quarterNumber}",
|
||||
"special_notes": {
|
||||
"search_prefix": "Ricerca:"
|
||||
"search_prefix": "Ricerca:",
|
||||
"llm_chat_prefix": "Chat:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "L'host del server di sincronizzazione non è impostato. Configurare prima la sincronizzazione.",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"creating-and-moving-notes": "Tworzenie i przenoszenie notatek",
|
||||
"create-note-after": "Utwórz notatkę po aktywnej notatce",
|
||||
"create-note-into": "Utwórz notatkę jako podrzędną aktywnej notatki",
|
||||
"create-note-into-inbox": "Utwórz notatkę w skrzynce odbiorczej (jeśli zdefiniowana) lub notatkę dnia",
|
||||
"create-note-into-inbox": "Utwórz notatkę w skrzynce odbiorczej (jeśli zdefiniowano) lub w notatce dziennej",
|
||||
"delete-note": "Usuń notatkę",
|
||||
"move-note-up": "Przenieś notatkę w górę",
|
||||
"move-note-down": "Przenieś notatkę w dół",
|
||||
@@ -59,7 +59,7 @@
|
||||
"show-backend-log": "Otwórz stronę \"Logi backendu\"",
|
||||
"show-help": "Otwórz wbudowany Poradnik Użytkownika",
|
||||
"show-cheatsheet": "Pokaż listę skrótów klawiszowych",
|
||||
"text-note-operations": "Operacje na notatkach tekstowych",
|
||||
"text-note-operations": "Operacje na notatkach",
|
||||
"add-link-to-text": "Otwórz okno dodawania linku do tekstu",
|
||||
"follow-link-under-cursor": "Podążaj za linkiem pod kursorem",
|
||||
"insert-date-and-time-to-text": "Wstaw aktualną datę i czas",
|
||||
|
||||
@@ -61,7 +61,8 @@ export default class Becca {
|
||||
name = name.substr(1);
|
||||
}
|
||||
|
||||
return this.attributeIndex[`${type}-${name}`] || [];
|
||||
const key = `${type}-${name}`;
|
||||
return Object.hasOwn(this.attributeIndex, key) ? this.attributeIndex[key] : [];
|
||||
}
|
||||
|
||||
findAttributesWithPrefix(type: string, name: string): BAttribute[] {
|
||||
@@ -89,11 +90,11 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getNote(noteId: string): BNote | null {
|
||||
return this.notes[noteId];
|
||||
return Object.hasOwn(this.notes, noteId) ? this.notes[noteId] : null;
|
||||
}
|
||||
|
||||
getNoteOrThrow(noteId: string): BNote {
|
||||
const note = this.notes[noteId];
|
||||
const note = Object.hasOwn(this.notes, noteId) ? this.notes[noteId] : null;
|
||||
if (!note) {
|
||||
throw new NotFoundError(`Note '${noteId}' doesn't exist.`);
|
||||
}
|
||||
@@ -105,7 +106,7 @@ export default class Becca {
|
||||
const filteredNotes: BNote[] = [];
|
||||
|
||||
for (const noteId of noteIds) {
|
||||
const note = this.notes[noteId];
|
||||
const note = Object.hasOwn(this.notes, noteId) ? this.notes[noteId] : null;
|
||||
|
||||
if (!note) {
|
||||
if (ignoreMissing) {
|
||||
@@ -122,7 +123,7 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getBranch(branchId: string): BBranch | null {
|
||||
return this.branches[branchId];
|
||||
return Object.hasOwn(this.branches, branchId) ? this.branches[branchId] : null;
|
||||
}
|
||||
|
||||
getBranchOrThrow(branchId: string): BBranch {
|
||||
@@ -134,7 +135,7 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getAttribute(attributeId: string): BAttribute | null {
|
||||
return this.attributes[attributeId];
|
||||
return Object.hasOwn(this.attributes, attributeId) ? this.attributes[attributeId] : null;
|
||||
}
|
||||
|
||||
getAttributeOrThrow(attributeId: string): BAttribute {
|
||||
@@ -147,7 +148,8 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getBranchFromChildAndParent(childNoteId: string, parentNoteId: string): BBranch | null {
|
||||
return this.childParentToBranch[`${childNoteId}-${parentNoteId}`];
|
||||
const key = `${childNoteId}-${parentNoteId}`;
|
||||
return Object.hasOwn(this.childParentToBranch, key) ? this.childParentToBranch[key] : null;
|
||||
}
|
||||
|
||||
getRevision(revisionId: string): BRevision | null {
|
||||
@@ -195,7 +197,7 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getOption(name: string): BOption | null {
|
||||
return this.options[name];
|
||||
return Object.hasOwn(this.options, name) ? this.options[name] : null;
|
||||
}
|
||||
|
||||
getEtapiTokens(): BEtapiToken[] {
|
||||
@@ -203,7 +205,7 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getEtapiToken(etapiTokenId: string): BEtapiToken | null {
|
||||
return this.etapiTokens[etapiTokenId];
|
||||
return Object.hasOwn(this.etapiTokens, etapiTokenId) ? this.etapiTokens[etapiTokenId] : null;
|
||||
}
|
||||
|
||||
getEntity<T extends AbstractBeccaEntity<T>>(entityName: string, entityId: string): AbstractBeccaEntity<T> | null {
|
||||
@@ -223,7 +225,8 @@ export default class Becca {
|
||||
throw new Error(`Unknown entity name '${camelCaseEntityName}' (original argument '${entityName}')`);
|
||||
}
|
||||
|
||||
return (this as any)[camelCaseEntityName][entityId];
|
||||
const collection = (this as any)[camelCaseEntityName];
|
||||
return Object.hasOwn(collection, entityId) ? collection[entityId] : null;
|
||||
}
|
||||
|
||||
getRecentNotesFromQuery(query: string, params: string[] = []): BRecentNote[] {
|
||||
|
||||
5
apps/server/src/express.d.ts
vendored
5
apps/server/src/express.d.ts
vendored
@@ -17,6 +17,11 @@ export declare module "express-serve-static-core" {
|
||||
"user-agent"?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Response {
|
||||
/** Set to true to prevent apiResultHandler from double-handling the response (e.g., for SSE streams) */
|
||||
triliumResponseHandled?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export declare module "express-session" {
|
||||
|
||||
@@ -6,6 +6,27 @@
|
||||
|
||||
// Migrations should be kept in descending order, so the latest migration is first.
|
||||
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
|
||||
// Add missing database indices for query performance
|
||||
{
|
||||
version: 235,
|
||||
sql: /*sql*/`
|
||||
CREATE INDEX IF NOT EXISTS IDX_entity_changes_isSynced_id
|
||||
ON entity_changes (isSynced, id);
|
||||
CREATE INDEX IF NOT EXISTS IDX_entity_changes_isErased_entityName
|
||||
ON entity_changes (isErased, entityName);
|
||||
CREATE INDEX IF NOT EXISTS IDX_notes_isDeleted_utcDateModified
|
||||
ON notes (isDeleted, utcDateModified);
|
||||
CREATE INDEX IF NOT EXISTS IDX_branches_isDeleted_utcDateModified
|
||||
ON branches (isDeleted, utcDateModified);
|
||||
CREATE INDEX IF NOT EXISTS IDX_attributes_isDeleted_utcDateModified
|
||||
ON attributes (isDeleted, utcDateModified);
|
||||
CREATE INDEX IF NOT EXISTS IDX_attachments_isDeleted_utcDateModified
|
||||
ON attachments (isDeleted, utcDateModified);
|
||||
DROP INDEX IF EXISTS IDX_branches_parentNoteId;
|
||||
CREATE INDEX IF NOT EXISTS IDX_branches_parentNoteId_isDeleted_notePosition
|
||||
ON branches (parentNoteId, isDeleted, notePosition);
|
||||
`
|
||||
},
|
||||
// Migrate aiChat notes to code notes since LLM integration has been removed
|
||||
{
|
||||
version: 234,
|
||||
|
||||
104
apps/server/src/routes/api/llm_chat.ts
Normal file
104
apps/server/src/routes/api/llm_chat.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { LlmMessage } from "@triliumnext/commons";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import { generateChatTitle } from "../../services/llm/chat_title.js";
|
||||
import { getAllModels, getProviderByType, hasConfiguredProviders, type LlmProviderConfig } from "../../services/llm/index.js";
|
||||
import { streamToChunks } from "../../services/llm/stream.js";
|
||||
import log from "../../services/log.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
|
||||
interface ChatRequest {
|
||||
messages: LlmMessage[];
|
||||
config?: LlmProviderConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE endpoint for streaming chat completions.
|
||||
*
|
||||
* Response format (Server-Sent Events):
|
||||
* data: {"type":"text","content":"Hello"}
|
||||
* data: {"type":"text","content":" world"}
|
||||
* data: {"type":"done"}
|
||||
*
|
||||
* On error:
|
||||
* data: {"type":"error","error":"Error message"}
|
||||
*/
|
||||
async function streamChat(req: Request, res: Response) {
|
||||
const { messages, config = {} } = req.body as ChatRequest;
|
||||
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
res.status(400).json({ error: "messages array is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up SSE headers - disable compression and buffering for real-time streaming
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
|
||||
res.flushHeaders();
|
||||
|
||||
// Mark response as handled to prevent double-handling by apiResultHandler
|
||||
res.triliumResponseHandled = true;
|
||||
|
||||
// Type assertion for flush method (available when compression is used)
|
||||
const flushableRes = res as Response & { flush?: () => void };
|
||||
|
||||
try {
|
||||
if (!hasConfiguredProviders()) {
|
||||
res.write(`data: ${JSON.stringify({ type: "error", error: "No LLM providers configured. Please add a provider in Options → AI / LLM." })}\n\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = getProviderByType(config.provider || "anthropic");
|
||||
const result = provider.chat(messages, config);
|
||||
|
||||
// Get pricing and display name for the model
|
||||
const modelId = config.model || provider.getAvailableModels().find(m => m.isDefault)?.id;
|
||||
if (!modelId) {
|
||||
res.write(`data: ${JSON.stringify({ type: "error", error: "No model specified and no default model available for the provider." })}\n\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pricing = provider.getModelPricing(modelId);
|
||||
const modelDisplayName = provider.getAvailableModels().find(m => m.id === modelId)?.name || modelId;
|
||||
for await (const chunk of streamToChunks(result, { model: modelDisplayName, pricing })) {
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
// Flush immediately to ensure real-time streaming
|
||||
if (typeof flushableRes.flush === "function") {
|
||||
flushableRes.flush();
|
||||
}
|
||||
}
|
||||
// Auto-generate a title for the chat note on the first user message
|
||||
const userMessages = messages.filter(m => m.role === "user");
|
||||
if (userMessages.length === 1 && config.chatNoteId) {
|
||||
try {
|
||||
await generateChatTitle(config.chatNoteId, userMessages[0].content);
|
||||
} catch (err) {
|
||||
// Title generation is best-effort; don't fail the chat
|
||||
log.error(`Failed to generate chat title: ${safeExtractMessageAndStackFromError(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
res.write(`data: ${JSON.stringify({ type: "error", error: errorMessage })}\n\n`);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models from all configured providers.
|
||||
*/
|
||||
function getModels(_req: Request, _res: Response) {
|
||||
if (!hasConfiguredProviders()) {
|
||||
return { models: [] };
|
||||
}
|
||||
|
||||
return { models: getAllModels() };
|
||||
}
|
||||
|
||||
export default {
|
||||
streamChat,
|
||||
getModels
|
||||
};
|
||||
@@ -104,7 +104,9 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"experimentalFeatures",
|
||||
"newLayout",
|
||||
"mfaEnabled",
|
||||
"mfaMethod"
|
||||
"mfaMethod",
|
||||
"llmProviders",
|
||||
"mcpEnabled"
|
||||
]);
|
||||
|
||||
function getOptions() {
|
||||
|
||||
@@ -86,6 +86,29 @@ 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());
|
||||
}
|
||||
@@ -119,6 +142,11 @@ export default {
|
||||
saveSqlConsole,
|
||||
createSearchNote,
|
||||
saveSearchNote,
|
||||
createLlmChat,
|
||||
getMostRecentLlmChat,
|
||||
getOrCreateLlmChat,
|
||||
getRecentLlmChats,
|
||||
saveLlmChat,
|
||||
createLauncher,
|
||||
resetLauncher,
|
||||
createOrUpdateScriptLauncherFromApi
|
||||
|
||||
@@ -115,6 +115,7 @@ class FakeResponse extends EventEmitter implements Pick<Response<any, Record<str
|
||||
}
|
||||
|
||||
json(obj) {
|
||||
this.respHeaders["Content-Type"] = "application/json";
|
||||
this.send(JSON.stringify(obj));
|
||||
return this as unknown as MockedResponse;
|
||||
}
|
||||
|
||||
62
apps/server/src/routes/mcp.ts
Normal file
62
apps/server/src/routes/mcp.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -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 as any).triliumResponseHandled) {
|
||||
if (res.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 as unknown as { triliumResponseHandled?: boolean }).triliumResponseHandled || res.headersSent) {
|
||||
if (res.triliumResponseHandled || res.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ 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";
|
||||
@@ -291,6 +292,11 @@ 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);
|
||||
@@ -323,6 +329,10 @@ 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);
|
||||
|
||||
@@ -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 = 234;
|
||||
const APP_DB_VERSION = 235;
|
||||
const SYNC_VERSION = 37;
|
||||
const CLIPPER_PROTOCOL_VERSION = "1.0";
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenS
|
||||
type: "doc",
|
||||
icon: "bx-data"
|
||||
},
|
||||
{
|
||||
id: "_llmChat",
|
||||
title: t("hidden-subtree.llm-chat-history-title"),
|
||||
type: "doc",
|
||||
icon: "bx-message-square-dots"
|
||||
},
|
||||
{
|
||||
id: "_share",
|
||||
title: t("hidden-subtree.shared-notes-title"),
|
||||
@@ -247,6 +253,7 @@ function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenS
|
||||
{ id: "_optionsEtapi", title: t("hidden-subtree.etapi-title"), type: "contentWidget", icon: "bx-extension" },
|
||||
{ id: "_optionsBackup", title: t("hidden-subtree.backup-title"), type: "contentWidget", icon: "bx-data" },
|
||||
{ id: "_optionsSync", title: t("hidden-subtree.sync-title"), type: "contentWidget", icon: "bx-wifi" },
|
||||
{ id: "_optionsLlm", title: t("hidden-subtree.llm-title"), type: "contentWidget", icon: "bx-bot" },
|
||||
{ id: "_optionsAi", title: "AI Chat", type: "contentWidget", enforceDeleted: true },
|
||||
{ id: "_optionsOther", title: t("hidden-subtree.other"), type: "contentWidget", icon: "bx-dots-horizontal" },
|
||||
{ id: "_optionsLocalization", title: t("hidden-subtree.localization"), type: "contentWidget", icon: "bx-world" },
|
||||
|
||||
@@ -78,6 +78,13 @@ export default function buildLaunchBarConfig() {
|
||||
type: "launcher",
|
||||
command: "toggleZenMode",
|
||||
icon: "bx bxs-yin-yang"
|
||||
},
|
||||
{
|
||||
id: "_lbSidebarChat",
|
||||
title: t("hidden-subtree.sidebar-chat-title"),
|
||||
type: "launcher",
|
||||
builtinWidget: "sidebarChat",
|
||||
icon: "bx bx-message-square-dots"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
37
apps/server/src/services/llm/chat_title.ts
Normal file
37
apps/server/src/services/llm/chat_title.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import becca from "../../becca/becca.js";
|
||||
import { getProvider } from "./index.js";
|
||||
import log from "../log.js";
|
||||
import { t } from "i18next";
|
||||
|
||||
/** Default title prefixes that indicate the note hasn't been manually renamed. */
|
||||
function hasDefaultTitle(title: string): boolean {
|
||||
// "Chat: <timestamp>" from sidebar/API-created chats
|
||||
const chatPrefix = t("special_notes.llm_chat_prefix");
|
||||
// "New note" from manually created chats
|
||||
const newNoteTitle = t("notes.new-note");
|
||||
|
||||
return title.startsWith(chatPrefix) || title === newNoteTitle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short descriptive title for a chat note based on the first user message,
|
||||
* then rename the note. Only renames if the note still has a default title.
|
||||
*/
|
||||
export async function generateChatTitle(chatNoteId: string, firstMessage: string): Promise<void> {
|
||||
const note = becca.getNote(chatNoteId);
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasDefaultTitle(note.title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = getProvider();
|
||||
const title = await provider.generateTitle(firstMessage);
|
||||
if (title) {
|
||||
note.title = title;
|
||||
note.save();
|
||||
log.info(`Auto-renamed chat note ${chatNoteId} to "${title}"`);
|
||||
}
|
||||
}
|
||||
138
apps/server/src/services/llm/index.ts
Normal file
138
apps/server/src/services/llm/index.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { LlmProvider, ModelInfo } from "./types.js";
|
||||
import { AnthropicProvider } from "./providers/anthropic.js";
|
||||
import { GoogleProvider } from "./providers/google.js";
|
||||
import { OpenAiProvider } from "./providers/openai.js";
|
||||
import optionService from "../options.js";
|
||||
import log from "../log.js";
|
||||
|
||||
/**
|
||||
* Configuration for a single LLM provider instance.
|
||||
* This matches the structure stored in the llmProviders option.
|
||||
*/
|
||||
export interface LlmProviderSetup {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/** Factory functions for creating provider instances */
|
||||
const providerFactories: Record<string, (apiKey: string) => LlmProvider> = {
|
||||
anthropic: (apiKey) => new AnthropicProvider(apiKey),
|
||||
openai: (apiKey) => new OpenAiProvider(apiKey),
|
||||
google: (apiKey) => new GoogleProvider(apiKey)
|
||||
};
|
||||
|
||||
/** Cache of instantiated providers by their config ID */
|
||||
let cachedProviders: Record<string, LlmProvider> = {};
|
||||
|
||||
/**
|
||||
* Get configured providers from the options.
|
||||
*/
|
||||
function getConfiguredProviders(): LlmProviderSetup[] {
|
||||
try {
|
||||
const providersJson = optionService.getOptionOrNull("llmProviders");
|
||||
if (!providersJson) {
|
||||
return [];
|
||||
}
|
||||
return JSON.parse(providersJson) as LlmProviderSetup[];
|
||||
} catch (e) {
|
||||
log.error(`Failed to parse llmProviders option: ${e}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a provider instance by its configuration ID.
|
||||
* If no ID is provided, returns the first configured provider.
|
||||
*/
|
||||
export function getProvider(providerId?: string): LlmProvider {
|
||||
const configs = getConfiguredProviders();
|
||||
|
||||
if (configs.length === 0) {
|
||||
throw new Error("No LLM providers configured. Please add a provider in Options → AI / LLM.");
|
||||
}
|
||||
|
||||
// Find the requested provider or use the first one
|
||||
const config = providerId
|
||||
? configs.find(c => c.id === providerId)
|
||||
: configs[0];
|
||||
|
||||
if (!config) {
|
||||
throw new Error(`LLM provider not found: ${providerId}`);
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (cachedProviders[config.id]) {
|
||||
return cachedProviders[config.id];
|
||||
}
|
||||
|
||||
// Create new provider instance
|
||||
const factory = providerFactories[config.provider];
|
||||
if (!factory) {
|
||||
throw new Error(`Unknown LLM provider type: ${config.provider}. Available: ${Object.keys(providerFactories).join(", ")}`);
|
||||
}
|
||||
|
||||
const provider = factory(config.apiKey);
|
||||
cachedProviders[config.id] = provider;
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first configured provider of a specific type (e.g., "anthropic").
|
||||
*/
|
||||
export function getProviderByType(providerType: string): LlmProvider {
|
||||
const configs = getConfiguredProviders();
|
||||
const config = configs.find(c => c.provider === providerType);
|
||||
|
||||
if (!config) {
|
||||
throw new Error(`No ${providerType} provider configured. Please add one in Options → AI / LLM.`);
|
||||
}
|
||||
|
||||
return getProvider(config.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any providers are configured.
|
||||
*/
|
||||
export function hasConfiguredProviders(): boolean {
|
||||
return getConfiguredProviders().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models from all configured providers, tagged with their provider type.
|
||||
*/
|
||||
export function getAllModels(): ModelInfo[] {
|
||||
const configs = getConfiguredProviders();
|
||||
const seenProviderTypes = new Set<string>();
|
||||
const allModels: ModelInfo[] = [];
|
||||
|
||||
for (const config of configs) {
|
||||
// Only include models once per provider type (not per config instance)
|
||||
if (seenProviderTypes.has(config.provider)) {
|
||||
continue;
|
||||
}
|
||||
seenProviderTypes.add(config.provider);
|
||||
|
||||
try {
|
||||
const provider = getProvider(config.id);
|
||||
const models = provider.getAvailableModels();
|
||||
for (const model of models) {
|
||||
allModels.push({ ...model, provider: config.provider });
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(`Failed to get models from provider ${config.provider}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return allModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the provider cache. Call this when provider configurations change.
|
||||
*/
|
||||
export function clearProviderCache(): void {
|
||||
cachedProviders = {};
|
||||
}
|
||||
|
||||
export type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing } from "./types.js";
|
||||
168
apps/server/src/services/llm/providers/anthropic.ts
Normal file
168
apps/server/src/services/llm/providers/anthropic.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { createAnthropic, type AnthropicProvider as AnthropicSDKProvider } from "@ai-sdk/anthropic";
|
||||
import { stepCountIs, streamText, type ModelMessage, type ToolSet } from "ai";
|
||||
import type { LlmMessage } from "@triliumnext/commons";
|
||||
|
||||
import type { LlmProviderConfig, StreamResult } from "../types.js";
|
||||
import { BaseProvider, buildModelList } from "./base_provider.js";
|
||||
|
||||
/**
|
||||
* Available Anthropic models with pricing (USD per million tokens).
|
||||
* Source: https://docs.anthropic.com/en/docs/about-claude/models
|
||||
*/
|
||||
const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([
|
||||
// ===== Current Models =====
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
pricing: { input: 3, output: 15 },
|
||||
contextWindow: 1000000,
|
||||
isDefault: true
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
pricing: { input: 5, output: 25 },
|
||||
contextWindow: 1000000
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-4-5-20251001",
|
||||
name: "Claude Haiku 4.5",
|
||||
pricing: { input: 1, output: 5 },
|
||||
contextWindow: 200000
|
||||
},
|
||||
// ===== Legacy Models =====
|
||||
{
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
pricing: { input: 3, output: 15 },
|
||||
contextWindow: 200000,
|
||||
isLegacy: true
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-5-20251101",
|
||||
name: "Claude Opus 4.5",
|
||||
pricing: { input: 5, output: 25 },
|
||||
contextWindow: 200000,
|
||||
isLegacy: true
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-1-20250805",
|
||||
name: "Claude Opus 4.1",
|
||||
pricing: { input: 15, output: 75 },
|
||||
contextWindow: 200000,
|
||||
isLegacy: true
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-20250514",
|
||||
name: "Claude Sonnet 4.0",
|
||||
pricing: { input: 3, output: 15 },
|
||||
contextWindow: 200000,
|
||||
isLegacy: true
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-20250514",
|
||||
name: "Claude Opus 4.0",
|
||||
pricing: { input: 15, output: 75 },
|
||||
contextWindow: 200000,
|
||||
isLegacy: true
|
||||
}
|
||||
]);
|
||||
|
||||
export class AnthropicProvider extends BaseProvider {
|
||||
name = "anthropic";
|
||||
protected defaultModel = "claude-sonnet-4-6";
|
||||
protected titleModel = "claude-haiku-4-5-20251001";
|
||||
protected availableModels = AVAILABLE_MODELS;
|
||||
protected modelPricing = MODEL_PRICING;
|
||||
|
||||
private anthropic: AnthropicSDKProvider;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
super();
|
||||
if (!apiKey) {
|
||||
throw new Error("API key is required for Anthropic provider");
|
||||
}
|
||||
this.anthropic = createAnthropic({ apiKey });
|
||||
}
|
||||
|
||||
protected createModel(modelId: string) {
|
||||
return this.anthropic(modelId);
|
||||
}
|
||||
|
||||
protected override addWebSearchTool(tools: ToolSet): void {
|
||||
tools.web_search = this.anthropic.tools.webSearch_20250305({
|
||||
maxUses: 5
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Override buildMessages to add Anthropic-specific cache control breakpoints.
|
||||
*/
|
||||
protected override buildMessages(chatMessages: LlmMessage[], systemPrompt: string | undefined): ModelMessage[] {
|
||||
const CACHE_CONTROL = { anthropic: { cacheControl: { type: "ephemeral" as const } } };
|
||||
const coreMessages: ModelMessage[] = [];
|
||||
|
||||
if (systemPrompt) {
|
||||
coreMessages.push({
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
providerOptions: CACHE_CONTROL
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < chatMessages.length; i++) {
|
||||
const m = chatMessages[i];
|
||||
const isLastBeforeNewTurn = i === chatMessages.length - 2;
|
||||
// Anthropic rejects empty text content blocks. Replace empty
|
||||
// content (e.g. tool-only assistant turns) with a placeholder
|
||||
// to preserve conversation flow.
|
||||
const content = m.content || "(tool use)";
|
||||
coreMessages.push({
|
||||
role: m.role as "user" | "assistant",
|
||||
content,
|
||||
...(isLastBeforeNewTurn && { providerOptions: CACHE_CONTROL })
|
||||
});
|
||||
}
|
||||
|
||||
return coreMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override chat to add Anthropic-specific extended thinking support.
|
||||
*/
|
||||
override chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult {
|
||||
if (!config.enableExtendedThinking) {
|
||||
return super.chat(messages, config);
|
||||
}
|
||||
|
||||
const systemPrompt = this.buildSystemPrompt(messages, config);
|
||||
const chatMessages = messages.filter(m => m.role !== "system");
|
||||
const coreMessages = this.buildMessages(chatMessages, systemPrompt);
|
||||
|
||||
const thinkingBudget = config.thinkingBudget || 10000;
|
||||
const maxTokens = Math.max(config.maxTokens || 8096, thinkingBudget + 4000);
|
||||
|
||||
const streamOptions: Parameters<typeof streamText>[0] = {
|
||||
model: this.createModel(config.model || this.defaultModel),
|
||||
messages: coreMessages,
|
||||
maxOutputTokens: maxTokens,
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budgetTokens: thinkingBudget
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tools = this.buildTools(config);
|
||||
if (Object.keys(tools).length > 0) {
|
||||
streamOptions.tools = tools;
|
||||
streamOptions.stopWhen = stepCountIs(5);
|
||||
streamOptions.toolChoice = "auto";
|
||||
}
|
||||
|
||||
return streamText(streamOptions);
|
||||
}
|
||||
}
|
||||
187
apps/server/src/services/llm/providers/base_provider.ts
Normal file
187
apps/server/src/services/llm/providers/base_provider.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Base class for LLM providers. Handles shared logic for system prompt building,
|
||||
* tool assembly, model pricing, and title generation.
|
||||
*/
|
||||
|
||||
import { generateText, streamText, stepCountIs, type ModelMessage, type ToolSet } from "ai";
|
||||
import type { LanguageModel } from "ai";
|
||||
import type { LlmMessage } from "@triliumnext/commons";
|
||||
|
||||
import becca from "../../../becca/becca.js";
|
||||
import { getSkillsSummary } from "../skills/index.js";
|
||||
import { allToolRegistries } from "../tools/index.js";
|
||||
import type { ToolContext } from "../tools/tool_registry.js";
|
||||
import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js";
|
||||
|
||||
const DEFAULT_MAX_TOKENS = 8096;
|
||||
const TITLE_MAX_TOKENS = 30;
|
||||
|
||||
/**
|
||||
* Calculate effective cost for comparison (weighted average: 1 input + 3 output).
|
||||
* Output is weighted more heavily as it's typically the dominant cost factor.
|
||||
*/
|
||||
function effectiveCost(pricing: ModelPricing): number {
|
||||
return (pricing.input + 3 * pricing.output) / 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lightweight context hint about the current note (title + type only, no content).
|
||||
*/
|
||||
function buildNoteHint(noteId: string): string | null {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `The user is currently viewing a ${note.type} note titled "${note.title}". Use the get_current_note tool to read its content if needed.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the model list with cost multipliers from a base model definition array.
|
||||
*/
|
||||
export function buildModelList(baseModels: Omit<ModelInfo, "costMultiplier">[]): {
|
||||
models: ModelInfo[];
|
||||
pricing: Record<string, ModelPricing>;
|
||||
} {
|
||||
const baselineModel = baseModels.find(m => m.isDefault) || baseModels[0];
|
||||
const baselineCost = effectiveCost(baselineModel.pricing);
|
||||
|
||||
const models = baseModels.map(m => ({
|
||||
...m,
|
||||
costMultiplier: Math.round((effectiveCost(m.pricing) / baselineCost) * 10) / 10
|
||||
}));
|
||||
|
||||
const pricing = Object.fromEntries(
|
||||
models.map(m => [m.id, m.pricing])
|
||||
);
|
||||
|
||||
return { models, pricing };
|
||||
}
|
||||
|
||||
export abstract class BaseProvider implements LlmProvider {
|
||||
abstract name: string;
|
||||
|
||||
protected abstract defaultModel: string;
|
||||
protected abstract titleModel: string;
|
||||
protected abstract availableModels: ModelInfo[];
|
||||
protected abstract modelPricing: Record<string, ModelPricing>;
|
||||
|
||||
/** Create a language model instance for the given model ID. */
|
||||
protected abstract createModel(modelId: string): LanguageModel;
|
||||
|
||||
/**
|
||||
* Build the system prompt with note hints and skills summary.
|
||||
*/
|
||||
protected buildSystemPrompt(messages: LlmMessage[], config: LlmProviderConfig): string | undefined {
|
||||
let systemPrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content;
|
||||
|
||||
if (config.contextNoteId) {
|
||||
const noteHint = buildNoteHint(config.contextNoteId);
|
||||
if (noteHint) {
|
||||
systemPrompt = systemPrompt
|
||||
? `${systemPrompt}\n\n${noteHint}`
|
||||
: noteHint;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.enableNoteTools) {
|
||||
const skillsHint = `You have access to skills that provide specialized instructions. Load a skill with the load_skill tool before performing complex operations.\n\nAvailable skills:\n${getSkillsSummary()}`;
|
||||
systemPrompt = systemPrompt
|
||||
? `${systemPrompt}\n\n${skillsHint}`
|
||||
: skillsHint;
|
||||
}
|
||||
|
||||
return systemPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ModelMessage array from LlmMessages (no provider-specific options).
|
||||
*/
|
||||
protected buildMessages(chatMessages: LlmMessage[], systemPrompt: string | undefined): ModelMessage[] {
|
||||
const coreMessages: ModelMessage[] = [];
|
||||
|
||||
if (systemPrompt) {
|
||||
coreMessages.push({ role: "system", content: systemPrompt });
|
||||
}
|
||||
|
||||
for (const m of chatMessages) {
|
||||
coreMessages.push({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content
|
||||
});
|
||||
}
|
||||
|
||||
return coreMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add provider-specific web search tool. Override in subclasses that support it.
|
||||
*/
|
||||
protected addWebSearchTool(_tools: ToolSet): void {}
|
||||
|
||||
/**
|
||||
* Build the tool set based on config.
|
||||
*/
|
||||
protected buildTools(config: LlmProviderConfig): ToolSet {
|
||||
const tools: ToolSet = {};
|
||||
|
||||
if (config.enableWebSearch) {
|
||||
this.addWebSearchTool(tools);
|
||||
}
|
||||
|
||||
if (config.enableNoteTools) {
|
||||
const context: ToolContext | undefined = config.contextNoteId
|
||||
? { contextNoteId: config.contextNoteId }
|
||||
: undefined;
|
||||
for (const registry of allToolRegistries) {
|
||||
Object.assign(tools, registry.toToolSet(context));
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult {
|
||||
const systemPrompt = this.buildSystemPrompt(messages, config);
|
||||
const chatMessages = messages.filter(m => m.role !== "system");
|
||||
const coreMessages = this.buildMessages(chatMessages, systemPrompt);
|
||||
|
||||
const streamOptions: Parameters<typeof streamText>[0] = {
|
||||
model: this.createModel(config.model || this.defaultModel),
|
||||
messages: coreMessages,
|
||||
maxOutputTokens: config.maxTokens || DEFAULT_MAX_TOKENS
|
||||
};
|
||||
|
||||
const tools = this.buildTools(config);
|
||||
if (Object.keys(tools).length > 0) {
|
||||
streamOptions.tools = tools;
|
||||
streamOptions.stopWhen = stepCountIs(5);
|
||||
streamOptions.toolChoice = "auto";
|
||||
}
|
||||
|
||||
return streamText(streamOptions);
|
||||
}
|
||||
|
||||
getModelPricing(model: string): ModelPricing | undefined {
|
||||
return this.modelPricing[model];
|
||||
}
|
||||
|
||||
getAvailableModels(): ModelInfo[] {
|
||||
return this.availableModels;
|
||||
}
|
||||
|
||||
async generateTitle(firstMessage: string): Promise<string> {
|
||||
const { text } = await generateText({
|
||||
model: this.createModel(this.titleModel),
|
||||
maxOutputTokens: TITLE_MAX_TOKENS,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Summarize the following message as a very short chat title (max 6 words). Reply with ONLY the title, no quotes or punctuation at the end.\n\nMessage: ${firstMessage}`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
102
apps/server/src/services/llm/providers/google.ts
Normal file
102
apps/server/src/services/llm/providers/google.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google";
|
||||
import { streamText, stepCountIs, type ToolSet } from "ai";
|
||||
import type { LlmMessage } from "@triliumnext/commons";
|
||||
|
||||
import type { LlmProviderConfig, StreamResult } from "../types.js";
|
||||
import { BaseProvider, buildModelList } from "./base_provider.js";
|
||||
|
||||
/**
|
||||
* Available Google Gemini models with pricing (USD per million tokens).
|
||||
* Source: https://ai.google.dev/gemini-api/docs/pricing
|
||||
*/
|
||||
const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([
|
||||
// ===== Current Models =====
|
||||
{
|
||||
id: "gemini-2.5-pro",
|
||||
name: "Gemini 2.5 Pro",
|
||||
pricing: { input: 1.25, output: 10 },
|
||||
contextWindow: 1048576
|
||||
},
|
||||
{
|
||||
id: "gemini-2.5-flash",
|
||||
name: "Gemini 2.5 Flash",
|
||||
pricing: { input: 0.3, output: 2.5 },
|
||||
contextWindow: 1048576,
|
||||
isDefault: true
|
||||
},
|
||||
{
|
||||
id: "gemini-2.5-flash-lite",
|
||||
name: "Gemini 2.5 Flash-Lite",
|
||||
pricing: { input: 0.1, output: 0.4 },
|
||||
contextWindow: 1048576
|
||||
},
|
||||
{
|
||||
id: "gemini-2.0-flash",
|
||||
name: "Gemini 2.0 Flash",
|
||||
pricing: { input: 0.1, output: 0.4 },
|
||||
contextWindow: 1048576,
|
||||
isLegacy: true
|
||||
}
|
||||
]);
|
||||
|
||||
export class GoogleProvider extends BaseProvider {
|
||||
name = "google";
|
||||
protected defaultModel = "gemini-2.5-flash";
|
||||
protected titleModel = "gemini-2.5-flash-lite";
|
||||
protected availableModels = AVAILABLE_MODELS;
|
||||
protected modelPricing = MODEL_PRICING;
|
||||
|
||||
private google: GoogleGenerativeAIProvider;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
super();
|
||||
if (!apiKey) {
|
||||
throw new Error("API key is required for Google provider");
|
||||
}
|
||||
this.google = createGoogleGenerativeAI({ apiKey });
|
||||
}
|
||||
|
||||
protected createModel(modelId: string) {
|
||||
return this.google(modelId);
|
||||
}
|
||||
|
||||
protected override addWebSearchTool(tools: ToolSet): void {
|
||||
tools.google_search = this.google.tools.googleSearch({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Override chat to add Google-specific extended thinking support.
|
||||
* Gemini 2.5 uses thinkingBudget, Gemini 3.x uses thinkingLevel.
|
||||
*/
|
||||
override chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult {
|
||||
if (!config.enableExtendedThinking) {
|
||||
return super.chat(messages, config);
|
||||
}
|
||||
|
||||
const systemPrompt = this.buildSystemPrompt(messages, config);
|
||||
const chatMessages = messages.filter(m => m.role !== "system");
|
||||
const coreMessages = this.buildMessages(chatMessages, systemPrompt);
|
||||
|
||||
const streamOptions: Parameters<typeof streamText>[0] = {
|
||||
model: this.createModel(config.model || this.defaultModel),
|
||||
messages: coreMessages,
|
||||
maxOutputTokens: config.maxTokens || 8096,
|
||||
providerOptions: {
|
||||
google: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: config.thinkingBudget || 10000
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tools = this.buildTools(config);
|
||||
if (Object.keys(tools).length > 0) {
|
||||
streamOptions.tools = tools;
|
||||
streamOptions.stopWhen = stepCountIs(5);
|
||||
streamOptions.toolChoice = "auto";
|
||||
}
|
||||
|
||||
return streamText(streamOptions);
|
||||
}
|
||||
}
|
||||
84
apps/server/src/services/llm/providers/openai.ts
Normal file
84
apps/server/src/services/llm/providers/openai.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createOpenAI, type OpenAIProvider as OpenAISDKProvider } from "@ai-sdk/openai";
|
||||
import type { ToolSet } from "ai";
|
||||
|
||||
import { BaseProvider, buildModelList } from "./base_provider.js";
|
||||
|
||||
/**
|
||||
* Available OpenAI models with pricing (USD per million tokens).
|
||||
* Source: https://platform.openai.com/docs/pricing
|
||||
*/
|
||||
const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([
|
||||
// ===== Current Models =====
|
||||
{
|
||||
id: "gpt-4.1",
|
||||
name: "GPT-4.1",
|
||||
pricing: { input: 2, output: 8 },
|
||||
contextWindow: 1047576,
|
||||
isDefault: true
|
||||
},
|
||||
{
|
||||
id: "gpt-4.1-mini",
|
||||
name: "GPT-4.1 Mini",
|
||||
pricing: { input: 0.4, output: 1.6 },
|
||||
contextWindow: 1047576
|
||||
},
|
||||
{
|
||||
id: "gpt-4.1-nano",
|
||||
name: "GPT-4.1 Nano",
|
||||
pricing: { input: 0.1, output: 0.4 },
|
||||
contextWindow: 1047576
|
||||
},
|
||||
{
|
||||
id: "o3",
|
||||
name: "o3",
|
||||
pricing: { input: 2, output: 8 },
|
||||
contextWindow: 200000
|
||||
},
|
||||
{
|
||||
id: "o4-mini",
|
||||
name: "o4-mini",
|
||||
pricing: { input: 1.1, output: 4.4 },
|
||||
contextWindow: 200000
|
||||
},
|
||||
// ===== Legacy Models =====
|
||||
{
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
pricing: { input: 2.5, output: 10 },
|
||||
contextWindow: 128000,
|
||||
isLegacy: true
|
||||
},
|
||||
{
|
||||
id: "gpt-4o-mini",
|
||||
name: "GPT-4o Mini",
|
||||
pricing: { input: 0.15, output: 0.6 },
|
||||
contextWindow: 128000,
|
||||
isLegacy: true
|
||||
}
|
||||
]);
|
||||
|
||||
export class OpenAiProvider extends BaseProvider {
|
||||
name = "openai";
|
||||
protected defaultModel = "gpt-4.1";
|
||||
protected titleModel = "gpt-4.1-mini";
|
||||
protected availableModels = AVAILABLE_MODELS;
|
||||
protected modelPricing = MODEL_PRICING;
|
||||
|
||||
private openai: OpenAISDKProvider;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
super();
|
||||
if (!apiKey) {
|
||||
throw new Error("API key is required for OpenAI provider");
|
||||
}
|
||||
this.openai = createOpenAI({ apiKey });
|
||||
}
|
||||
|
||||
protected createModel(modelId: string) {
|
||||
return this.openai(modelId);
|
||||
}
|
||||
|
||||
protected override addWebSearchTool(tools: ToolSet): void {
|
||||
tools.web_search = this.openai.tools.webSearch();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user