mirror of
https://github.com/zadam/trilium.git
synced 2026-04-06 03:59:01 +02:00
Compare commits
548 Commits
autocomple
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec301ae585 | ||
|
|
f97370c8f7 | ||
|
|
afad96a375 | ||
|
|
9e5ababfcb | ||
|
|
dc1e0e8db4 | ||
|
|
1e861d1125 | ||
|
|
baa93cb371 | ||
|
|
61dcc8db47 | ||
|
|
2c557eb015 | ||
|
|
f5a80526ab | ||
|
|
27e1455874 | ||
|
|
278d8428de | ||
|
|
164e667158 | ||
|
|
28b31791e7 | ||
|
|
9515768e62 | ||
|
|
fbbad19cb7 | ||
|
|
eab353ca2e | ||
|
|
cb9ee20763 | ||
|
|
dac12532bc | ||
|
|
1d99734ea0 | ||
|
|
3e764c762a | ||
|
|
7be51168d3 | ||
|
|
530d193734 | ||
|
|
aba5ff75af | ||
|
|
9e34fcb8a8 | ||
|
|
055dd9cd01 | ||
|
|
1437fdc4e3 | ||
|
|
e5c67b16ac | ||
|
|
94987314b8 | ||
|
|
f4f881e839 | ||
|
|
92f5901b95 | ||
|
|
1c0cb601cb | ||
|
|
109f06f8bb | ||
|
|
bf23439792 | ||
|
|
b7a0bc08be | ||
|
|
9d6a26dda9 | ||
|
|
a01ce2c3fc | ||
|
|
ba6298af27 | ||
|
|
3d17e0aa75 | ||
|
|
7e18166160 | ||
|
|
40d8571797 | ||
|
|
25e04e358a | ||
|
|
e473e12c0e | ||
|
|
dfb20df16f | ||
|
|
efcbf439ee | ||
|
|
514f7fedbc | ||
|
|
ee88fedacd | ||
|
|
2933f9c49f | ||
|
|
1cca5d989c | ||
|
|
9981020728 | ||
|
|
56843dcf8b | ||
|
|
e661118192 | ||
|
|
54a7de6cb0 | ||
|
|
13b1e0afbb | ||
|
|
4a48796142 | ||
|
|
9a4fef80b9 | ||
|
|
79dc4b39f1 | ||
|
|
9bc18b774e | ||
|
|
465c36407c | ||
|
|
b99486259e | ||
|
|
ecf5475966 | ||
|
|
90822cc8a3 | ||
|
|
5c46209ddc | ||
|
|
176de87b6b | ||
|
|
7f199c527b | ||
|
|
2432e230c5 | ||
|
|
fc1be0d23d | ||
|
|
d084b9e941 | ||
|
|
6678c0af49 | ||
|
|
37754ecf31 | ||
|
|
709d9633a1 | ||
|
|
7ca57efaad | ||
|
|
342fedca1c | ||
|
|
b1262b0448 | ||
|
|
626aca5181 | ||
|
|
8204322b46 | ||
|
|
70ce86cd53 | ||
|
|
ed3b86cd49 | ||
|
|
b371675494 | ||
|
|
ff06c8e7bd | ||
|
|
8ff41d8fa9 | ||
|
|
5f5b9ba8cb | ||
|
|
a3221470e7 | ||
|
|
0e115bd92a | ||
|
|
95a50c0ba6 | ||
|
|
e323ccb259 | ||
|
|
3294d0b93b | ||
|
|
55e8694990 | ||
|
|
b3888b391a | ||
|
|
f2907ab40f | ||
|
|
7e7218cbdf | ||
|
|
e41c9cb7f4 | ||
|
|
20f96c88e4 | ||
|
|
66afda1343 | ||
|
|
c5a6212065 | ||
|
|
3e7e355575 | ||
|
|
fb9eb3e4b5 | ||
|
|
a35ac82f24 | ||
|
|
66add6b9e4 | ||
|
|
fe81bde1c9 | ||
|
|
6b223098ab | ||
|
|
788e867a6c | ||
|
|
7ad8d307dc | ||
|
|
b6d4ac5ada | ||
|
|
0a069854e5 | ||
|
|
8770afa211 | ||
|
|
312c193b1a | ||
|
|
3700e2bb93 | ||
|
|
a9be72081c | ||
|
|
f57b57791b | ||
|
|
5cf249afa4 | ||
|
|
3f24627f67 | ||
|
|
806c3fdc00 | ||
|
|
e81ee88cda | ||
|
|
db46f63337 | ||
|
|
395102026d | ||
|
|
b62c078de6 | ||
|
|
47c1c08bed | ||
|
|
a23c4f03e0 | ||
|
|
5a6da60fe8 | ||
|
|
588c47aee7 | ||
|
|
36fd51219a | ||
|
|
bc43a79d97 | ||
|
|
5c22c029d7 | ||
|
|
126d9be9d8 | ||
|
|
09be2822e0 | ||
|
|
a93029f789 | ||
|
|
48cf214f4c | ||
|
|
6834bad7b0 | ||
|
|
855458bab0 | ||
|
|
5be48bf8c8 | ||
|
|
80ac0eea62 | ||
|
|
5995ec468d | ||
|
|
e9a876e8f0 | ||
|
|
90223a5ffd | ||
|
|
8331daae5b | ||
|
|
027280954a | ||
|
|
5138a63d23 | ||
|
|
be95cf5510 | ||
|
|
4082328c2b | ||
|
|
729e840af2 | ||
|
|
e4a38fe277 | ||
|
|
a5cb9c7de6 | ||
|
|
7543109583 | ||
|
|
bff2f10fa4 | ||
|
|
37120bf153 | ||
|
|
b88c85db5e | ||
|
|
c682e3dfc0 | ||
|
|
6c0bbb7778 | ||
|
|
bde8c40d16 | ||
|
|
c4d352ba26 | ||
|
|
cc1c0696ad | ||
|
|
186b784004 | ||
|
|
5441d15654 | ||
|
|
bd61af89ae | ||
|
|
eddd77f97f | ||
|
|
ab0338c318 | ||
|
|
1892bec772 | ||
|
|
bf7070a7da | ||
|
|
314331b956 | ||
|
|
6ff949fdb5 | ||
|
|
21d24b7bea | ||
|
|
8522151949 | ||
|
|
3720099b1d | ||
|
|
073873c33c | ||
|
|
25bf62faa3 | ||
|
|
e54cb9c626 | ||
|
|
208330d73a | ||
|
|
343e3e67ed | ||
|
|
6447003927 | ||
|
|
cbdf925703 | ||
|
|
7440e4a610 | ||
|
|
54a5c3fac0 | ||
|
|
42e60da127 | ||
|
|
325dc9c8a8 | ||
|
|
877427f0db | ||
|
|
1a64e7ba63 | ||
|
|
7dfa59a845 | ||
|
|
62fd19368d | ||
|
|
058518fcba | ||
|
|
6e1d10f052 | ||
|
|
af988fec69 | ||
|
|
dd5979aec8 | ||
|
|
657fbeba79 | ||
|
|
4a0d45ad7d | ||
|
|
f47ec21aa8 | ||
|
|
be40d65982 | ||
|
|
faebacb883 | ||
|
|
df0efc39d5 | ||
|
|
57a299de8f | ||
|
|
be724ec45f | ||
|
|
98c70e662d | ||
|
|
4ed9b84d75 | ||
|
|
b7f05acfd3 | ||
|
|
45ebb37a01 | ||
|
|
f77adea800 | ||
|
|
88b855ed47 | ||
|
|
4fa689873f | ||
|
|
d76b9329fc | ||
|
|
1c43ddd3a9 | ||
|
|
1aedbcef94 | ||
|
|
295280861a | ||
|
|
9f70e20fa0 | ||
|
|
a20e96eb6a | ||
|
|
9b238a3ac6 | ||
|
|
0167597ae0 | ||
|
|
a4f6071c8b | ||
|
|
aa0b0bd249 | ||
|
|
c6185a51c2 | ||
|
|
9c9c717025 | ||
|
|
00342ed569 | ||
|
|
1f0a6b4a79 | ||
|
|
3e767b4723 | ||
|
|
e539b11718 | ||
|
|
2fca8c3850 | ||
|
|
0d3f70a231 | ||
|
|
a3a52aaafe | ||
|
|
a6c4401973 | ||
|
|
2e34ec2a17 | ||
|
|
927afec83c | ||
|
|
8bd1da0552 | ||
|
|
4f571fc3d7 | ||
|
|
c3f8e523cc | ||
|
|
9878f76f65 | ||
|
|
23799562ae | ||
|
|
f441a145b5 | ||
|
|
7189764916 | ||
|
|
70bc707e3a | ||
|
|
90215bde8b | ||
|
|
2b3ae5285b | ||
|
|
9b6d0db5b6 | ||
|
|
723da88ff8 | ||
|
|
5bcf2f4356 | ||
|
|
42680574c1 | ||
|
|
82e723c915 | ||
|
|
ac9560d9d7 | ||
|
|
32f95efa54 | ||
|
|
3da416908d | ||
|
|
d79d2e9ad2 | ||
|
|
30ba36894d | ||
|
|
b747402352 | ||
|
|
0398a9bda3 | ||
|
|
72dff88384 | ||
|
|
0314a9755f | ||
|
|
bc967b15b2 | ||
|
|
8ac686a19f | ||
|
|
aafecaa3a4 | ||
|
|
bb23b08b15 | ||
|
|
476396da53 | ||
|
|
5112971848 | ||
|
|
2d852c38ec | ||
|
|
f163cacddc | ||
|
|
6ecb1cb2b0 | ||
|
|
24fefe0711 | ||
|
|
e5eba69d0d | ||
|
|
bdd2b7e317 | ||
|
|
ad29375975 | ||
|
|
cf73a4ef43 | ||
|
|
60a2621928 | ||
|
|
b4e5d9dbc2 | ||
|
|
650b700415 | ||
|
|
212f742164 | ||
|
|
6f2296eb05 | ||
|
|
722efd74c2 | ||
|
|
5dc9b6defe | ||
|
|
605fbaaa4a | ||
|
|
23b46865c5 | ||
|
|
ac310eaaf5 | ||
|
|
010f59df8a | ||
|
|
44a5dccd61 | ||
|
|
acbbf021a1 | ||
|
|
731fece258 | ||
|
|
8d255d1b89 | ||
|
|
64318c92e7 | ||
|
|
49fc7e48d4 | ||
|
|
ec9fa0baee | ||
|
|
ba91d91fd1 | ||
|
|
0aa1fea9dc | ||
|
|
1551f01f49 | ||
|
|
d46748602e | ||
|
|
9cfad0fe6a | ||
|
|
6d3cff84a4 | ||
|
|
010230645c | ||
|
|
5979290f0c | ||
|
|
e648872257 | ||
|
|
e4910ae31a | ||
|
|
d8ea0c7bcf | ||
|
|
6393d2c188 | ||
|
|
d9f0a163cf | ||
|
|
6534beec14 | ||
|
|
6d050340ee | ||
|
|
0e7f7fa208 | ||
|
|
287be0bd25 | ||
|
|
18cf2ff873 | ||
|
|
b626fb448b | ||
|
|
38f6fb5a7f | ||
|
|
5846df7d02 | ||
|
|
9462d6109c | ||
|
|
f0c93cd06e | ||
|
|
14e0507689 | ||
|
|
393b90f7be | ||
|
|
47ee5c1d84 | ||
|
|
1cb6f2d351 | ||
|
|
bb72b0cdfc | ||
|
|
ab2467b074 | ||
|
|
2d652523bb | ||
|
|
55df50253f | ||
|
|
d009914ff9 | ||
|
|
5e97222206 | ||
|
|
038705483b | ||
|
|
10c9ba5783 | ||
|
|
a1d008688b | ||
|
|
78a043c536 | ||
|
|
acdc840f17 | ||
|
|
63d4b8894b | ||
|
|
23ccbf9642 | ||
|
|
a5793ff768 | ||
|
|
a84e2f72c3 | ||
|
|
0d805a01c1 | ||
|
|
ba90a1c396 | ||
|
|
465927e730 | ||
|
|
74f3c14a62 | ||
|
|
2eb40c7b42 | ||
|
|
457c5f85af | ||
|
|
c6ef3d774a | ||
|
|
7f1e4c0969 | ||
|
|
e55cd7841f | ||
|
|
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 | ||
|
|
6c295611cc | ||
|
|
81f02209ea | ||
|
|
124d456c60 | ||
|
|
b9cef158d8 | ||
|
|
5ec6141369 | ||
|
|
55ac1e01f2 | ||
|
|
65b58c3668 | ||
|
|
2cb4e5e8dc | ||
|
|
72cea245f1 | ||
|
|
08ca86c68a | ||
|
|
925c9c1e7b | ||
|
|
6212ea0304 | ||
|
|
f295592134 | ||
|
|
69b0973e6d | ||
|
|
422d318dac | ||
|
|
c55aa6ee88 | ||
|
|
090b175152 | ||
|
|
11e9b097a2 | ||
|
|
2adfc1d32b | ||
|
|
99fa5d89e7 | ||
|
|
ca8cbf8ccf | ||
|
|
6722d2d266 | ||
|
|
508cbeaa1b | ||
|
|
e040865905 | ||
|
|
a7878dd2c6 | ||
|
|
02980834ad | ||
|
|
2a8c8871c4 | ||
|
|
893be24c1d | ||
|
|
9029f59410 | ||
|
|
4b5e8d33a6 | ||
|
|
09196c045f | ||
|
|
7868ebec1e | ||
|
|
80a9182f05 | ||
|
|
d20b3d854f | ||
|
|
f1356228a3 | ||
|
|
a4adc51e50 | ||
|
|
864543e4f9 | ||
|
|
33a549202b | ||
|
|
c4a0219b18 |
21
.github/copilot-instructions.md
vendored
21
.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`)
|
||||
@@ -275,6 +289,12 @@ View types are configured via `#viewType` label (e.g., `#viewType=table`). Each
|
||||
- Register in `packages/ckeditor5/src/plugins.ts`
|
||||
- See `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid` for examples
|
||||
|
||||
### Updating PDF.js
|
||||
1. Update `pdfjs-dist` version in `packages/pdfjs-viewer/package.json`
|
||||
2. Run `npx tsx scripts/update-viewer.ts` from that directory
|
||||
3. Run `pnpm build` to verify success
|
||||
4. Commit all changes including updated viewer files
|
||||
|
||||
### Database Migrations
|
||||
- Add migration scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
|
||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||
@@ -299,6 +319,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
.github/workflows/dev.yml
vendored
8
.github/workflows/dev.yml
vendored
@@ -1,9 +1,13 @@
|
||||
name: Dev
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
- "release/*"
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
- "release/*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
@@ -69,6 +69,8 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
env:
|
||||
npm_config_package_import_method: copy
|
||||
- name: Update nightly version
|
||||
run: pnpm run chore:ci-update-nightly-version
|
||||
- name: Run the build
|
||||
|
||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"trilium": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:8080/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
CLAUDE.md
39
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,37 @@ 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`, `attachment_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
|
||||
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
|
||||
5. Use ETAPI (`apps/server/src/etapi/`) as inspiration for what fields to expose, but **do not import ETAPI mappers** — inline the field mappings directly in the tool so the LLM layer stays decoupled from the API layer
|
||||
|
||||
### Updating PDF.js
|
||||
1. Update `pdfjs-dist` version in `packages/pdfjs-viewer/package.json`
|
||||
2. Run `npx tsx scripts/update-viewer.ts` from that directory
|
||||
3. Run `pnpm build` to verify success
|
||||
4. Commit all changes including updated viewer files
|
||||
|
||||
### 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
|
||||
|
||||
84
SECURITY.md
84
SECURITY.md
@@ -2,13 +2,87 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
In the (still active) 0.X phase of the project only the latest stable minor release is getting bugfixes (including security ones).
|
||||
Only the latest stable minor release receives security fixes.
|
||||
|
||||
So e.g. if the latest stable version is 0.42.3 and the latest beta version is 0.43.0-beta, then 0.42 line will still get security fixes but older versions (like 0.41.X) won't get any fixes.
|
||||
For example, if the latest stable version is 0.92.3 and the latest beta is 0.93.0-beta, then only the 0.92.x line will receive security patches. Older versions (like 0.91.x) will not receive fixes.
|
||||
|
||||
Description above is a general rule and may be altered on case by case basis.
|
||||
This policy may be altered on a case-by-case basis for critical vulnerabilities.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
* For low severity vulnerabilities, they can be reported as GitHub issues.
|
||||
* For severe vulnerabilities, please report it using [GitHub Security Advisories](https://github.com/TriliumNext/Trilium/security/advisories).
|
||||
**Please report all security vulnerabilities through [GitHub Security Advisories](https://github.com/TriliumNext/Notes/security/advisories/new).**
|
||||
|
||||
We do not accept security reports via email, public issues, or other channels. GitHub Security Advisories allows us to:
|
||||
- Discuss and triage vulnerabilities privately
|
||||
- Coordinate fixes before public disclosure
|
||||
- Credit reporters appropriately
|
||||
- Publish advisories with CVE identifiers
|
||||
|
||||
### What to Include
|
||||
|
||||
When reporting, please provide:
|
||||
- A clear description of the vulnerability
|
||||
- Steps to reproduce or proof-of-concept
|
||||
- Affected versions (if known)
|
||||
- Potential impact assessment
|
||||
- Any suggested mitigations or fixes
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Initial response**: Within 7 days
|
||||
- **Triage decision**: Within 14 days
|
||||
- **Fix timeline**: Depends on severity and complexity
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Remote code execution
|
||||
- Authentication/authorization bypass
|
||||
- Cross-site scripting (XSS) that affects other users
|
||||
- SQL injection
|
||||
- Path traversal
|
||||
- Sensitive data exposure
|
||||
- Privilege escalation
|
||||
|
||||
### Out of Scope (Won't Fix)
|
||||
|
||||
The following are considered out of scope or accepted risks:
|
||||
|
||||
#### Self-XSS / Self-Injection
|
||||
Trilium is a personal knowledge base where users have full control over their own data. Users can intentionally create notes containing scripts, HTML, or other executable content. This is by design - Trilium's scripting system allows users to extend functionality with custom JavaScript.
|
||||
|
||||
Vulnerabilities that require a user to inject malicious content into their own notes and then view it themselves are not considered security issues.
|
||||
|
||||
#### Electron Architecture (nodeIntegration)
|
||||
Trilium's desktop application runs with `nodeIntegration: true` to enable its powerful scripting features. This is an intentional design decision, similar to VS Code extensions having full system access. We mitigate risks by:
|
||||
- Sanitizing content at input boundaries
|
||||
- Fixing specific XSS vectors as they're discovered
|
||||
- Using Electron fuses to prevent external abuse
|
||||
|
||||
#### Authenticated User Actions
|
||||
Actions that require valid authentication and only affect the authenticated user's own data are generally not vulnerabilities.
|
||||
|
||||
#### Denial of Service via Resource Exhaustion
|
||||
Creating extremely large notes or performing many operations is expected user behavior in a note-taking application.
|
||||
|
||||
#### Missing Security Headers on Non-Sensitive Endpoints
|
||||
We implement security headers where they provide meaningful protection, but may omit them on endpoints where they provide no practical benefit.
|
||||
|
||||
## Coordinated Disclosure
|
||||
|
||||
We follow a coordinated disclosure process:
|
||||
|
||||
1. **Report received** - We acknowledge receipt and begin triage
|
||||
2. **Fix developed** - We develop and test a fix privately
|
||||
3. **Release prepared** - Security release is prepared with vague changelog
|
||||
4. **Users notified** - Release is published, users encouraged to upgrade
|
||||
5. **Advisory published** - After reasonable upgrade window (typically 2-4 weeks), full advisory is published
|
||||
|
||||
We appreciate reporters allowing us time to fix issues before public disclosure. We aim to credit all reporters in published advisories unless they prefer to remain anonymous.
|
||||
|
||||
## Security Updates
|
||||
|
||||
Security fixes are released as patch versions (e.g., 0.92.1 → 0.92.2) to minimize upgrade friction. We recommend all users keep their installations up to date.
|
||||
|
||||
Subscribe to GitHub releases or watch the repository to receive notifications of new releases.
|
||||
|
||||
@@ -16,12 +16,10 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.25.1",
|
||||
"@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.18",
|
||||
"typedoc-plugin-missing-exports": "4.1.2"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.102.1",
|
||||
"version": "0.102.2",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -27,48 +27,46 @@
|
||||
"@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.6.0",
|
||||
"@univerjs/preset-sheets-conditional-formatting": "0.19.0",
|
||||
"@univerjs/preset-sheets-core": "0.19.0",
|
||||
"@univerjs/preset-sheets-data-validation": "0.19.0",
|
||||
"@univerjs/preset-sheets-filter": "0.19.0",
|
||||
"@univerjs/preset-sheets-find-replace": "0.19.0",
|
||||
"@univerjs/preset-sheets-note": "0.19.0",
|
||||
"@univerjs/preset-sheets-sort": "0.19.0",
|
||||
"@univerjs/presets": "0.19.0",
|
||||
"@zumer/snapdom": "2.7.0",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
"clsx": "2.1.1",
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"dompurify": "3.3.3",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.2",
|
||||
"globals": "17.4.0",
|
||||
"i18next": "25.10.10",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"i18next": "26.0.3",
|
||||
"i18next-http-backend": "3.0.4",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.43",
|
||||
"katex": "0.16.44",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.5",
|
||||
"mermaid": "11.13.0",
|
||||
"mind-elixir": "5.9.3",
|
||||
"normalize.css": "8.0.1",
|
||||
"mermaid": "11.14.0",
|
||||
"mind-elixir": "5.10.0",
|
||||
"panzoom": "9.4.4",
|
||||
"preact": "10.29.0",
|
||||
"react-i18next": "16.6.6",
|
||||
"react-i18next": "17.0.2",
|
||||
"react-window": "2.2.7",
|
||||
"reveal.js": "6.0.0",
|
||||
"rrule": "2.8.1",
|
||||
@@ -89,6 +87,6 @@
|
||||
"happy-dom": "20.8.9",
|
||||
"lightningcss": "1.32.0",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.4.0"
|
||||
"vite-plugin-static-copy": "4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,7 @@ export type CommandMappings = {
|
||||
ninthTab: CommandData;
|
||||
lastTab: CommandData;
|
||||
showNoteSource: CommandData;
|
||||
showNoteOCRText: CommandData;
|
||||
showSQLConsole: CommandData;
|
||||
showBackendLog: CommandData;
|
||||
showCheatsheet: CommandData;
|
||||
@@ -508,7 +509,7 @@ type EventMappings = {
|
||||
contentSafeMarginChanged: {
|
||||
top: number;
|
||||
noteContext: NoteContext;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export type EventListener<T extends EventNames> = {
|
||||
|
||||
@@ -148,6 +148,19 @@ export default class RootCommandExecutor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
async showNoteOCRTextCommand() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
|
||||
if (notePath) {
|
||||
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
|
||||
activate: true,
|
||||
viewScope: {
|
||||
viewMode: "ocr"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async showAttachmentsCommand() {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./content_renderer.css";
|
||||
|
||||
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
|
||||
import { normalizeMimeTypeForCKEditor, type TextRepresentationResponse } from "@triliumnext/commons";
|
||||
import { h, render } from "preact";
|
||||
import WheelZoom from 'vanilla-js-wheel-zoom';
|
||||
|
||||
@@ -15,6 +15,7 @@ import openService from "./open.js";
|
||||
import protectedSessionService from "./protected_session.js";
|
||||
import protectedSessionHolder from "./protected_session_holder.js";
|
||||
import renderService from "./render.js";
|
||||
import server from "./server.js";
|
||||
import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js";
|
||||
import utils, { getErrorMessage } from "./utils.js";
|
||||
|
||||
@@ -32,6 +33,7 @@ export interface RenderOptions {
|
||||
includeArchivedNotes?: boolean;
|
||||
/** Set of note IDs that have already been seen during rendering to prevent infinite recursion. */
|
||||
seenNoteIds?: Set<string>;
|
||||
showTextRepresentation?: boolean;
|
||||
}
|
||||
|
||||
const CODE_MIME_TYPES = new Set(["application/json"]);
|
||||
@@ -55,9 +57,9 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
|
||||
} else if (type === "code") {
|
||||
await renderCode(entity, $renderedContent);
|
||||
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
|
||||
renderImage(entity, $renderedContent, options);
|
||||
await renderImage(entity, $renderedContent, options);
|
||||
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
||||
await renderFile(entity, type, $renderedContent);
|
||||
await renderFile(entity, type, $renderedContent, options);
|
||||
} else if (type === "mermaid") {
|
||||
await renderMermaid(entity, $renderedContent);
|
||||
} else if (type === "render" && entity instanceof FNote) {
|
||||
@@ -138,7 +140,7 @@ async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HT
|
||||
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
|
||||
}
|
||||
|
||||
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
|
||||
async function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
|
||||
const encodedTitle = encodeURIComponent(entity.title);
|
||||
|
||||
let url;
|
||||
@@ -146,13 +148,14 @@ function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLE
|
||||
if (entity instanceof FNote) {
|
||||
url = `api/images/${entity.noteId}/${encodedTitle}?${Math.random()}`;
|
||||
} else if (entity instanceof FAttachment) {
|
||||
url = `api/attachments/${entity.attachmentId}/image/${encodedTitle}?${entity.utcDateModified}">`;
|
||||
url = `api/attachments/${entity.attachmentId}/image/${encodedTitle}?${entity.utcDateModified}`;
|
||||
}
|
||||
|
||||
$renderedContent // styles needed for the zoom to work well
|
||||
.css("display", "flex")
|
||||
.css("align-items", "center")
|
||||
.css("justify-content", "center");
|
||||
.css("justify-content", "center")
|
||||
.css("flex-direction", "column"); // OCR text is displayed below the image.
|
||||
|
||||
const $img = $("<img>")
|
||||
.attr("src", url || "")
|
||||
@@ -178,9 +181,35 @@ function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLE
|
||||
}
|
||||
|
||||
imageContextMenuService.setupContextMenu($img);
|
||||
|
||||
if (entity instanceof FNote && options.showTextRepresentation) {
|
||||
await addOCRTextIfAvailable(entity, $renderedContent);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>) {
|
||||
async function addOCRTextIfAvailable(note: FNote, $content: JQuery<HTMLElement>) {
|
||||
try {
|
||||
const data = await server.get<TextRepresentationResponse>(`ocr/notes/${note.noteId}/text`);
|
||||
if (data.success && data.hasOcr && data.text) {
|
||||
const $ocrSection = $(`
|
||||
<div class="ocr-text-section">
|
||||
<div class="ocr-header">
|
||||
<span class="bx bx-text"></span> ${t("ocr.extracted_text")}
|
||||
</div>
|
||||
<div class="ocr-content"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$ocrSection.find('.ocr-content').text(data.text);
|
||||
$content.append($ocrSection);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail if OCR API is not available
|
||||
console.debug('Failed to fetch OCR text:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
|
||||
let entityType, entityId;
|
||||
|
||||
if (entity instanceof FNote) {
|
||||
@@ -220,6 +249,10 @@ async function renderFile(entity: FNote | FAttachment, type: string, $renderedCo
|
||||
$content.append($videoPreview);
|
||||
}
|
||||
|
||||
if (entity instanceof FNote && options.showTextRepresentation) {
|
||||
await addOCRTextIfAvailable(entity, $content);
|
||||
}
|
||||
|
||||
if (entityType === "notes" && "noteId" in entity) {
|
||||
// TODO: we should make this available also for attachments, but there's a problem with "Open externally" support
|
||||
// in attachment list
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
30
apps/client/src/services/doc_renderer.spec.ts
Normal file
30
apps/client/src/services/doc_renderer.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isValidDocName } from "./doc_renderer.js";
|
||||
|
||||
describe("isValidDocName", () => {
|
||||
it("accepts valid docNames", () => {
|
||||
expect(isValidDocName("launchbar_intro")).toBe(true);
|
||||
expect(isValidDocName("User Guide/Quick Start")).toBe(true);
|
||||
expect(isValidDocName("User Guide/User Guide/Quick Start")).toBe(true);
|
||||
expect(isValidDocName("Quick Start Guide")).toBe(true);
|
||||
expect(isValidDocName("quick_start_guide")).toBe(true);
|
||||
expect(isValidDocName("quick-start-guide")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects path traversal attacks", () => {
|
||||
expect(isValidDocName("..")).toBe(false);
|
||||
expect(isValidDocName("../etc/passwd")).toBe(false);
|
||||
expect(isValidDocName("foo/../bar")).toBe(false);
|
||||
expect(isValidDocName("../../../../api/notes/_malicious/open")).toBe(false);
|
||||
expect(isValidDocName("..\\etc\\passwd")).toBe(false);
|
||||
expect(isValidDocName("foo\\bar")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects URL manipulation attacks", () => {
|
||||
expect(isValidDocName("../../../../api/notes/_malicious/open?x=")).toBe(false);
|
||||
expect(isValidDocName("foo#bar")).toBe(false);
|
||||
expect(isValidDocName("%2e%2e")).toBe(false);
|
||||
expect(isValidDocName("%2e%2e%2f%2e%2e%2fapi")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,22 +3,39 @@ import { applyReferenceLinks } from "../widgets/type_widgets/text/read_only_help
|
||||
import { getCurrentLanguage } from "./i18n.js";
|
||||
import { formatCodeBlocks } from "./syntax_highlight.js";
|
||||
|
||||
/**
|
||||
* Validates a docName to prevent path traversal attacks.
|
||||
* Allows forward slashes for subdirectories (e.g., "User Guide/Quick Start")
|
||||
* but blocks traversal sequences and URL manipulation characters.
|
||||
*/
|
||||
export function isValidDocName(docName: string): boolean {
|
||||
// Allow alphanumeric characters, spaces, underscores, hyphens, and forward slashes.
|
||||
const validDocNameRegex = /^[a-zA-Z0-9_/\- ]+$/;
|
||||
return validDocNameRegex.test(docName);
|
||||
}
|
||||
|
||||
export default function renderDoc(note: FNote) {
|
||||
return new Promise<JQuery<HTMLElement>>((resolve) => {
|
||||
let docName = note.getLabelValue("docName");
|
||||
const docName = note.getLabelValue("docName");
|
||||
const $content = $("<div>");
|
||||
|
||||
if (docName) {
|
||||
// find doc based on language
|
||||
const url = getUrl(docName, getCurrentLanguage());
|
||||
// find doc based on language
|
||||
const url = getUrl(docName, getCurrentLanguage());
|
||||
|
||||
if (url) {
|
||||
$content.load(url, async (response, status) => {
|
||||
// fallback to english doc if no translation available
|
||||
if (status === "error") {
|
||||
const fallbackUrl = getUrl(docName, "en");
|
||||
$content.load(fallbackUrl, async () => {
|
||||
await processContent(fallbackUrl, $content)
|
||||
|
||||
if (fallbackUrl) {
|
||||
$content.load(fallbackUrl, async () => {
|
||||
await processContent(fallbackUrl, $content);
|
||||
resolve($content);
|
||||
});
|
||||
} else {
|
||||
resolve($content);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,8 +45,6 @@ export default function renderDoc(note: FNote) {
|
||||
} else {
|
||||
resolve($content);
|
||||
}
|
||||
|
||||
return $content;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,7 +54,7 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
|
||||
$content.find("img").each((i, el) => {
|
||||
const $img = $(el);
|
||||
$img.attr("src", dir + "/" + $img.attr("src"));
|
||||
$img.attr("src", `${dir}/${$img.attr("src")}`);
|
||||
});
|
||||
|
||||
formatCodeBlocks($content);
|
||||
@@ -48,10 +63,17 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
await applyReferenceLinks($content[0]);
|
||||
}
|
||||
|
||||
function getUrl(docNameValue: string, language: string) {
|
||||
function getUrl(docNameValue: string | null, language: string) {
|
||||
if (!docNameValue) return;
|
||||
|
||||
if (!isValidDocName(docNameValue)) {
|
||||
console.error(`Invalid docName: ${docNameValue}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cannot have spaces in the URL due to how JQuery.load works.
|
||||
docNameValue = docNameValue.replaceAll(" ", "%20");
|
||||
|
||||
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
|
||||
const basePath = window.glob.isDev ? `${window.glob.assetPath }/..` : window.glob.assetPath;
|
||||
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -24,8 +24,7 @@ export async function initLocale() {
|
||||
backend: {
|
||||
loadPath: `${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json`
|
||||
},
|
||||
returnEmptyString: false,
|
||||
showSupportNotice: false
|
||||
returnEmptyString: false
|
||||
});
|
||||
|
||||
await setDayjsLocale(locale);
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -28,7 +28,7 @@ async function getLinkIcon(noteId: string, viewMode: ViewMode | undefined) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
export type ViewMode = "default" | "source" | "attachments" | "contextual-help" | "note-map";
|
||||
export type ViewMode = "default" | "source" | "attachments" | "contextual-help" | "note-map" | "ocr";
|
||||
|
||||
export interface ViewScope {
|
||||
/**
|
||||
|
||||
114
apps/client/src/services/llm_chat.ts
Normal file
114
apps/client/src/services/llm_chat.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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);
|
||||
// Yield to force Preact to commit the pending tool call
|
||||
// state before we process the result.
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
break;
|
||||
case "tool_result":
|
||||
callbacks.onToolResult?.(data.toolName, data.result, data.isError);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
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";
|
||||
@@ -41,6 +42,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
|
||||
{ type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), icon: "bxs-network-chart" },
|
||||
|
||||
// Misc note types
|
||||
{ type: "llmChat", mime: "application/json", title: t("note_types.llm-chat"), icon: "bx-message-square-dots", isBeta: true },
|
||||
{ type: "render", mime: "", title: t("note_types.render-note"), icon: "bx-extension" },
|
||||
{ type: "search", title: t("note_types.saved-search"), icon: "bx-file-find", static: true },
|
||||
{ type: "webView", mime: "", title: t("note_types.web-view"), icon: "bx-globe-alt" },
|
||||
@@ -92,6 +94,7 @@ async function getNoteTypeItems(command?: TreeCommandNames) {
|
||||
function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandNames>[] {
|
||||
return NOTE_TYPES
|
||||
.filter((nt) => !nt.reserved && nt.type !== "book")
|
||||
.filter((nt) => nt.type !== "llmChat" || isExperimentalFeatureEnabled("llm"))
|
||||
.map((nt) => {
|
||||
const menuItem: MenuCommandItem<TreeCommandNames> = {
|
||||
title: nt.title,
|
||||
|
||||
@@ -270,7 +270,11 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, opts
|
||||
} else if (opts.silentInternalServerError && jqXhr.status === 500) {
|
||||
// report nothing
|
||||
} else {
|
||||
await reportError(method, url, jqXhr.status, jqXhr.responseText);
|
||||
try {
|
||||
await reportError(method, url, jqXhr.status, jqXhr.responseText);
|
||||
} catch {
|
||||
// reportError may throw (e.g. ValidationError); ensure rej() is still called below.
|
||||
}
|
||||
}
|
||||
|
||||
rej(jqXhr.responseText);
|
||||
|
||||
@@ -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);
|
||||
@@ -2638,3 +2641,26 @@ iframe.print-iframe {
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ocr-text-section {
|
||||
padding: 10px;
|
||||
background: var(--accented-background-color);
|
||||
border-left: 3px solid var(--main-border-color);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ocr-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.ocr-content {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@@ -544,14 +544,11 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#toast-container .toast .toast-header .btn-close {
|
||||
#toast-container .toast .toast-header .btn-close,
|
||||
#toast-container .toast .toast-close .btn-close {
|
||||
margin: 0 0 0 12px;
|
||||
}
|
||||
|
||||
#toast-container .toast.no-title {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#toast-container .toast .toast-body {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
.modal .modal-header .btn-close,
|
||||
.modal .modal-header .help-button,
|
||||
.modal .modal-header .custom-title-bar-button,
|
||||
#toast-container .toast .toast-header .btn-close {
|
||||
#toast-container .toast .toast-header .btn-close,
|
||||
#toast-container .toast .toast-close .btn-close {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -46,12 +47,14 @@
|
||||
}
|
||||
|
||||
.modal .modal-header .btn-close,
|
||||
#toast-container .toast .toast-header .btn-close {
|
||||
#toast-container .toast .toast-header .btn-close,
|
||||
#toast-container .toast .toast-close .btn-close {
|
||||
--modal-control-button-hover-background: var(--modal-close-button-hover-background);
|
||||
}
|
||||
|
||||
.modal .modal-header .btn-close::after,
|
||||
#toast-container .toast .toast-header .btn-close::after {
|
||||
#toast-container .toast .toast-header .btn-close::after,
|
||||
#toast-container .toast .toast-close .btn-close::after {
|
||||
content: "\ec8d";
|
||||
font-family: boxicons;
|
||||
}
|
||||
@@ -67,7 +70,8 @@
|
||||
.modal .modal-header .btn-close:hover,
|
||||
.modal .modal-header .help-button:hover,
|
||||
.modal .modal-header .custom-title-bar-button:hover,
|
||||
#toast-container .toast .toast-header .btn-close:hover {
|
||||
#toast-container .toast .toast-header .btn-close:hover,
|
||||
#toast-container .toast .toast-close .btn-close:hover {
|
||||
background: var(--modal-control-button-hover-background);
|
||||
color: var(--modal-control-button-hover-color);
|
||||
}
|
||||
@@ -75,19 +79,22 @@
|
||||
.modal .modal-header .btn-close:active,
|
||||
.modal .modal-header .help-button:active,
|
||||
.modal .modal-header .custom-title-bar-button:active,
|
||||
#toast-container .toast .toast-header .btn-close:active {
|
||||
#toast-container .toast .toast-header .btn-close:active,
|
||||
#toast-container .toast .toast-close .btn-close:active {
|
||||
transform: scale(.85);
|
||||
}
|
||||
|
||||
.modal .modal-header .btn-close:focus,
|
||||
.modal .modal-header .help-button:focus,
|
||||
#toast-container .toast .toast-header .btn-close:focus {
|
||||
#toast-container .toast .toast-header .btn-close:focus,
|
||||
#toast-container .toast .toast-close .btn-close:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.modal .modal-header .btn-close:focus-visible,
|
||||
.modal .modal-header .help-button:focus-visible,
|
||||
#toast-container .toast .toast-header .btn-close:focus-visible {
|
||||
#toast-container .toast .toast-header .btn-close:focus-visible,
|
||||
#toast-container .toast .toast-close .btn-close:focus-visible {
|
||||
outline: 2px solid var(--input-focus-outline-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -709,7 +709,8 @@
|
||||
"advanced": "高级",
|
||||
"export_as_image": "导出为图像",
|
||||
"export_as_image_png": "PNG(栅格)",
|
||||
"export_as_image_svg": "SVG(矢量图)"
|
||||
"export_as_image_svg": "SVG(矢量图)",
|
||||
"view_ocr_text": "查看 OCR 文本"
|
||||
},
|
||||
"onclick_button": {
|
||||
"no_click_handler": "按钮组件'{{componentId}}'没有定义点击处理程序"
|
||||
@@ -1197,12 +1198,28 @@
|
||||
},
|
||||
"images": {
|
||||
"images_section_title": "图片",
|
||||
"download_images_automatically": "自动下载图片以供离线使用。",
|
||||
"download_images_description": "粘贴的 HTML 可能包含在线图片的引用,Trilium 会找到这些引用并下载图片,以便它们可以离线使用。",
|
||||
"enable_image_compression": "启用图片压缩",
|
||||
"max_image_dimensions": "图片的最大宽度/高度(超过此限制的图像将会被缩放)。",
|
||||
"jpeg_quality_description": "JPEG 质量(10 - 最差质量,100 最佳质量,建议为 50 - 85)",
|
||||
"max_image_dimensions_unit": "像素"
|
||||
"download_images_automatically": "自动下载图片",
|
||||
"download_images_description": "从粘贴的 HTML 代码中下载引用的在线图片,以便离线使用。",
|
||||
"enable_image_compression": "图片压缩",
|
||||
"max_image_dimensions": "最大图像尺寸",
|
||||
"jpeg_quality_description": "建议范围为 50–85。较低的值可以减小文件大小,较高的值可以保留细节。",
|
||||
"max_image_dimensions_unit": "像素",
|
||||
"enable_image_compression_description": "上传或粘贴图片时,对其进行压缩和调整大小。",
|
||||
"max_image_dimensions_description": "超过此尺寸的图片将自动调整大小。",
|
||||
"jpeg_quality": "JPEG质量",
|
||||
"ocr_section_title": "文本提取(OCR)",
|
||||
"ocr_related_content_languages": "内容语言(用于文本提取)",
|
||||
"ocr_auto_process": "自动处理新文件",
|
||||
"ocr_auto_process_description": "自动从新上传或粘贴的文件中提取文本。",
|
||||
"ocr_min_confidence": "最低置信度",
|
||||
"ocr_confidence_description": "仅提取置信度高于此阈值的文本。较低的置信度阈值会包含更多文本,但可能准确性较低。",
|
||||
"batch_ocr_title": "处理现有文件",
|
||||
"batch_ocr_description": "从笔记中的所有现有图像、PDF 和 Office 文档中提取文本。这可能需要一些时间,具体取决于文件数量。",
|
||||
"batch_ocr_start": "开始批量处理",
|
||||
"batch_ocr_starting": "开始批量处理...",
|
||||
"batch_ocr_progress": "正在处理 {{processed}} 个文件,共 {{total}} 个文件...",
|
||||
"batch_ocr_completed": "批量处理完成!已处理 {{processed}} 个文件。",
|
||||
"batch_ocr_error": "批量处理过程中出错:{{error}}"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "附件清理超时",
|
||||
@@ -1535,8 +1552,9 @@
|
||||
"new-feature": "新建",
|
||||
"collections": "集合",
|
||||
"book": "集合",
|
||||
"ai-chat": "AI聊天",
|
||||
"spreadsheet": "电子表格"
|
||||
"ai-chat": "AI对话",
|
||||
"spreadsheet": "电子表格",
|
||||
"llm-chat": "AI对话"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保护笔记",
|
||||
@@ -2046,7 +2064,9 @@
|
||||
"title": "实验选项",
|
||||
"disclaimer": "这些选项处于实验阶段,可能导致系统不稳定。请谨慎使用。",
|
||||
"new_layout_name": "新布局",
|
||||
"new_layout_description": "尝试全新布局,呈现更现代的外观并提升易用性。后续版本将进行重大调整。"
|
||||
"new_layout_description": "尝试全新布局,呈现更现代的外观并提升易用性。后续版本将进行重大调整。",
|
||||
"llm_name": "AI/大语言模型对话",
|
||||
"llm_description": "启用由大语言模型驱动的 AI对话侧边栏和大语言模型对话笔记。"
|
||||
},
|
||||
"tab_history_navigation_buttons": {
|
||||
"go-back": "返回前一笔记",
|
||||
@@ -2215,5 +2235,77 @@
|
||||
"sample_venn": "韦恩图",
|
||||
"sample_ishikawa": "鱼骨图",
|
||||
"placeholder": "输入你的美人鱼图的内容,或者使用下面的示例图之一。"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "输入消息…",
|
||||
"send": "发送",
|
||||
"sending": "正在发送...",
|
||||
"empty_state": "在下方输入消息,即可开始对话。",
|
||||
"searching_web": "在网上搜索…",
|
||||
"web_search": "联网搜索",
|
||||
"sources": "来源",
|
||||
"extended_thinking": "延伸思考",
|
||||
"legacy_models": "传统模型",
|
||||
"thinking": "正在思考...",
|
||||
"thought_process": "思考过程",
|
||||
"tool_calls": "{{count}} 次工具调用",
|
||||
"input": "输入",
|
||||
"result": "结果",
|
||||
"error": "错误",
|
||||
"tool_error": "失败",
|
||||
"total_tokens": "{{total}} 个词元",
|
||||
"tokens_detail": "{{prompt}} 提示词 + {{completion}} 补全",
|
||||
"tokens_used": "{{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元",
|
||||
"tokens_used_with_cost": "{{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元(约 ${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元(约 ${{cost}})",
|
||||
"tokens": "词元",
|
||||
"context_used": "{{percentage}}% 使用率",
|
||||
"note_context_enabled": "点击即可禁用笔记上下文:{{title}}",
|
||||
"note_context_disabled": "点击即可将当前注释添加到上下文中",
|
||||
"no_provider_message": "未配置人工智能提供商。添加一个即可开始对话。",
|
||||
"add_provider": "添加人工智能提供商",
|
||||
"note_tools": "笔记访问"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "AI对话",
|
||||
"launcher_title": "打开AI对话",
|
||||
"new_chat": "开始新对话",
|
||||
"save_chat": "将对话保存到笔记",
|
||||
"empty_state": "开始对话",
|
||||
"history": "对话历史",
|
||||
"recent_chats": "最近对话",
|
||||
"no_chats": "无历史对话"
|
||||
},
|
||||
"ocr": {
|
||||
"extracted_text": "提取文本(OCR)",
|
||||
"extracted_text_title": "提取文本(OCR)",
|
||||
"loading_text": "正在加载OCR文本...",
|
||||
"no_text_available": "暂无OCR文本",
|
||||
"no_text_explanation": "该笔记未进行 OCR 文本提取处理,或未找到文本。",
|
||||
"failed_to_load": "OCR文本加载失败",
|
||||
"process_now": "处理 OCR",
|
||||
"processing": "正在处理...",
|
||||
"processing_started": "OCR识别已开始。请稍候片刻并刷新页面。",
|
||||
"processing_failed": "OCR处理启动失败",
|
||||
"view_extracted_text": "查看提取的文本(OCR)"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "添加子节点",
|
||||
"addParent": "添加父节点",
|
||||
"addSibling": "添加同级节点",
|
||||
"removeNode": "删除节点",
|
||||
"focus": "专注模式",
|
||||
"cancelFocus": "退出专注模式",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"link": "链接",
|
||||
"linkBidirectional": "双向链接",
|
||||
"clickTips": "请点击目标节点",
|
||||
"summary": "总结"
|
||||
},
|
||||
"llm": {
|
||||
"settings_description": "配置人工智能和大语言模型集成。",
|
||||
"add_provider": "添加提供商"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@
|
||||
"calendar_root": "marks note which should be used as root for day notes. Only one should be marked as such.",
|
||||
"archived": "notes with this label won't be visible by default in search results (also in Jump To, Add Link dialogs etc).",
|
||||
"exclude_from_export": "notes (with their sub-tree) won't be included in any note export",
|
||||
"run": "defines on which events script should run. Possible values are:\n<ul>\n<li>frontendStartup - when Trilium frontend starts up (or is refreshed), but not on mobile.</li>\n<li>mobileStartup - when Trilium frontend starts up (or is refreshed), on mobile.</li>\n<li>backendStartup - when Trilium backend starts up</li>\n<li>hourly - run once an hour. You can use additional label <code>runAtHour</code> to specify at which hour.</li>\n<li>daily - run once a day</li>\n</ul>",
|
||||
"run": "defines on which events script should run. Possible values are:\n<ul>\n<li>frontendStartup - when Trilium frontend starts up (or is refreshed), but not on mobile.</li>\n<li>mobileStartup - when Trilium frontend starts up (or is refreshed), on mobile.</li>\n<li>backendStartup - when Trilium backend starts up.</li>\n<li>hourly - run once an hour. You can use additional label <code>runAtHour</code> to specify at which hour.</li>\n<li>daily - run once a day.</li>\n</ul>",
|
||||
"run_on_instance": "Define which trilium instance should run this on. Default to all instances.",
|
||||
"run_at_hour": "On which hour should this run. Should be used together with <code>#run=hourly</code>. Can be defined multiple times for more runs during the day.",
|
||||
"disable_inclusion": "scripts with this label won't be included into parent script execution.",
|
||||
@@ -691,6 +691,7 @@
|
||||
"search_in_note": "Search in note",
|
||||
"note_source": "Note source",
|
||||
"note_attachments": "Note attachments",
|
||||
"view_ocr_text": "View OCR text",
|
||||
"open_note_externally": "Open note externally",
|
||||
"open_note_externally_title": "File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.",
|
||||
"open_note_custom": "Open note custom",
|
||||
@@ -1157,7 +1158,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",
|
||||
@@ -1252,12 +1255,28 @@
|
||||
},
|
||||
"images": {
|
||||
"images_section_title": "Images",
|
||||
"download_images_automatically": "Download images automatically for offline use.",
|
||||
"download_images_description": "Pasted HTML can contain references to online images, Trilium will find those references and download the images so that they are available offline.",
|
||||
"enable_image_compression": "Enable image compression",
|
||||
"max_image_dimensions": "Max width / height of an image (image will be resized if it exceeds this setting).",
|
||||
"download_images_automatically": "Download images automatically",
|
||||
"download_images_description": "Download referenced online images from pasted HTML so they are available offline.",
|
||||
"enable_image_compression": "Image compression",
|
||||
"enable_image_compression_description": "Compress and resize images when they are uploaded or pasted.",
|
||||
"max_image_dimensions": "Max image dimensions",
|
||||
"max_image_dimensions_description": "Images exceeding this size will be resized automatically.",
|
||||
"max_image_dimensions_unit": "pixels",
|
||||
"jpeg_quality_description": "JPEG quality (10 - worst quality, 100 - best quality, 50 - 85 is recommended)"
|
||||
"jpeg_quality": "JPEG quality",
|
||||
"jpeg_quality_description": "Recommended range is 50–85. Lower values reduce file size, higher values preserve detail.",
|
||||
"ocr_section_title": "Text Extraction (OCR)",
|
||||
"ocr_related_content_languages": "Content languages (used for text extraction)",
|
||||
"ocr_auto_process": "Auto-process new files",
|
||||
"ocr_auto_process_description": "Automatically extract text from newly uploaded or pasted files.",
|
||||
"ocr_min_confidence": "Minimum confidence",
|
||||
"ocr_confidence_description": "Only extract text above this confidence threshold. Lower values include more text but may be less accurate.",
|
||||
"batch_ocr_title": "Process Existing Files",
|
||||
"batch_ocr_description": "Extract text from all existing images, PDFs, and Office documents in your notes. This may take some time depending on the number of files.",
|
||||
"batch_ocr_start": "Start Batch Processing",
|
||||
"batch_ocr_starting": "Starting batch processing...",
|
||||
"batch_ocr_progress": "Processing {{processed}} of {{total}} files...",
|
||||
"batch_ocr_completed": "Batch processing completed! Processed {{processed}} files.",
|
||||
"batch_ocr_error": "Error during batch processing: {{error}}"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "Attachment Erasure Timeout",
|
||||
@@ -1303,7 +1322,7 @@
|
||||
"custom_name_label": "Custom search engine name",
|
||||
"custom_name_placeholder": "Customize search engine name",
|
||||
"custom_url_label": "Custom search engine URL should include {keyword} as a placeholder for the search term.",
|
||||
"custom_url_placeholder": "Customize search engine url",
|
||||
"custom_url_placeholder": "Customize search engine URL",
|
||||
"save_button": "Save"
|
||||
},
|
||||
"tray": {
|
||||
@@ -1599,6 +1618,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 +1630,48 @@
|
||||
"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",
|
||||
"sources_summary": "{{count}} sources from {{sites}} sites",
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
@@ -1921,7 +1983,7 @@
|
||||
},
|
||||
"content_language": {
|
||||
"title": "Content languages",
|
||||
"description": "Select one or more languages that should appear in the language selection in the Basic Properties section of a read-only or editable text note. This will allow features such as spell-checking or right-to-left support."
|
||||
"description": "Select one or more languages that should appear in the language selection in the Basic Properties section of a read-only or editable text note. This will allow features such as spell-checking, right-to-left support and text extraction (OCR)."
|
||||
},
|
||||
"switch_layout_button": {
|
||||
"title_vertical": "Move editing pane to the bottom",
|
||||
@@ -2021,6 +2083,22 @@
|
||||
"calendar_view": {
|
||||
"delete_note": "Delete note..."
|
||||
},
|
||||
"ocr": {
|
||||
"extracted_text": "Extracted Text (OCR)",
|
||||
"extracted_text_title": "Extracted Text (OCR)",
|
||||
"loading_text": "Loading OCR text...",
|
||||
"no_text_available": "No OCR text available",
|
||||
"no_text_explanation": "This note has not been processed for OCR text extraction or no text was found.",
|
||||
"failed_to_load": "Failed to load OCR text",
|
||||
"process_now": "Process OCR",
|
||||
"processing": "Processing...",
|
||||
"processing_started": "OCR processing has been started. Please wait a moment and refresh.",
|
||||
"processing_complete": "OCR processing complete.",
|
||||
"processing_failed": "Failed to start OCR processing",
|
||||
"text_filtered_low_confidence": "OCR detected text with {{confidence}}% confidence, but it was discarded because your minimum threshold is {{threshold}}%.",
|
||||
"open_media_settings": "Open Settings",
|
||||
"view_extracted_text": "View extracted text (OCR)"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "Tree: {{name}}",
|
||||
"export_note_title": "Export Note",
|
||||
@@ -2229,6 +2307,63 @@
|
||||
"sample_user_journey": "User Journey",
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Venn",
|
||||
"sample_ishikawa": "Ishikawa"
|
||||
"sample_ishikawa": "Ishikawa",
|
||||
"sample_treeview": "TreeView",
|
||||
"sample_wardley": "Wardley Map"
|
||||
},
|
||||
"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.",
|
||||
"feature_not_enabled": "Enable the LLM experimental feature in Settings → Advanced → Experimental features to use AI 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": "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.",
|
||||
"mcp_endpoint_title": "Endpoint URL",
|
||||
"mcp_endpoint_description": "Add this URL to your AI assistant's MCP configuration",
|
||||
"tools": {
|
||||
"search_notes": "Search notes",
|
||||
"get_note": "Get note",
|
||||
"get_note_content": "Get note content",
|
||||
"update_note_content": "Update note content",
|
||||
"append_to_note": "Append to note",
|
||||
"create_note": "Create 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/>",
|
||||
"get_attachment": "Get attachment",
|
||||
"get_attachment_content": "Read attachment content"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Rendu impossible d'un widget React custom"
|
||||
}
|
||||
},
|
||||
"widget-missing-parent": "Le widget personnalisé ne comprend pas de propriété '{{property}}' définie\n\nSi ce script est prévu pour être exécuté sans fonctionnalité UI, utilisez '#run=frontendStartup' plutôt.",
|
||||
"open-script-note": "Ouvrir une note script",
|
||||
"scripting-error": "Échec du script personnalisé : {{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Ajouter un lien",
|
||||
@@ -46,7 +49,7 @@
|
||||
"prefix": "Préfixe : ",
|
||||
"save": "Sauvegarder",
|
||||
"branch_prefix_saved": "Le préfixe de la branche a été enregistré.",
|
||||
"edit_branch_prefix_multiple": "Modifier le préfixe de branche pour {{count}} branches",
|
||||
"edit_branch_prefix_multiple": "Modifier le préfixe pour {{count}} branches",
|
||||
"branch_prefix_saved_multiple": "Le préfixe de la branche a été sauvegardé pour {{count}} branches.",
|
||||
"affected_branches": "Branches impactées ({{count}}):"
|
||||
},
|
||||
@@ -114,7 +117,7 @@
|
||||
"export_in_progress": "Exportation en cours : {{progressCount}}",
|
||||
"export_finished_successfully": "L'exportation s'est terminée avec succès.",
|
||||
"format_pdf": "PDF - pour l'impression ou le partage de documents.",
|
||||
"share-format": "HTML pour la publication Web - utilise le même thème que celui utilisé pour les notes partagées, mais peut être publié sous forme de site Web statique."
|
||||
"share-format": "HTML pour la publication Web : utilise le même thème que celui utilisé pour les notes partagées, mais peut être publié sous forme de site Web statique."
|
||||
},
|
||||
"help": {
|
||||
"noteNavigation": "Navigation dans les notes",
|
||||
@@ -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,23 +747,25 @@
|
||||
"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"
|
||||
},
|
||||
"zpetne_odkazy": {
|
||||
"relation": "relation",
|
||||
"backlink_one": "{{count}} Lien inverse",
|
||||
"backlink_many": "",
|
||||
"backlink_other": "{{count}} Liens inverses"
|
||||
"backlink_one": "{{count}} Rétrolien",
|
||||
"backlink_many": "{{count}} Rétroliens",
|
||||
"backlink_other": "{{count}} Rétrolien"
|
||||
},
|
||||
"mobile_detail_menu": {
|
||||
"insert_child_note": "Insérer une note enfant",
|
||||
"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": "{{number}} icône recherchées parmi {{count}} packs.",
|
||||
"search_placeholder_many": "{{number}} icônes recherchées parmi {{count}} packs.",
|
||||
"search_placeholder_other": "{{number}} icônes recherchées parmi {{count}} packs.",
|
||||
"search_placeholder_filtered": "Rechercher {{number}} icônes dans {{name}}"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Type de note",
|
||||
@@ -782,7 +795,7 @@
|
||||
"collapse_all_notes": "Réduire toutes les notes",
|
||||
"collapse": "Réduire",
|
||||
"expand": "Développer",
|
||||
"invalid_view_type": "Type de vue non valide '{{type}}'",
|
||||
"invalid_view_type": "Type de vue '{{type}}' non valide",
|
||||
"calendar": "Calendrier",
|
||||
"book_properties": "Propriétés de la collection",
|
||||
"table": "Tableau",
|
||||
@@ -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é."
|
||||
},
|
||||
@@ -1171,8 +1187,8 @@
|
||||
},
|
||||
"code_mime_types": {
|
||||
"title": "Types MIME disponibles dans la liste déroulante",
|
||||
"tooltip_syntax_highlighting": "Souligner la syntaxe",
|
||||
"tooltip_code_block_syntax": "Blocs de code dans les notes de texte",
|
||||
"tooltip_syntax_highlighting": "Mise en évidence de la syntaxe",
|
||||
"tooltip_code_block_syntax": "Blocs de code dans les notes textuelles",
|
||||
"tooltip_code_note_syntax": "Notes de code"
|
||||
},
|
||||
"vim_key_bindings": {
|
||||
@@ -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",
|
||||
@@ -1516,7 +1539,13 @@
|
||||
},
|
||||
"highlights_list_2": {
|
||||
"title": "Accentuations",
|
||||
"options": "Options"
|
||||
"options": "Options",
|
||||
"title_with_count_one": "{{count}} mise en évidence",
|
||||
"title_with_count_many": "{{count}} mises en évidence",
|
||||
"title_with_count_other": "{{count}} mises en évidence",
|
||||
"modal_title": "Configurer les mises en évidence",
|
||||
"menu_configure": "Configuration des mises en évidence...",
|
||||
"no_highlights": "Aucune mise en évidence."
|
||||
},
|
||||
"quick-search": {
|
||||
"placeholder": "Recherche rapide",
|
||||
@@ -1540,7 +1569,17 @@
|
||||
"create-child-note": "Créer une note enfant",
|
||||
"unhoist": "Désactiver le focus",
|
||||
"toggle-sidebar": "Basculer la barre latérale",
|
||||
"dropping-not-allowed": "Lâcher des notes à cet endroit n'est pas autorisé"
|
||||
"dropping-not-allowed": "Déplacer des notes à cet emplacement n'est pas autorisé.",
|
||||
"clone-indicator-tooltip": "Cette note a {{- count}} parents: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "Cette note est clonée (1 parent supplémentaire: {{- parent}})",
|
||||
"shared-indicator-tooltip": "Cette note est partagée publiquement",
|
||||
"shared-indicator-tooltip-with-url": "Cette note est partagée publiquement sur: {{- url}}",
|
||||
"subtree-hidden-tooltip_one": "{{count}} note enfant cachée de l'arbre",
|
||||
"subtree-hidden-tooltip_many": "{{count}} notes enfants cachées de l'arbre",
|
||||
"subtree-hidden-tooltip_other": "{{count}} notes enfants cachées de l'arbre",
|
||||
"subtree-hidden-moved-title": "Ajouté à {{title}}",
|
||||
"subtree-hidden-moved-description-collection": "Cette collection cache ses notes enfants dans l'arbre.",
|
||||
"subtree-hidden-moved-description-other": "Les notes enfants sont cachées dans l'arbre pour cette note."
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Épingler cette fenêtre au premier plan"
|
||||
@@ -1551,7 +1590,12 @@
|
||||
"printing_pdf": "Export au format PDF en cours...",
|
||||
"print_report_title": "Imprimer le rapport",
|
||||
"print_report_collection_details_button": "Consulter les détails",
|
||||
"print_report_collection_details_ignored_notes": "Notes ignorées"
|
||||
"print_report_collection_details_ignored_notes": "Notes ignorées",
|
||||
"print_report_error_title": "Échec de l'impression",
|
||||
"print_report_stack_trace": "Trace de la pile",
|
||||
"print_report_collection_content_one": "La {{count}} note de la collection n'a pas pu être imprimée car elle n'est pas prises en charge ou est protégée.",
|
||||
"print_report_collection_content_many": "Les {{count}} notes de la collection n'ont pas pu être imprimées car elles ne sont pas prises en charge ou sont protégées.",
|
||||
"print_report_collection_content_other": "Les {{count}} notes de la collection n'ont pas pu être imprimées car elles ne sont pas prises en charge ou sont protégées."
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "saisir le titre de la note ici...",
|
||||
@@ -1560,17 +1604,24 @@
|
||||
"note_type_switcher_label": "Basculer de {{type}} à :",
|
||||
"note_type_switcher_others": "Autre type de note",
|
||||
"note_type_switcher_templates": "Modèle",
|
||||
"note_type_switcher_collection": "Collection"
|
||||
"note_type_switcher_collection": "Collection",
|
||||
"edited_notes": "Notes éditées ce jour",
|
||||
"promoted_attributes": "Attributs promus"
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "Aucune note n'a été trouvée pour les paramètres de recherche donnés.",
|
||||
"search_not_executed": "La recherche n'a pas encore été exécutée. Cliquez sur le bouton \"Rechercher\" ci-dessus pour voir les résultats."
|
||||
"search_not_executed": "La recherche n'a pas encore été exécutée.",
|
||||
"search_now": "Recherche maintenant"
|
||||
},
|
||||
"spacer": {
|
||||
"configure_launchbar": "Configurer la Barre de raccourcis"
|
||||
},
|
||||
"sql_result": {
|
||||
"no_rows": "Aucune ligne n'a été renvoyée pour cette requête"
|
||||
"no_rows": "Aucune ligne n'a été renvoyée pour cette requête",
|
||||
"not_executed": "La requête n'a pas encore été exécutée.",
|
||||
"failed": "L'exécution de requêtes SQL a échoué",
|
||||
"statement_result": "Résultat de la déclaration",
|
||||
"execute_now": "Exécuter maintenant"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tableaux"
|
||||
@@ -1693,7 +1744,7 @@
|
||||
"paste": "Coller",
|
||||
"paste-as-plain-text": "Coller comme texte brut",
|
||||
"search_online": "Rechercher «{{term}}» avec {{searchEngine}}",
|
||||
"search_in_trilium": "Rechercher \"{{term}}\" dans Trilium"
|
||||
"search_in_trilium": "Rechercher « {{term}} » dans Trilium"
|
||||
},
|
||||
"image_context_menu": {
|
||||
"copy_reference_to_clipboard": "Copier la référence dans le presse-papiers",
|
||||
@@ -1703,14 +1754,15 @@
|
||||
"open_note_in_new_tab": "Ouvrir la note dans un nouvel onglet",
|
||||
"open_note_in_new_split": "Ouvrir la note dans une nouvelle division",
|
||||
"open_note_in_new_window": "Ouvrir la note dans une nouvelle fenêtre",
|
||||
"open_note_in_popup": "Édition rapide"
|
||||
"open_note_in_popup": "Édition rapide",
|
||||
"open_note_in_other_split": "Ouvrir la note dans l'autre volet"
|
||||
},
|
||||
"electron_integration": {
|
||||
"desktop-application": "Application de bureau",
|
||||
"native-title-bar": "Barre de titre native",
|
||||
"native-title-bar-description": "Sous Windows et macOS, désactiver la barre de titre native rend l'application plus compacte. Sous Linux, le maintien de la barre de titre native permet une meilleure intégration avec le reste du système.",
|
||||
"background-effects": "Activer les effets d'arrière-plan (Windows 11 uniquement)",
|
||||
"background-effects-description": "L'effet Mica ajoute un fond flou et élégant aux fenêtres de l'application, créant une profondeur et un style moderne.",
|
||||
"background-effects": "Activer les effets d'arrière-plan",
|
||||
"background-effects-description": "Ajoute un arrière-plan flou et élégant aux fenêtres d'application, créant de la profondeur et un style moderne. La « barre de titre native » doit être désactivée.",
|
||||
"restart-app-button": "Redémarrez l'application pour afficher les modifications",
|
||||
"zoom-factor": "Facteur de zoom"
|
||||
},
|
||||
@@ -1729,7 +1781,8 @@
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Créer une nouvelle note enfant et l'ajouter à la carte",
|
||||
"create-child-note-instruction": "Cliquez sur la carte pour créer une nouvelle note à cet endroit ou appuyez sur Échap pour la supprimer.",
|
||||
"unable-to-load-map": "Impossible de charger la carte."
|
||||
"unable-to-load-map": "Impossible de charger la carte.",
|
||||
"create-child-note-text": "Ajouter le marqueur"
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "Ouvrir la position",
|
||||
@@ -1834,12 +1887,13 @@
|
||||
"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)",
|
||||
"vector_dark": "Vecteur (foncé)",
|
||||
"show-scale": "Afficher l'échelle"
|
||||
"show-scale": "Afficher l'échelle",
|
||||
"show-labels": "Afficher les noms des marqueurs"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Supprimer la ligne"
|
||||
@@ -1860,7 +1914,7 @@
|
||||
"add-column-placeholder": "Entrez le nom de la colonne...",
|
||||
"edit-note-title": "Cliquez pour modifier le titre de la note",
|
||||
"edit-column-title": "Cliquez pour modifier le titre de la colonne",
|
||||
"column-already-exists": "Cette colonne existe déjà dans le tableau."
|
||||
"column-already-exists": "Cette colonne existe déjà sur le tableau."
|
||||
},
|
||||
"presentation_view": {
|
||||
"edit-slide": "Modifier cette diapositive",
|
||||
@@ -1890,22 +1944,30 @@
|
||||
"next_theme_message": "Vous utilisez actuellement le thème hérité de l'ancienne version, souhaitez-vous essayer le nouveau thème ?",
|
||||
"next_theme_button": "Essayez le nouveau thème",
|
||||
"background_effects_title": "Les effets d'arrière-plan sont désormais stables",
|
||||
"background_effects_message": "Sur les appareils Windows, les effets d'arrière-plan sont désormais parfaitement stables. Ils ajoutent une touche de couleur à l'interface utilisateur en floutant l'arrière-plan. Cette technique est également utilisée dans d'autres applications comme l'Explorateur Windows.",
|
||||
"background_effects_message": "Sur les appareils Windows et macOS les effets d'arrière-plan sont désormais stables. Ils ajoutent une touche de couleur à l'interface utilisateur en floutant l'arrière-plan.",
|
||||
"background_effects_button": "Activer les effets d'arrière-plan",
|
||||
"dismiss": "Rejeter"
|
||||
"dismiss": "Rejeter",
|
||||
"new_layout_title": "Nouvelle mise en page",
|
||||
"new_layout_message": "Nous avons introduit une mise en page modernisée pour Trilium. Le ruban a été supprimé et intégré de manière transparente dans l'interface principale, avec une nouvelle barre d'état et des sections extensibles (telles que les attributs promus) reprenant les fonctions clés.\n\nLa nouvelle mise en page est activée par défaut et peut être temporairement désactivée via Options → Apparence.",
|
||||
"new_layout_button": "Plus d'infos"
|
||||
},
|
||||
"settings": {
|
||||
"related_settings": "Paramètres associés"
|
||||
},
|
||||
"settings_appearance": {
|
||||
"related_code_blocks": "Schéma de coloration syntaxique pour les blocs de code dans les notes de texte",
|
||||
"related_code_notes": "Schéma de couleurs pour les notes de code"
|
||||
"related_code_notes": "Schéma de couleurs pour les notes de code",
|
||||
"ui": "Interface utilisateur",
|
||||
"ui_old_layout": "Ancienne mise en page",
|
||||
"ui_new_layout": "Nouvelle mise en page"
|
||||
},
|
||||
"units": {
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"total_notes": "{{count}} notes"
|
||||
"total_notes": "{{count}} notes",
|
||||
"prev_page": "Page précédente",
|
||||
"next_page": "Page suivante"
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "Impossible d'afficher le contenu en raison d'une erreur."
|
||||
@@ -1924,8 +1986,9 @@
|
||||
"unknown_widget": "Widget inconnu pour « {{id}} »."
|
||||
},
|
||||
"note_language": {
|
||||
"not_set": "Non défini",
|
||||
"configure-languages": "Configurer les langues..."
|
||||
"not_set": "Langage non défini",
|
||||
"configure-languages": "Configurer les langues...",
|
||||
"help-on-languages": "Aide sur les langues de contenu..."
|
||||
},
|
||||
"content_language": {
|
||||
"title": "Contenu des langues",
|
||||
@@ -1973,14 +2036,288 @@
|
||||
"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.",
|
||||
"auto-read-only-note": "Cette note s'affiche en mode lecture seule pour un chargement plus rapide.",
|
||||
"edit-note": "Editer la note"
|
||||
"edit-note": "Modifier la note"
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "Effacer la note..."
|
||||
"delete_note": "Supprimer 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",
|
||||
"result": "Résultat",
|
||||
"error": "Erreur",
|
||||
"tool_error": "échoué",
|
||||
"total_tokens": "{{total}} jetons",
|
||||
"tokens_detail": "{{prompt}} prompt + {{completion}} achèvement",
|
||||
"tokens_used": "{{prompt}} prompt + {{completion}} achèvement = {{total}} jetons",
|
||||
"tokens_used_with_cost": "{{prompt}} prompt + {{completion}} achèvement = {{total}} jetons (~${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} prompt + {{completion}} achèvement = {{total}} jetons",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} prompt + {{completion}} achèvement = {{total}} jetons (~${{cost}})",
|
||||
"tokens": "jetons",
|
||||
"context_used": "{{percentage}}% utilisé",
|
||||
"note_context_enabled": "Cliquez pour désactiver le contexte de la note : {{title}}",
|
||||
"note_context_disabled": "Cliquez pour inclure la note actuelle dans le contexte",
|
||||
"no_provider_message": "Aucun fournisseur d'IA configuré. Ajoutez en un pour commencer à discuter.",
|
||||
"add_provider": "Ajouter un fournisseur d'IA"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "discussion IA",
|
||||
"launcher_title": "Ouvrir la discussion IA",
|
||||
"new_chat": "Démarrer une nouvelle discussion",
|
||||
"save_chat": "Enregistrer la discussion dans les notes",
|
||||
"empty_state": "Démarrer une conversation",
|
||||
"history": "Historique des discussions",
|
||||
"recent_chats": "Discussions récentes",
|
||||
"no_chats": "Pas de discussions précédentes"
|
||||
},
|
||||
"note-color": {
|
||||
"clear-color": "Retirer la couleur de la note",
|
||||
"set-color": "Définir la couleur de la note",
|
||||
"set-custom-color": "Définir la couleur personnalisée de la note"
|
||||
},
|
||||
"popup-editor": {
|
||||
"maximize": "Basculer sur l'éditeur complet"
|
||||
},
|
||||
"server": {
|
||||
"unknown_http_error_title": "Erreur de communication avec le serveur",
|
||||
"unknown_http_error_content": "Code de statut: {{statusCode}}\nURL: {{method}} {{url}}\nMessage: {{message}}",
|
||||
"traefik_blocks_requests": "Si vous utilisez le reverse proxy Traefik, celui-ci a introduit un changement de rupture qui affecte la communication avec le serveur."
|
||||
},
|
||||
"tab_history_navigation_buttons": {
|
||||
"go-back": "Revenir à la note précédente",
|
||||
"go-forward": "Aller vers la note suivante"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"hoisted_badge": "Remonté",
|
||||
"hoisted_badge_title": "Redescendu",
|
||||
"workspace_badge": "Espace de travail",
|
||||
"scroll_to_top_title": "Aller au début de la note",
|
||||
"create_new_note": "Créer une nouvelle note enfant",
|
||||
"empty_hide_archived_notes": "Cacher les notes archivées"
|
||||
},
|
||||
"breadcrumb_badges": {
|
||||
"read_only_explicit": "Lecture seule",
|
||||
"read_only_explicit_description": "Cette note a été paramétrée manuellement en lecture seule.\nCliquer pour temporairement l'éditer.",
|
||||
"read_only_auto": "Lecture seule automatique",
|
||||
"read_only_auto_description": "Cette note a été réglée automatiquement en mode lecture seule pour des raisons de performances. Cette limite automatique est réglable à partir des paramètres.\n\nCliquez pour la modifier temporairement.",
|
||||
"read_only_temporarily_disabled": "Temporairement modifiable",
|
||||
"read_only_temporarily_disabled_description": "Cette note est actuellement modifiable, mais elle est normalement en lecture seule. La note redeviendra en lecture seule dès que vous accéderez à une autre note.\n\nCliquez pour réactiver le mode lecture seule.",
|
||||
"shared_publicly": "Partagés publiquement",
|
||||
"shared_locally": "Partagé localement",
|
||||
"shared_copy_to_clipboard": "Copier le lien vers le presse-papier",
|
||||
"shared_open_in_browser": "Ouvrir le lien dans le navigateur",
|
||||
"shared_unshare": "Supprimer le partage",
|
||||
"clipped_note": "Clip Web",
|
||||
"clipped_note_description": "Cette note a été initialement construite depuis l'url {{url}}.\n\nCliquez pour accéder à la page Web source.",
|
||||
"execute_script": "Exécuter le script",
|
||||
"execute_script_description": "Cette note est une note de script. Cliquez pour exécuter le script.",
|
||||
"execute_sql": "Exécuter la commande SQL",
|
||||
"execute_sql_description": "Cette note est une note SQL. Cliquer pour exécuter la requête SQL.",
|
||||
"save_status_saved": "Enregister",
|
||||
"save_status_saving": "Enregistrement...",
|
||||
"save_status_unsaved": "Non sauvée",
|
||||
"save_status_error": "La sauvegarde a échoué",
|
||||
"save_status_saving_tooltip": "Les modifications sont enregistrées.",
|
||||
"save_status_unsaved_tooltip": "Il y a des changements non enregistrés. Ils seront enregistrés automatiquement dans un instant.",
|
||||
"save_status_error_tooltip": "Une erreur s'est produite lors de l'enregistrement de la note. Si possible, essayez de copier le contenu de la note ailleurs et de recharger l'application."
|
||||
},
|
||||
"right_pane": {
|
||||
"toggle": "Basculer le panneau de droite",
|
||||
"custom_widget_go_to_source": "Aller sur le code source",
|
||||
"empty_message": "Rien à afficher pour cette note",
|
||||
"empty_button": "Cacher le panneau"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_one": "{{count}} pièce jointe",
|
||||
"attachments_many": "{{count}} pièces jointes",
|
||||
"attachments_other": "{{count}} pièces jointes",
|
||||
"layers_one": "{{count}} couche",
|
||||
"layers_many": "{{count}} couches",
|
||||
"layers_other": "{{count}} couches",
|
||||
"pages_one": "{{count}} page",
|
||||
"pages_many": "{{count}} pages",
|
||||
"pages_other": "{{count}} pages",
|
||||
"pages_alt": "Page {{pageNumber}}",
|
||||
"pages_loading": "Chargement..."
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "Disponible sur {{platform}}"
|
||||
},
|
||||
"mobile_tab_switcher": {
|
||||
"title_one": "{{count}} onglet",
|
||||
"title_many": "{{count}} onglets",
|
||||
"title_other": "{{count}} onglets",
|
||||
"more_options": "Autres options"
|
||||
},
|
||||
"bookmark_buttons": {
|
||||
"bookmarks": "Signets"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "pack d'icônes",
|
||||
"type_backend_script": "Script backend",
|
||||
"type_frontend_script": "Script frontend",
|
||||
"type_widget": "Widget",
|
||||
"type_app_css": "CSS personnalisé",
|
||||
"type_render_note": "Note de rendu",
|
||||
"type_web_view": "Vue Web",
|
||||
"type_app_theme": "Thème personnalisé",
|
||||
"toggle_tooltip_enable_tooltip": "Cliquer pour activer {{type}}.",
|
||||
"toggle_tooltip_disable_tooltip": "Cliquer pour désactiver ce {{type}}.",
|
||||
"menu_docs": "Ouvrir la documentation",
|
||||
"menu_execute_now": "Exécuter le script maintenant",
|
||||
"menu_run": "Démarrer automatiquement",
|
||||
"menu_run_disabled": "Manuellement",
|
||||
"menu_run_backend_startup": "Lorsque le backend commence",
|
||||
"menu_run_hourly": "Horaire",
|
||||
"menu_run_daily": "Quotidien",
|
||||
"menu_run_frontend_startup": "Lorsque le frontend du bureau démarre",
|
||||
"menu_run_mobile_startup": "Lorsque le frontend mobile démarre",
|
||||
"menu_change_to_widget": "Passer au widget",
|
||||
"menu_change_to_frontend_script": "Passer au script frontend",
|
||||
"menu_theme_base": "Thème de base"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "En savoir plus"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "Tapez le contenu de votre diagramme Mermaid ou utilisez l'un des diagrammes de l'échantillon ci-dessous.",
|
||||
"sample_diagrams": "Diagrammes d 'exemple:",
|
||||
"sample_flowchart": "Organigramme",
|
||||
"sample_class": "Classe",
|
||||
"sample_sequence": "Séquence",
|
||||
"sample_entity_relationship": "Entité relationnelle",
|
||||
"sample_state": "État",
|
||||
"sample_mindmap": "Carte mentale",
|
||||
"sample_architecture": "Architecture",
|
||||
"sample_block": "Bloc",
|
||||
"sample_c4": "C4",
|
||||
"sample_gantt": "Gantt",
|
||||
"sample_git": "Git",
|
||||
"sample_kanban": "Kanban",
|
||||
"sample_packet": "Paquet",
|
||||
"sample_pie": "Camembert",
|
||||
"sample_quadrant": "Quadrant",
|
||||
"sample_radar": "Radar",
|
||||
"sample_requirement": "Exigence",
|
||||
"sample_sankey": "Sankey",
|
||||
"sample_timeline": "Chronologie",
|
||||
"sample_treemap": "Arborescence",
|
||||
"sample_user_journey": "Utilisateur Journey",
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Venn",
|
||||
"sample_ishikawa": "Ishikawa"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "Ajouter un enfant",
|
||||
"addParent": "Ajouter parent",
|
||||
"addSibling": "Ajouter un frère",
|
||||
"removeNode": "Supprimer le nœud",
|
||||
"focus": "Mode Focus",
|
||||
"cancelFocus": "Annuler le mode Focus",
|
||||
"moveUp": "Monter",
|
||||
"moveDown": "Descendre",
|
||||
"link": "Lien",
|
||||
"linkBidirectional": "Lien bidirectionnel",
|
||||
"clickTips": "Cliquer sur le nœud cible",
|
||||
"summary": "Résumé"
|
||||
},
|
||||
"llm": {
|
||||
"settings_title": "AI / LLM",
|
||||
"settings_description": "Configurer les intégrations AI et les LLM (Large Language Model).",
|
||||
"add_provider": "Ajouter le fournisseur",
|
||||
"add_provider_title": "Ajouter le fournisseur d'IA",
|
||||
"configured_providers": "Fournisseurs configurés",
|
||||
"no_providers_configured": "Aucun fournisseur n'est encore configuré.",
|
||||
"provider_name": "Nom",
|
||||
"provider_type": "Fournisseur",
|
||||
"actions": "Actions",
|
||||
"delete_provider": "Supprimer",
|
||||
"delete_provider_confirmation": "Êtes-vous sûr de vouloir supprimer le fournisseur \"{{name}}\" ?",
|
||||
"api_key": "Clé API",
|
||||
"api_key_placeholder": "Entrer votre clé API",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"status_bar": {
|
||||
"language_title": "Changer de langue",
|
||||
"note_info_title": "Afficher les informations sur les notes (par exemple, dates, taille des notes)",
|
||||
"backlinks_one": "{{count}} rétrolien",
|
||||
"backlinks_many": "{{count}} rétroliens",
|
||||
"backlinks_other": "{{count}} rétroliens",
|
||||
"backlinks_title_one": "voir le rétrolien",
|
||||
"backlinks_title_many": "voir les rétroliens",
|
||||
"backlinks_title_other": "voir les rétroliens",
|
||||
"attachments_one": "{{count}} pièce-jointe",
|
||||
"attachments_many": "{{count}} pièces-jointes",
|
||||
"attachments_other": "{{count}} pièces-jointes",
|
||||
"attachments_title_one": "Voir la pièce-jointe dans un nouvel onglet",
|
||||
"attachments_title_many": "Voir les pièces-jointes dans un nouvel onglet",
|
||||
"attachments_title_other": "Voir les pièces-jointes dans un nouvel onglet",
|
||||
"attributes_one": "{{count}} attribut",
|
||||
"attributes_many": "{{count}} attributs",
|
||||
"attributes_other": "{{count}} attributs",
|
||||
"attributes_title": "Attributs propres et attributs hérités",
|
||||
"note_paths_one": "{{count}} chemin",
|
||||
"note_paths_many": "{{count}} chemins",
|
||||
"note_paths_other": "{{count}} chemins",
|
||||
"note_paths_title": "Chemins de la note",
|
||||
"code_note_switcher": "Changer de langue"
|
||||
},
|
||||
"attributes_panel": {
|
||||
"title": "Attributs de la note"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
"calendar_root": "nóta marcáilte ar cheart a úsáid mar fhréamh do nótaí lae. Níor cheart ach ceann amháin a mharcáil mar sin.",
|
||||
"archived": "Ní bheidh nótaí leis an lipéad seo le feiceáil de réir réamhshocraithe i dtorthaí cuardaigh (i ndialóga Léim Chuig, Cuir Nasc Leis srl. chomh maith).",
|
||||
"exclude_from_export": "ní chuirfear nótaí (lena bhfo-chrann) san áireamh in aon onnmhairiú nótaí",
|
||||
"run": "Sainmhíníonn sé seo cé na himeachtaí ar cheart don script rith orthu. Is iad seo a leanas na luachanna féideartha:\n<ul>\n<li>frontendStartup - nuair a thosaíonn (nó a athnuachan) tosaigh Trilium, ach ní ar fhóin phóca.</li>\n<li>mobileStartup - nuair a thosaíonn (nó a athnuachan) tosaigh Trilium, ar fhóin phóca.</li>\n<li>backendStartup - nuair a thosaíonn cúltaca Trilium</li>\n<li>uair an chloig - rith uair san uair. Is féidir leat lipéad breise <code>runAtHour</code> a úsáid chun a shonrú cén uair a ritheann sé.</li>\n<li>daily - rith uair sa lá</li>\n</ul>",
|
||||
"run": "Sainmhíníonn sé seo cé na himeachtaí ar cheart don script rith orthu. Is iad seo a leanas na luachanna féideartha:\n<ul>\n<li>frontendStartup - nuair a thosaíonn (nó a athnuachan) tosaigh Trilium, ach ní ar fhóin phóca.</li>\n<li>mobileStartup - nuair a thosaíonn (nó a athnuachan) tosaigh Trilium, ar fhóin phóca.</li>\n<li>backendStartup - nuair a thosaíonn cúltaca Trilium.</li>\n<li>hourly - rith uair san uair. Is féidir leat lipéad breise <code>runAtHour</code> a úsáid chun a shonrú cén uair a ritheann sé.</li>\n<li>daily - rith uair sa lá.</li>\n</ul>",
|
||||
"run_on_instance": "Sainmhínigh cén sampla de Trilium ba chóir é seo a rith air. Réamhshocrú do gach sampla.",
|
||||
"run_at_hour": "Cén uair ar cheart é seo a rith? Ba cheart é a úsáid i dteannta <code>#run=hourly</code>. Is féidir é seo a shainiú arís agus arís eile le haghaidh níos mó ritheanna i rith an lae.",
|
||||
"disable_inclusion": "Ní chuirfear scripteanna leis an lipéad seo san áireamh i bhforghníomhú an scripte tuismitheora.",
|
||||
@@ -709,7 +709,8 @@
|
||||
"export_as_image": "Easpórtáil mar íomhá",
|
||||
"export_as_image_png": "PNG (rastar)",
|
||||
"export_as_image_svg": "SVG (veicteoir)",
|
||||
"note_map": "Léarscáil nótaí"
|
||||
"note_map": "Léarscáil nótaí",
|
||||
"view_ocr_text": "Féach ar théacs OCR"
|
||||
},
|
||||
"onclick_button": {
|
||||
"no_click_handler": "Níl aon láimhseálaí cliceáil sainithe ag an ngiuirléid cnaipe '{{componentId}}'"
|
||||
@@ -1127,7 +1128,9 @@
|
||||
"title": "Roghanna Turgnamhacha",
|
||||
"disclaimer": "Is roghanna turgnamhacha iad seo agus d’fhéadfadh éagobhsaíocht a bheith mar thoradh orthu. Bain úsáid astu go cúramach.",
|
||||
"new_layout_name": "Leagan Amach Nua",
|
||||
"new_layout_description": "Bain triail as an leagan amach nua le haghaidh cuma níos nua-aimseartha agus inúsáidteachta feabhsaithe. Tá sé faoi réir athruithe móra sna heisiúintí atá le teacht."
|
||||
"new_layout_description": "Bain triail as an leagan amach nua le haghaidh cuma níos nua-aimseartha agus inúsáidteachta feabhsaithe. Tá sé faoi réir athruithe móra sna heisiúintí atá le teacht.",
|
||||
"llm_name": "Comhrá AI / LLM",
|
||||
"llm_description": "Cumasaigh an taobhbharra comhrá AI agus nótaí comhrá LLM faoi thiomáint ag samhlacha teanga móra."
|
||||
},
|
||||
"fonts": {
|
||||
"theme_defined": "Téama sainmhínithe",
|
||||
@@ -1222,12 +1225,28 @@
|
||||
},
|
||||
"images": {
|
||||
"images_section_title": "Íomhánna",
|
||||
"download_images_automatically": "Íoslódáil íomhánna go huathoibríoch le húsáid as líne.",
|
||||
"download_images_description": "Is féidir tagairtí d’íomhánna ar líne a bheith i HTML greamaithe, aimseoidh Trilium na tagairtí sin agus íoslódálfaidh sé na híomhánna ionas go mbeidh siad ar fáil as líne.",
|
||||
"enable_image_compression": "Cumasaigh comhbhrú íomhá",
|
||||
"max_image_dimensions": "Uasleithead/airde íomhá (athrófar méid na híomhá má sháraíonn sí an socrú seo).",
|
||||
"download_images_automatically": "Íomhánna a íoslódáil go huathoibríoch",
|
||||
"download_images_description": "Íoslódáil íomhánna tagartha ar líne ó HTML greamaithe ionas go mbeidh siad ar fáil as líne.",
|
||||
"enable_image_compression": "Comhbhrú íomhá",
|
||||
"max_image_dimensions": "Uasmhéid toisí íomhá",
|
||||
"max_image_dimensions_unit": "picteilíní",
|
||||
"jpeg_quality_description": "Cáilíocht JPEG (10 - an caighdeán is measa, 100 - an caighdeán is fearr, moltar 50 - 85)"
|
||||
"jpeg_quality_description": "Is é an raon molta ná 50–85. Laghdaíonn luachanna níos ísle méid an chomhaid, agus coinníonn luachanna níos airde sonraí.",
|
||||
"enable_image_compression_description": "Comhbhrúigh agus athraigh méid íomhánna nuair a uaslódálfar nó a ghreamaítear iad.",
|
||||
"max_image_dimensions_description": "Athrófar méid íomhánna a sháraíonn an méid seo go huathoibríoch.",
|
||||
"jpeg_quality": "Cáilíocht JPEG",
|
||||
"ocr_section_title": "Eastóscadh Téacs (OCR)",
|
||||
"ocr_related_content_languages": "Teangacha ábhair (a úsáidtear le haghaidh eastóscadh téacs)",
|
||||
"ocr_auto_process": "Próiseáil comhaid nua go huathoibríoch",
|
||||
"ocr_auto_process_description": "Bain téacs go huathoibríoch as comhaid atá uaslódáilte nó greamaithe le déanaí.",
|
||||
"ocr_min_confidence": "Íosmhuinín",
|
||||
"ocr_confidence_description": "Ná bain ach téacs os cionn an tairsí muiníne seo. Cuimsíonn luachanna níos ísle níos mó téacs ach d'fhéadfadh siad a bheith níos lú cruinn.",
|
||||
"batch_ocr_title": "Próiseáil Comhaid atá ann cheana",
|
||||
"batch_ocr_description": "Bain téacs as na híomhánna, na PDFanna agus na doiciméid Office go léir atá i do nótaí. D’fhéadfadh sé seo roinnt ama a thógáil ag brath ar líon na gcomhad.",
|
||||
"batch_ocr_start": "Tosaigh Próiseáil Bhaisc",
|
||||
"batch_ocr_starting": "Ag tosú próiseáil bhaisc...",
|
||||
"batch_ocr_progress": "Ag próiseáil {{processed}} de {{total}} comhad...",
|
||||
"batch_ocr_completed": "Próiseáil bhaisc críochnaithe! Próiseáladh {{processed}} comhad.",
|
||||
"batch_ocr_error": "Earráid le linn próiseála baisce: {{error}}"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "Am Teorann Scriosadh Ceangaltáin",
|
||||
@@ -1273,7 +1292,7 @@
|
||||
"custom_name_label": "Ainm innill chuardaigh saincheaptha",
|
||||
"custom_name_placeholder": "Saincheap ainm an innill chuardaigh",
|
||||
"custom_url_label": "Ba chóir go mbeadh {keyword} san áireamh mar áitchoinneálaí don téarma cuardaigh i URL inneall cuardaigh saincheaptha.",
|
||||
"custom_url_placeholder": "Saincheap url an innill chuardaigh",
|
||||
"custom_url_placeholder": "Saincheap URL an innill chuardaigh",
|
||||
"save_button": "Sábháil"
|
||||
},
|
||||
"tray": {
|
||||
@@ -1572,7 +1591,8 @@
|
||||
"task-list": "Liosta Tascanna",
|
||||
"new-feature": "Nua",
|
||||
"collections": "Bailiúcháin",
|
||||
"spreadsheet": "Scarbhileog"
|
||||
"spreadsheet": "Scarbhileog",
|
||||
"llm-chat": "Comhrá AI"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Cosain an nóta",
|
||||
@@ -1900,7 +1920,7 @@
|
||||
},
|
||||
"content_language": {
|
||||
"title": "Teangacha ábhair",
|
||||
"description": "Roghnaigh teanga amháin nó níos mó ar cheart dóibh a bheith le feiceáil sa rogha teanga sa rannán Airíonna Bunúsacha de nóta téacs inléite amháin nó in-eagarthóireachta. Ceadóidh sé seo gnéithe ar nós seiceáil litrithe nó tacaíocht ó dheis go clé."
|
||||
"description": "Roghnaigh teanga amháin nó níos mó ar cheart dóibh a bheith le feiceáil sa rogha teanga sa rannán Airíonna Bunúsacha de nóta téacs inléite amháin nó in-eagarthóireachta. Ceadaíonn sé seo gnéithe ar nós seiceáil litrithe, tacaíocht ó dheis go clé agus eastóscadh téacs (OCR)."
|
||||
},
|
||||
"switch_layout_button": {
|
||||
"title_vertical": "Bog an painéal eagarthóireachta go dtí an bun",
|
||||
@@ -2275,5 +2295,115 @@
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Venn",
|
||||
"sample_ishikawa": "Ishikawa"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "Clóscríobh teachtaireacht...",
|
||||
"send": "Seol",
|
||||
"sending": "Ag seoladh...",
|
||||
"empty_state": "Tosaigh comhrá trí theachtaireacht a chlóscríobh thíos.",
|
||||
"searching_web": "Ag cuardach an ghréasáin...",
|
||||
"web_search": "Cuardach gréasáin",
|
||||
"note_tools": "Rochtain nótaí",
|
||||
"sources": "Foinsí",
|
||||
"extended_thinking": "Smaointeoireacht leathnaithe",
|
||||
"legacy_models": "Samhlacha oidhreachta",
|
||||
"thinking": "Ag smaoineamh...",
|
||||
"thought_process": "Próiseas smaointeoireachta",
|
||||
"tool_calls": "{{count}} glao(í) uirlisí",
|
||||
"input": "Ionchur",
|
||||
"result": "Toradh",
|
||||
"error": "Earráid",
|
||||
"tool_error": "theip",
|
||||
"total_tokens": "{{total}} comharthaí",
|
||||
"tokens_detail": "leid {{prompt}} + críochnú {{completion}}",
|
||||
"tokens_used": "{{prompt}} leid + {{completion}} críochnú = {{total}} comharthaí",
|
||||
"tokens_used_with_cost": "{{prompt}} leid + {{completion}} críochnú = {{total}} comharthaí (~${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} leid + {{completion}} críochnú = {{total}} comharthaí",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: leid {{prompt}} + críochnú {{completion}} = {{total}} comharthaí (~${{cost}})",
|
||||
"tokens": "comharthaí",
|
||||
"context_used": "Úsáideadh {{percentage}}%",
|
||||
"note_context_enabled": "Cliceáil chun comhthéacs nótaí a dhíchumasú: {{title}}",
|
||||
"note_context_disabled": "Cliceáil chun an nóta reatha a chur san áireamh i gcomhthéacs",
|
||||
"no_provider_message": "Níl aon soláthraí AI cumraithe. Cuir ceann leis chun comhrá a thosú.",
|
||||
"add_provider": "Cuir Soláthraí AI leis",
|
||||
"sources_summary": "{{count}} foinsí ó {{sites}} suíomhanna"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "Comhrá AI",
|
||||
"launcher_title": "Oscail Comhrá AI",
|
||||
"new_chat": "Tosaigh comhrá nua",
|
||||
"save_chat": "Sábháil comhrá sna nótaí",
|
||||
"empty_state": "Tosaigh comhrá",
|
||||
"history": "Stair chomhrá",
|
||||
"recent_chats": "Comhráite le déanaí",
|
||||
"no_chats": "Gan aon chomhráite roimhe seo"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "Cuir páiste leis",
|
||||
"addParent": "Cuir tuismitheoir leis",
|
||||
"addSibling": "Cuir deartháir nó deirfiúr leis",
|
||||
"removeNode": "Bain nód",
|
||||
"focus": "Mód Fócais",
|
||||
"cancelFocus": "Cealaigh Mód Fócais",
|
||||
"moveUp": "Bog suas",
|
||||
"moveDown": "Bog síos",
|
||||
"link": "Nasc",
|
||||
"linkBidirectional": "Nasc Déthreoch",
|
||||
"clickTips": "Cliceáil ar an nód sprice le do thoil",
|
||||
"summary": "Achoimre"
|
||||
},
|
||||
"llm": {
|
||||
"settings_title": "AI / LLM",
|
||||
"settings_description": "Cumraigh comhtháthú idir Intleacht Shaorga agus Múnla Teanga Mór.",
|
||||
"add_provider": "Cuir Soláthraí leis",
|
||||
"add_provider_title": "Cuir Soláthraí AI leis",
|
||||
"configured_providers": "Soláthraithe Cumraithe",
|
||||
"no_providers_configured": "Níl aon soláthraithe cumraithe fós.",
|
||||
"provider_name": "Ainm",
|
||||
"provider_type": "Soláthraí",
|
||||
"actions": "Gníomhartha",
|
||||
"delete_provider": "Scrios",
|
||||
"delete_provider_confirmation": "An bhfuil tú cinnte gur mian leat an soláthraí \"{{name}}\" a scriosadh?",
|
||||
"api_key": "Eochair API",
|
||||
"api_key_placeholder": "Cuir isteach d'eochair API",
|
||||
"cancel": "Cealaigh",
|
||||
"feature_not_enabled": "Cumasaigh an ghné turgnamhach LLM i Socruithe → Ardleibhéil → Gnéithe turgnamhacha chun comhtháthú AI a úsáid.",
|
||||
"mcp_title": "MCP (Prótacal Comhthéacs Múnla)",
|
||||
"mcp_enabled": "Freastalaí MCP",
|
||||
"mcp_enabled_description": "Nochtaigh críochphointe Prótacal Comhthéacs Múnla (MCP) ionas gur féidir le cúntóirí códaithe AI (m.sh. Claude Code, GitHub Copilot) do nótaí a léamh agus a mhodhnú. Ní féidir rochtain a fháil ar an gcríochphointe ach ó localhost.",
|
||||
"mcp_endpoint_title": "URL críochphointe",
|
||||
"mcp_endpoint_description": "Cuir an URL seo le cumraíocht MCP do chúntóra AI",
|
||||
"tools": {
|
||||
"search_notes": "Cuardaigh nótaí",
|
||||
"get_note": "Faigh nóta",
|
||||
"get_note_content": "Faigh ábhar nótaí",
|
||||
"update_note_content": "Nuashonraigh ábhar an nóta",
|
||||
"append_to_note": "Cuir leis an nóta",
|
||||
"create_note": "Cruthaigh nóta",
|
||||
"get_attributes": "Faigh tréithe",
|
||||
"get_attribute": "Faigh tréith",
|
||||
"set_attribute": "Socraigh tréith",
|
||||
"delete_attribute": "Scrios tréith",
|
||||
"get_child_notes": "Faigh nótaí leanaí",
|
||||
"get_subtree": "Faigh fo-chrann",
|
||||
"load_skill": "Luchtaigh scileanna",
|
||||
"web_search": "Cuardach gréasáin",
|
||||
"note_in_parent": "<Note/> i <Parent/>",
|
||||
"get_attachment": "Faigh ceangaltán",
|
||||
"get_attachment_content": "Léigh ábhar an cheangail"
|
||||
}
|
||||
},
|
||||
"ocr": {
|
||||
"extracted_text": "Téacs Bainte (OCR)",
|
||||
"extracted_text_title": "Téacs Bainte (OCR)",
|
||||
"loading_text": "Ag lódáil téacs OCR...",
|
||||
"no_text_available": "Níl aon téacs OCR ar fáil",
|
||||
"no_text_explanation": "Níor próiseáladh an nóta seo le haghaidh eastóscadh téacs OCR nó níor aimsíodh aon téacs.",
|
||||
"failed_to_load": "Theip ar lódáil téacs OCR",
|
||||
"process_now": "Próiseas OCR",
|
||||
"processing": "Ag próiseáil...",
|
||||
"processing_started": "Tá próiseáil OCR tosaithe. Fan nóiméad agus athnuachan le do thoil.",
|
||||
"processing_failed": "Theip ar phróiseáil OCR a thosú",
|
||||
"view_extracted_text": "Féach ar théacs eastósctha (OCR)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 indirizzo url del motore di ricerca"
|
||||
"custom_url_placeholder": "Personalizza indirizzo URL del motore di ricerca"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
@@ -1718,7 +1718,8 @@
|
||||
"new-feature": "Nuovo",
|
||||
"collections": "Collezioni",
|
||||
"ai-chat": "Chat con IA",
|
||||
"spreadsheet": "Foglio di calcolo"
|
||||
"spreadsheet": "Foglio di calcolo",
|
||||
"llm-chat": "Chat con IA"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Proteggi la nota",
|
||||
@@ -2051,7 +2052,9 @@
|
||||
"title": "Opzioni sperimentali",
|
||||
"disclaimer": "Queste opzioni sono sperimentali e potrebbero causare instabilità. Usare con cautela.",
|
||||
"new_layout_name": "Nuovo layout",
|
||||
"new_layout_description": "Prova il nuovo layout per un look più moderno e una maggiore usabilità. Soggetto a modifiche significative nelle prossime versioni."
|
||||
"new_layout_description": "Prova il nuovo layout per un look più moderno e una maggiore usabilità. Soggetto a modifiche significative nelle prossime versioni.",
|
||||
"llm_name": "Chat con IA / LLM",
|
||||
"llm_description": "Attiva la barra laterale della chat con IA e le note della chat LLM basate su modelli linguistici di grandi dimensioni."
|
||||
},
|
||||
"server": {
|
||||
"unknown_http_error_title": "Errore di comunicazione con il server",
|
||||
@@ -2245,5 +2248,62 @@
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,7 +486,8 @@
|
||||
"advanced": "高度",
|
||||
"export_as_image": "画像としてエクスポート",
|
||||
"export_as_image_png": "PNG (raster)",
|
||||
"export_as_image_svg": "SVG (vector)"
|
||||
"export_as_image_svg": "SVG (vector)",
|
||||
"view_ocr_text": "OCR テキストを表示"
|
||||
},
|
||||
"command_palette": {
|
||||
"export_note_title": "ノートをエクスポート",
|
||||
@@ -601,7 +602,8 @@
|
||||
"new-feature": "New",
|
||||
"collections": "コレクション",
|
||||
"ai-chat": "AI チャット",
|
||||
"spreadsheet": "スプレッドシート"
|
||||
"spreadsheet": "スプレッドシート",
|
||||
"llm-chat": "AI チャット"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "この日の編集されたノートはまだありません...",
|
||||
@@ -897,12 +899,28 @@
|
||||
},
|
||||
"images": {
|
||||
"images_section_title": "画像",
|
||||
"download_images_automatically": "画像を自動的にダウンロードしてオフラインで使用可能にする。",
|
||||
"download_images_description": "貼り付けられたHTMLにはオンライン画像への参照が含まれていることがありますが、Triliumはそれらの参照を見つけて画像をダウンロードし、オフラインで利用できるようにします。",
|
||||
"enable_image_compression": "画像の圧縮を有効にする",
|
||||
"max_image_dimensions": "画像の最大幅/高さ(この設定を超えると画像はリサイズされます)。",
|
||||
"download_images_automatically": "画像を自動的にダウンロードする。",
|
||||
"download_images_description": "貼り付けた HTML 内の参照画像をダウンロードし、オフラインでも利用できるようにする。",
|
||||
"enable_image_compression": "画像の圧縮",
|
||||
"max_image_dimensions": "画像の最大サイズ",
|
||||
"max_image_dimensions_unit": "ピクセル",
|
||||
"jpeg_quality_description": "JPEGの品質(10 - 最低品質、100 - 最高品質、50 - 80を推奨)"
|
||||
"jpeg_quality_description": "推奨範囲は50~85です。値が低いほどファイルサイズが小さくなり、値が高いほどディテールが保持されます。",
|
||||
"enable_image_compression_description": "画像をアップロードまたは貼り付ける際に、画像を圧縮およびサイズ変更します。",
|
||||
"max_image_dimensions_description": "このサイズを超える画像は自動的にサイズ変更されます",
|
||||
"jpeg_quality": "JPEG 画質",
|
||||
"ocr_section_title": "テキスト抽出(OCR)",
|
||||
"ocr_related_content_languages": "コンテンツ言語(テキスト抽出に使用)",
|
||||
"ocr_auto_process": "新しいファイルを自動処理",
|
||||
"ocr_auto_process_description": "新しくアップロードまたは貼り付けられたファイルからテキストを自動的に抽出します。",
|
||||
"ocr_min_confidence": "最低限の信頼度",
|
||||
"ocr_confidence_description": "この信頼度閾値以上のテキストのみを抽出します。信頼度が低いほど抽出されるテキストの量は増えますが、精度が低下する可能性があります。",
|
||||
"batch_ocr_title": "既存ファイルの処理",
|
||||
"batch_ocr_description": "ノート内の既存の画像、PDF、Office 文書からテキストを抽出します。ファイル数によっては時間がかかる場合があります。",
|
||||
"batch_ocr_start": "バッチ処理を開始します",
|
||||
"batch_ocr_starting": "バッチ処理を開始しています...",
|
||||
"batch_ocr_progress": "{{total}} ファイルのうち {{processed}} ファイルを処理中...",
|
||||
"batch_ocr_completed": "バッチ処理が完了しました!{{processed}} ファイルを処理しました。",
|
||||
"batch_ocr_error": "バッチ処理中にエラーが発生しました: {{error}}"
|
||||
},
|
||||
"search_engine": {
|
||||
"title": "検索エンジン",
|
||||
@@ -915,7 +933,7 @@
|
||||
"custom_name_label": "カスタム検索エンジンの名前",
|
||||
"custom_name_placeholder": "カスタム検索エンジンの名前",
|
||||
"custom_url_label": "カスタム検索エンジンのURLには、検索語句のプレースホルダーとして {keyword} を含める必要があります。",
|
||||
"custom_url_placeholder": "カスタム検索エンジンのurl",
|
||||
"custom_url_placeholder": "検索エンジンの URL をカスタマイズ",
|
||||
"save_button": "保存"
|
||||
},
|
||||
"tray": {
|
||||
@@ -1102,7 +1120,7 @@
|
||||
"calendar_root": "dayノートのルートとして使用するノートをマークします。このようにマークできるのは 1 つだけです。",
|
||||
"archived": "このラベルの付いたノートは、デフォルトでは検索結果に表示されません (ジャンプ先、リンクの追加ダイアログなどにも表示されません)。",
|
||||
"exclude_from_export": "ノート(サブツリーを含む)はノートのエクスポートには含まれません",
|
||||
"run": "どのイベントでスクリプトを実行するかを定義します。可能な値は次の通り:\n<ul>\n<li>frontendStartup - Trilium フロントエンドが起動(または更新)されたとき。モバイルは除く</li>\n<li>mobileStartup - モバイルで Trilium フロントエンドが起動(または更新)されたとき。</li>\n<li>backendStartup - Trilium バックエンドが起動したとき</li>\n<li>hourly - 1時間に1回実行します。 <code>runAtHour</code> というラベルを追加して、実行時刻を指定できます。</li>\n<li>daily - 1日に1回実行</li>\n</ul>",
|
||||
"run": "スクリプトを実行するイベントを定義します。指定可能な値は以下の通りです:\n<ul>\n<li>frontendStartup - Trilium フロントエンドの起動時(または更新時)に実行されます。モバイルでは実行されません。</li>\n<li>mobileStartup - モバイルでの Trilium フロントエンドの起動時(または更新時)に実行されます。</li>\n<li>backendStartup - Trilium バックエンドの起動時。</li>\n<li>hourly - 1時間ごとに実行。 <code>runAtHour</code> というラベルを追加することで、実行時刻を指定できます。</li>\n<li>daily - 1日に1回実行。</li>\n</ul>",
|
||||
"run_on_instance": "どの Trilium インスタンスでこれを実行するかを定義します。デフォルトはすべてのインスタンスです。",
|
||||
"run_at_hour": "何時に実行するかを指定します。 <code>#run=hourly</code> と併用してください。1日に複数回実行したい場合は、複数回定義できます。",
|
||||
"disable_inclusion": "このラベルが付いたスクリプトは親スクリプトの実行には含まれません。",
|
||||
@@ -1390,7 +1408,7 @@
|
||||
},
|
||||
"content_language": {
|
||||
"title": "コンテンツの言語",
|
||||
"description": "読み取り専用または編集可能なテキストノートの基本プロパティセクションの言語選択に表示する言語を 1 つ以上選択します。これにより、スペルチェックや右から左へのサポートなどの機能が利用できるようになります。"
|
||||
"description": "読み取り専用または編集可能なテキストノートの基本プロパティセクションの言語選択に表示する言語を 1 つ以上選択してください。これにより、スペルチェック、右から左へのサポート、テキスト抽出(OCR)などの機能が利用できるようになります。"
|
||||
},
|
||||
"png_export_button": {
|
||||
"button_title": "図をPNG形式でエクスポート"
|
||||
@@ -2050,7 +2068,9 @@
|
||||
"title": "実験オプション",
|
||||
"disclaimer": "これらのオプションは試験的なもので、動作が不安定になる可能性があります。注意してご使用ください。",
|
||||
"new_layout_name": "新しいレイアウト",
|
||||
"new_layout_description": "よりモダンな外観と使いやすさが向上した新しいレイアウトをお試しください。今後のリリースで大幅な変更が加えられる可能性があります。"
|
||||
"new_layout_description": "よりモダンな外観と使いやすさが向上した新しいレイアウトをお試しください。今後のリリースで大幅な変更が加えられる可能性があります。",
|
||||
"llm_name": "AI / LLM チャット",
|
||||
"llm_description": "大規模言語モデルを活用した AI チャットサイドバーと LLM チャットノートを有効にします。"
|
||||
},
|
||||
"breadcrumb_badges": {
|
||||
"read_only_explicit": "読み取り専用",
|
||||
@@ -2215,5 +2235,115 @@
|
||||
"sample_xy": "XY チャート",
|
||||
"sample_venn": "ベン図",
|
||||
"sample_ishikawa": "石川図"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "メッセージを入力してください…",
|
||||
"send": "送信",
|
||||
"sending": "送信中...",
|
||||
"empty_state": "下記にメッセージを入力して会話を始めましょう。",
|
||||
"searching_web": "ウェブ検索中…",
|
||||
"web_search": "ウェブ検索",
|
||||
"note_tools": "ノートへのアクセス",
|
||||
"sources": "ソース",
|
||||
"extended_thinking": "思考を拡張",
|
||||
"legacy_models": "レガシーモデル",
|
||||
"thinking": "思考中...",
|
||||
"thought_process": "思考プロセス",
|
||||
"tool_calls": "{{count}} 回のツール呼び出し",
|
||||
"input": "入力",
|
||||
"result": "結果",
|
||||
"error": "エラー",
|
||||
"tool_error": "失敗",
|
||||
"total_tokens": "{{total}} トークン",
|
||||
"tokens_detail": "{{prompt}} プロンプト + {{completion}} コンプリーション",
|
||||
"tokens_used": "{{prompt}} プロンプト + {{completion}} コンプリーション = {{total}} トークン",
|
||||
"tokens_used_with_cost": "{{prompt}} プロンプト + {{completion}} コンプリーション = {{total}} トークン (~${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} プロンプト + {{completion}} コンプリーション = {{total}} トークン",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} プロンプト + {{completion}} コンプリーション = {{total}} トークン (~${{cost}})",
|
||||
"tokens": "トークン",
|
||||
"context_used": "{{percentage}} % 使用済み",
|
||||
"note_context_enabled": "クリックしてノートのコンテキストを無効にする: {{title}}",
|
||||
"note_context_disabled": "クリックして現在のノートをコンテキストに含める",
|
||||
"no_provider_message": "AI プロバイダーが設定されていません。チャットを開始するには、プロバイダーを追加してください。",
|
||||
"add_provider": "AI プロバイダーを追加",
|
||||
"sources_summary": "{{count}} 件のソースを {{sites}} サイトから取得"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "AI チャット",
|
||||
"launcher_title": "AI チャットを開く",
|
||||
"new_chat": "新しいチャットを開始",
|
||||
"save_chat": "チャットをノートに保存",
|
||||
"empty_state": "会話を開始",
|
||||
"history": "チャット履歴",
|
||||
"recent_chats": "最近のチャット",
|
||||
"no_chats": "過去のチャットはありません"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "子ノードを追加",
|
||||
"addParent": "親ノードを追加",
|
||||
"addSibling": "兄弟ノードを追加",
|
||||
"removeNode": "ノードを削除",
|
||||
"focus": "フォーカスモード",
|
||||
"cancelFocus": "フォーカスモードを解除",
|
||||
"moveUp": "上に移動",
|
||||
"moveDown": "下に移動",
|
||||
"link": "リンク",
|
||||
"linkBidirectional": "双方向リンク",
|
||||
"clickTips": "対象ノードをクリックしてください",
|
||||
"summary": "概要"
|
||||
},
|
||||
"llm": {
|
||||
"settings_title": "AI / LLM",
|
||||
"settings_description": "AI と大規模言語モデルの連携設定をします。",
|
||||
"add_provider": "プロバイダーを追加",
|
||||
"add_provider_title": "AI プロバイダーを追加",
|
||||
"configured_providers": "設定済みプロバイダー",
|
||||
"no_providers_configured": "まだプロバイダーが設定されていません。",
|
||||
"provider_name": "名前",
|
||||
"provider_type": "プロバイダー",
|
||||
"actions": "アクション",
|
||||
"delete_provider": "削除",
|
||||
"delete_provider_confirmation": "プロバイダー \"{{name}}\" を削除してもよろしいですか?",
|
||||
"api_key": "API キー",
|
||||
"api_key_placeholder": "API キーを入力してください",
|
||||
"cancel": "キャンセル",
|
||||
"feature_not_enabled": "AI 連携機能を使用するには、「設定」→「高度」→「実験的機能」で LLM 実験的機能を有効にしてください。",
|
||||
"mcp_title": "MCP(モデル コンテキスト プロトコル)",
|
||||
"mcp_enabled": "MCP サーバー",
|
||||
"mcp_enabled_description": "AI コーディングアシスタント(例:Claude Code、GitHub Copilot)がノートを読み取って変更できるように、モデルコンテキストプロトコル(MCP)エンドポイントを公開します。このエンドポイントは localhost からのみアクセス可能です。",
|
||||
"mcp_endpoint_title": "エンドポイント URL",
|
||||
"mcp_endpoint_description": "この URL を AI アシスタントの MCP 設定に追加してください",
|
||||
"tools": {
|
||||
"search_notes": "ノートを検索",
|
||||
"get_note": "ノートを取得",
|
||||
"get_note_content": "ノートの内容を取得",
|
||||
"update_note_content": "ノートの内容を更新",
|
||||
"append_to_note": "ノートに追記",
|
||||
"create_note": "ノートを作成",
|
||||
"get_attributes": "複数の属性を取得",
|
||||
"get_attribute": "属性を取得",
|
||||
"set_attribute": "属性を設定",
|
||||
"delete_attribute": "属性を削除",
|
||||
"get_child_notes": "子ノートを取得",
|
||||
"get_subtree": "サブツリーを取得",
|
||||
"load_skill": "スキルを読み込む",
|
||||
"web_search": "Web 検索",
|
||||
"note_in_parent": "<Note/> を <Parent/>",
|
||||
"get_attachment": "添付ファイルを取得",
|
||||
"get_attachment_content": "添付ファイルの内容を読み取る"
|
||||
}
|
||||
},
|
||||
"ocr": {
|
||||
"extracted_text": "抽出されたテキスト(OCR)",
|
||||
"extracted_text_title": "抽出されたテキスト(OCR)",
|
||||
"loading_text": "OCR テキストを読み込んでいます…",
|
||||
"no_text_available": "OCR テキストが見つかりません",
|
||||
"no_text_explanation": "このノートは OCR テキスト抽出処理が行われなかったか、テキストが見つかりませんでした。",
|
||||
"failed_to_load": "OCR テキストの読み込みに失敗しました",
|
||||
"process_now": "OCR 処理",
|
||||
"processing": "処理中…",
|
||||
"processing_started": "OCR 処理が開始されました。しばらくお待ちいただき、ページを更新してください。",
|
||||
"processing_failed": "OCR 処理の開始に失敗しました",
|
||||
"view_extracted_text": "抽出されたテキスト(OCR)を表示"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
"row-insert-child": "Создать дочернюю заметку",
|
||||
"row-insert-below": "Добавить строку ниже",
|
||||
"row-insert-above": "Добавить строку выше",
|
||||
"new-column-relation": "Связь"
|
||||
"new-column-relation": "Отношение"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Добавить метку",
|
||||
@@ -465,13 +465,13 @@
|
||||
"related_notes_title": "Другие заметки с этой меткой",
|
||||
"label": "Метка",
|
||||
"label_definition": "Определение метки",
|
||||
"relation": "Отношение",
|
||||
"relation": "Детали отношения",
|
||||
"relation_definition": "Определение отношения",
|
||||
"disable_versioning": "отключает автоматическое версионирование. Полезно, например, для больших, но неважных заметок, например, для больших JS-библиотек, используемых для написания скриптов",
|
||||
"calendar_root": "отмечает заметку, которая должна использоваться в качестве корневой для заметок дня. Только одна должна быть отмечена как таковая.",
|
||||
"archived": "заметки с этой меткой не будут отображаться в результатах поиска по умолчанию (а также в диалоговых окнах «Перейти к», «Добавить ссылку» и т. д.).",
|
||||
"exclude_from_export": "заметки (с их поддеревьями) не будут включены ни в один экспорт заметок",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li></ul>",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:\n<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li>\n</ul>",
|
||||
"run_on_instance": "Определить, на каком экземпляре Trilium это должно выполняться. По умолчанию — для всех экземпляров.",
|
||||
"run_at_hour": "В какой час это должно выполняться? Следует использовать вместе с <code>#run=hourly</code>. Можно задать несколько раз для большего количества запусков в течение дня.",
|
||||
"disable_inclusion": "скрипты с этой меткой не будут включены в выполнение родительского скрипта.",
|
||||
@@ -495,7 +495,7 @@
|
||||
"is_owned_by_note": "принадлежит заметке",
|
||||
"and_more": "... и ещё {{count}}.",
|
||||
"app_theme": "отмечает заметки CSS, которые являются полноценными темами Trilium и, таким образом, доступны в опциях Trilium.",
|
||||
"title_template": "Заголовок по умолчанию для заметок, создаваемых как дочерние элементы данной заметки. Значение вычисляется как строка JavaScript\n и, таким образом, может быть дополнено динамическим контентом с помощью внедренных переменных <code>now</code> и <code>parentNote</code>. Примеры:\n \n <ul>\n <li><code>Литературные произведения ${parentNote.getLabelValue('authorName')}</code></li>\n <li><code>Лог для ${now.format('YYYY-MM-DD HH:mm:ss')}</code></li>\n </ul>\n \n Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/default-note-title.html\">вики</a>, документации API для <a href=\"https://zadam.github.io/trilium/backend_api/Note.html\">parentNote</a> и <a href=\"https://day.js.org/docs/en/display/format\">now</a>.",
|
||||
"title_template": "заголовок по умолчанию для заметок, создаваемых как дочерние элементы текущей. Значение вычисляется как строка JavaScript \n и может быть дополнено динамическим контентом с помощью внедренных переменных <code>now</code> и <code>parentNote</code>. Например:\n \n <ul>\n <li><code>Литературные произведения ${parentNote.getLabelValue('authorName')}</code></li>\n <li><code>Лог для ${now.format('YYYY-MM-DD HH:mm:ss')}</code></li>\n </ul>\n \n Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/default-note-title.html\">вики</a>, документации API для <a href=\"https://zadam.github.io/trilium/backend_api/Note.html\">parentNote</a> и <a href=\"https://day.js.org/docs/en/display/format\">now</a>.",
|
||||
"icon_class": "значение этой метки добавляется в виде CSS-класса к значку в дереве, что помогает визуально различать заметки в дереве. Примером может служить bx bx-home — значки берутся из boxicons. Может использоваться в шаблонах заметок.",
|
||||
"share_favicon": "Заметка о фавиконе должна быть размещена на странице общего доступа. Обычно её назначают корневой папке общего доступа и делают наследуемой. Заметка о фавиконе также должна находиться в поддереве общего доступа. Рассмотрите возможность использования атрибута 'share_hidden_from_tree'.",
|
||||
"inbox": "расположение папки «Входящие» по умолчанию для новых заметок — при создании заметки с помощью кнопки «Новая заметка» на боковой панели заметки будут созданы как дочерние заметки в заметке, помеченной меткой <code>#inbox</code>.",
|
||||
@@ -548,7 +548,8 @@
|
||||
"render_note": "заметки типа «Рендер HTML» будут отображаться с использованием кодовой заметки (HTML или скрипта), и необходимо указать с помощью этой связи, какую заметку следует отобразить",
|
||||
"widget_relation": "заметка, на которую ссылается отношение будет выполнена и отображена как виджет на боковой панели",
|
||||
"share_js": "JavaScript-заметка, которая будет добавлена на страницу общего доступа. JavaScript-заметка также должна находиться в общем поддереве. Рекомендуется использовать 'share_hidden_from_tree'.",
|
||||
"other_notes_with_name": "Другие заметки с {{attributeType}} названием \"{{attributeName}}\""
|
||||
"other_notes_with_name": "Другие заметки с {{attributeType}} названием \"{{attributeName}}\"",
|
||||
"textarea": "Многострочный текст"
|
||||
},
|
||||
"command_palette": {
|
||||
"configure_launch_bar_description": "Откройте конфигурацию панели запуска, чтобы добавить или удалить элементы.",
|
||||
@@ -835,7 +836,8 @@
|
||||
"task-list": "Список задач",
|
||||
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?",
|
||||
"ai-chat": "Чат с ИИ",
|
||||
"spreadsheet": "Электронная таблица"
|
||||
"spreadsheet": "Электронная таблица",
|
||||
"llm-chat": "Чат с ИИ"
|
||||
},
|
||||
"tree-context-menu": {
|
||||
"open-in-popup": "Быстрое редактирование",
|
||||
@@ -1015,7 +1017,7 @@
|
||||
"open_sql_console_history": "Открыть историю консоли SQL",
|
||||
"show_shared_notes_subtree": "Поддерево общедоступных заметок",
|
||||
"switch_to_mobile_version": "Перейти на мобильную версию",
|
||||
"switch_to_desktop_version": "Переключиться на версию для ПК",
|
||||
"switch_to_desktop_version": "Переключиться на версию для компьютера",
|
||||
"new-version-available": "Доступно обновление",
|
||||
"download-update": "Обновить до {{latestVersion}}",
|
||||
"search_notes": "Поиск заметок"
|
||||
@@ -1637,11 +1639,11 @@
|
||||
"start_dragging_relations": "Начните перетягивать отношения отсюда на другую заметку."
|
||||
},
|
||||
"vacuum_database": {
|
||||
"title": "Сжатие базы данных",
|
||||
"description": "Это приведет к перестройке базы данных, что, как правило, приводит к уменьшению размера файла базы данных. Данные затронуты не будут.",
|
||||
"button_text": "Сжать базу данных",
|
||||
"vacuuming_database": "Сжатие БД...",
|
||||
"database_vacuumed": "База данных была сжата"
|
||||
"title": "Уменьшение размера файла базы данных",
|
||||
"description": "Это приведет к перестройке базы данных, что, скорее всего, уменьшит размер её файла. Данные не будут изменены.",
|
||||
"button_text": "Уменьшить размер файла базы данных",
|
||||
"vacuuming_database": "Уменьшение размера файла базы данных...",
|
||||
"database_vacuumed": "База данных была перестроена"
|
||||
},
|
||||
"vim_key_bindings": {
|
||||
"use_vim_keybindings_in_code_notes": "Сочетания клавиш Vim",
|
||||
@@ -1763,8 +1765,8 @@
|
||||
"database_integrity_check": {
|
||||
"title": "Проверка целостности базы данных",
|
||||
"description": "Это позволит проверить базу данных на предмет повреждений на уровне SQLite. Это может занять некоторое время в зависимости от размера базы данных.",
|
||||
"check_button": "Проверить целостность БД",
|
||||
"checking_integrity": "Проверка целостности БД...",
|
||||
"check_button": "Проверить целостность базы данных",
|
||||
"checking_integrity": "Проверка целостности базы данных...",
|
||||
"integrity_check_succeeded": "Проверка целостности прошла успешно - проблем не обнаружено.",
|
||||
"integrity_check_failed": "Проверка целостности завершена с ошибками: {{results}}"
|
||||
},
|
||||
@@ -2115,7 +2117,9 @@
|
||||
"new_layout_description": "Попробуйте новый современный и удобный дизайн. В будущих обновлениях возможны его существенные изменения.",
|
||||
"new_layout_name": "Новый дизайн",
|
||||
"title": "Экспериментальные параметры",
|
||||
"disclaimer": "Эти параметры экспериментальные и могут повлиять на стабильность. Используйте с осторожностью."
|
||||
"disclaimer": "Эти параметры экспериментальные и могут повлиять на стабильность. Используйте с осторожностью.",
|
||||
"llm_name": "ИИ / LLM чат",
|
||||
"llm_description": "Включить боковую панель чата с ИИ и заметки, созданные на основе больших языковых моделей (LLM)."
|
||||
},
|
||||
"popup-editor": {
|
||||
"maximize": "Переключить на полный редактор"
|
||||
@@ -2197,5 +2201,123 @@
|
||||
},
|
||||
"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": "Сбросить заполнение путём масштабирования"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "Введите сообщение...",
|
||||
"send": "Отправить",
|
||||
"sending": "Отправка...",
|
||||
"empty_state": "Начните общение, написав сообщение в поле ниже.",
|
||||
"searching_web": "Поиск в сети...",
|
||||
"web_search": "Поиск в сети",
|
||||
"note_tools": "Доступ к заметке",
|
||||
"sources": "Источники",
|
||||
"extended_thinking": "Расширенное мышление",
|
||||
"legacy_models": "Устаревшие модели",
|
||||
"thinking": "Обработка...",
|
||||
"thought_process": "Процесс обработки",
|
||||
"tool_calls": "{{count}} вызов(а/ов) инструмента",
|
||||
"input": "Ввод",
|
||||
"result": "Результат",
|
||||
"error": "Ошибка",
|
||||
"tool_error": "ошибка",
|
||||
"total_tokens": "{{total}} токен(а/ов)",
|
||||
"tokens": "токены",
|
||||
"context_used": "{{percentage}}% использовано",
|
||||
"note_context_enabled": "Нажмите, чтобы отключить контекст заметки: {{title}}",
|
||||
"note_context_disabled": "Нажмите, чтобы включить текущую заметку в контекст",
|
||||
"no_provider_message": "Не выбран провайдер ИИ. Добавьте его для начала общения.",
|
||||
"add_provider": "Добавить провайдера ИИ",
|
||||
"tokens_detail": "{{prompt}} (промт) + {{completion}} (ответ)",
|
||||
"tokens_used": "{{prompt}} (промт) + {{completion}} (ответ) = {{total}} токен(а/ов)",
|
||||
"tokens_used_with_cost": "{{prompt}} (промт) + {{completion}} (ответ) = {{total}} токен(а/ов) (~${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} (промт) + {{completion}} (ответ) = {{total}} токен(а/ов)",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} (промт) + {{completion}} (ответ) = {{total}} токен(а/ов) (~${{cost}})"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "Чат с ИИ",
|
||||
"launcher_title": "Чат с Open AI",
|
||||
"new_chat": "Начать новый чат",
|
||||
"save_chat": "Сохранить чат в заметках",
|
||||
"empty_state": "Начать общение",
|
||||
"history": "История чата",
|
||||
"recent_chats": "Недавние чаты",
|
||||
"no_chats": "Нет предыдущих чатов"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "Введите содержимое вашей Mermaid диаграммы или используйте один из примеров ниже.",
|
||||
"sample_diagrams": "Примеры диаграм:",
|
||||
"sample_flowchart": "Блок-схема",
|
||||
"sample_class": "Диаграмма классов",
|
||||
"sample_sequence": "Диаграмма последовательностей",
|
||||
"sample_entity_relationship": "Диаграмма \"Сущность — связь\"",
|
||||
"sample_state": "Диаграмма состояний",
|
||||
"sample_mindmap": "Ментальная карта",
|
||||
"sample_architecture": "Архитектурная схема",
|
||||
"sample_block": "Структурная схема",
|
||||
"sample_gantt": "Диаграмма Ганта",
|
||||
"sample_git": "Git",
|
||||
"sample_kanban": "Канбан",
|
||||
"sample_ishikawa": "Диаграмма Исикавы",
|
||||
"sample_c4": "C4",
|
||||
"sample_packet": "Диаграмма сетевых пакетов",
|
||||
"sample_pie": "Круговая диаграмма",
|
||||
"sample_quadrant": "Квадрантная диаграмма",
|
||||
"sample_radar": "Радиолокационная схема",
|
||||
"sample_requirement": "Диаграмма зависимостей",
|
||||
"sample_sankey": "Диаграмма Сэнки",
|
||||
"sample_timeline": "Временная диаграмма",
|
||||
"sample_treemap": "Древовидная диаграмма",
|
||||
"sample_user_journey": "Карта пользовательского пути",
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Диаграмма Венна"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "Добавить дочерний элемент",
|
||||
"addParent": "Добавить родительский элемент",
|
||||
"addSibling": "Добавить элемент на том же уровне",
|
||||
"removeNode": "Удалить узел",
|
||||
"focus": "Режим фокусировки",
|
||||
"cancelFocus": "Отключить режим фокусировки",
|
||||
"moveUp": "Передвинуть выше",
|
||||
"moveDown": "Передвинуть ниже",
|
||||
"link": "Связь",
|
||||
"linkBidirectional": "Двусторонняя связь",
|
||||
"clickTips": "Пожалуйста, нажмите на целевой узел",
|
||||
"summary": "Сводка"
|
||||
},
|
||||
"llm": {
|
||||
"settings_title": "ИИ / LLM",
|
||||
"settings_description": "Настроить интеграции ИИ и больших языковых моделей.",
|
||||
"add_provider": "Добавить провайдера",
|
||||
"add_provider_title": "Добавить провайдера ИИ",
|
||||
"configured_providers": "Настроенные провайдеры",
|
||||
"no_providers_configured": "Ещё нет настроенных провайдеров.",
|
||||
"provider_name": "Название",
|
||||
"provider_type": "Провайдер",
|
||||
"actions": "Действия",
|
||||
"delete_provider": "Удалить",
|
||||
"delete_provider_confirmation": "Вы уверены, что желаете удалить провайдера \"{{name}}\"?",
|
||||
"api_key": "Ключ API",
|
||||
"api_key_placeholder": "Введите ваш ключ API",
|
||||
"cancel": "Отмена"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +336,8 @@ export async function getExtendedWidgetType(note: FNote | null | undefined, note
|
||||
|
||||
if (noteContext?.viewScope?.viewMode === "source") {
|
||||
resultingType = "readOnlyCode";
|
||||
} else if (noteContext.viewScope?.viewMode === "ocr") {
|
||||
resultingType = "readOnlyOCRText";
|
||||
} else if (noteContext.viewScope?.viewMode === "attachments") {
|
||||
resultingType = noteContext.viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
|
||||
} else if (noteContext.viewScope?.viewMode === "note-map") {
|
||||
|
||||
@@ -28,9 +28,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast.no-title {
|
||||
.toast.no-title .toast-main-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast.no-title .toast-icon {
|
||||
@@ -40,22 +41,26 @@
|
||||
}
|
||||
|
||||
.toast.no-title .toast-body {
|
||||
padding-inline-start: 0;
|
||||
padding-inline-end: 0;
|
||||
flex: 1;
|
||||
padding-block: var(--bs-toast-padding-y);
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.toast.no-title .toast-header {
|
||||
background-color: unset !important;
|
||||
.toast.no-title .toast-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x);
|
||||
}
|
||||
|
||||
.toast {
|
||||
.toast-buttons {
|
||||
padding: 0 1em 1em 1em;
|
||||
padding: 0 var(--bs-toast-padding-x) var(--bs-toast-padding-y) var(--bs-toast-padding-x);
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
color: var(--bs-toast-color);
|
||||
background: var(--modal-control-button-background);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useEffect } from "preact/hooks";
|
||||
|
||||
import { removeToastFromStore, ToastOptionsWithRequiredId, toasts } from "../services/toast";
|
||||
import Icon from "./react/Icon";
|
||||
import { RawHtmlBlock } from "./react/RawHtml";
|
||||
import Button from "./react/Button";
|
||||
|
||||
export default function ToastContainer() {
|
||||
@@ -43,21 +42,24 @@ function Toast({ id, title, timeout, progress, message, icon, buttons }: ToastOp
|
||||
id={`toast-${id}`}
|
||||
>
|
||||
{title ? (
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">
|
||||
{toastIcon}
|
||||
<span class="toast-title">{title}</span>
|
||||
</strong>
|
||||
{closeButton}
|
||||
</div>
|
||||
<>
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">
|
||||
{toastIcon}
|
||||
<span class="toast-title">{title}</span>
|
||||
</strong>
|
||||
{closeButton}
|
||||
</div>
|
||||
<div className="toast-body">{message}</div>
|
||||
</>
|
||||
) : (
|
||||
<div class="toast-icon">{toastIcon}</div>
|
||||
<div class="toast-main-row">
|
||||
<div class="toast-icon">{toastIcon}</div>
|
||||
<div className="toast-body">{message}</div>
|
||||
<div class="toast-close">{closeButton}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RawHtmlBlock className="toast-body" html={message} />
|
||||
|
||||
{!title && <div class="toast-header">{closeButton}</div>}
|
||||
|
||||
{buttons && (
|
||||
<div class="toast-buttons">
|
||||
{buttons.map(({ text, onClick }) => (
|
||||
|
||||
@@ -25,6 +25,7 @@ interface NoteListProps {
|
||||
viewType: ViewTypeOptions | undefined;
|
||||
onReady?: (data: PrintReport) => void;
|
||||
onProgressChanged?(progress: number): void;
|
||||
showTextRepresentation?: boolean;
|
||||
}
|
||||
|
||||
type LazyLoadedComponent = ((props: ViewModeProps<any>) => VNode<any> | undefined);
|
||||
@@ -67,7 +68,7 @@ export default function NoteList(props: Pick<NoteListProps, "displayOnlyCollecti
|
||||
|
||||
export function SearchNoteList(props: Omit<NoteListProps, "isEnabled" | "viewType">) {
|
||||
const viewType = useNoteViewType(props.note);
|
||||
return <CustomNoteList {...props} isEnabled={true} viewType={viewType} />;
|
||||
return <CustomNoteList {...props} isEnabled={true} viewType={viewType} showTextRepresentation />;
|
||||
}
|
||||
|
||||
export function CustomNoteList({ note, viewType, isEnabled: shouldEnable, notePath, highlightedTokens, displayOnlyCollections, ntxId, onReady, onProgressChanged, ...restProps }: NoteListProps) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { it, describe, expect } from "vitest";
|
||||
import { buildNote } from "../../../test/easy-froca";
|
||||
import { getBoardData } from "./data";
|
||||
import { describe, expect,it } from "vitest";
|
||||
|
||||
import FBranch from "../../../entities/fbranch";
|
||||
import froca from "../../../services/froca";
|
||||
import { buildNote } from "../../../test/easy-froca";
|
||||
import { getBoardData } from "./data";
|
||||
|
||||
describe("Board data", () => {
|
||||
it("deduplicates cloned notes", async () => {
|
||||
|
||||
@@ -21,4 +21,5 @@ export interface ViewModeProps<T extends object> {
|
||||
media: ViewModeMedia;
|
||||
onReady(data: PrintReport): void;
|
||||
onProgressChanged?: ProgressChangedFn;
|
||||
showTextRepresentation?: boolean;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ComponentChildren, TargetedMouseEvent } from "preact";
|
||||
|
||||
const contentSizeObserver = new ResizeObserver(onContentResized);
|
||||
|
||||
export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) {
|
||||
export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens, showTextRepresentation }: ViewModeProps<{}>) {
|
||||
const expandDepth = useExpansionDepth(note);
|
||||
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
|
||||
const { pageNotes, ...pagination } = usePagination(note, noteIds);
|
||||
@@ -37,13 +37,14 @@ export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
|
||||
key={childNote.noteId}
|
||||
note={childNote} parentNote={note}
|
||||
expandDepth={expandDepth} highlightedTokens={highlightedTokens}
|
||||
currentLevel={1} includeArchived={includeArchived} />
|
||||
currentLevel={1} includeArchived={includeArchived}
|
||||
showTextRepresentation={showTextRepresentation} />
|
||||
))}
|
||||
</Card>
|
||||
</NoteList>;
|
||||
}
|
||||
|
||||
export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) {
|
||||
export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens, showTextRepresentation }: ViewModeProps<{}>) {
|
||||
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
|
||||
const { pageNotes, ...pagination } = usePagination(note, noteIds);
|
||||
const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived");
|
||||
@@ -56,7 +57,8 @@ export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
|
||||
note={childNote}
|
||||
parentNote={note}
|
||||
highlightedTokens={highlightedTokens}
|
||||
includeArchived={includeArchived} />
|
||||
includeArchived={includeArchived}
|
||||
showTextRepresentation={showTextRepresentation} />
|
||||
))}
|
||||
</div>
|
||||
</NoteList>
|
||||
@@ -91,13 +93,14 @@ function NoteList(props: NoteListProps) {
|
||||
</div>
|
||||
}
|
||||
|
||||
function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expandDepth, includeArchived }: {
|
||||
function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expandDepth, includeArchived, showTextRepresentation }: {
|
||||
note: FNote,
|
||||
parentNote: FNote,
|
||||
currentLevel: number,
|
||||
expandDepth: number,
|
||||
highlightedTokens: string[] | null | undefined;
|
||||
includeArchived: boolean;
|
||||
showTextRepresentation?: boolean;
|
||||
}) {
|
||||
|
||||
const [ isExpanded, setExpanded ] = useState(currentLevel <= expandDepth);
|
||||
@@ -113,7 +116,8 @@ function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expan
|
||||
<NoteContent note={note}
|
||||
highlightedTokens={highlightedTokens}
|
||||
noChildrenList
|
||||
includeArchivedNotes={includeArchived} />
|
||||
includeArchivedNotes={includeArchived}
|
||||
showTextRepresentation={showTextRepresentation} />
|
||||
</CardSection>
|
||||
|
||||
<NoteChildren note={note}
|
||||
@@ -157,6 +161,7 @@ interface GridNoteCardProps {
|
||||
parentNote: FNote;
|
||||
highlightedTokens: string[] | null | undefined;
|
||||
includeArchived: boolean;
|
||||
showTextRepresentation?: boolean;
|
||||
}
|
||||
|
||||
function GridNoteCard(props: GridNoteCardProps) {
|
||||
@@ -185,6 +190,7 @@ function GridNoteCard(props: GridNoteCardProps) {
|
||||
trim
|
||||
highlightedTokens={props.highlightedTokens}
|
||||
includeArchivedNotes={props.includeArchived}
|
||||
showTextRepresentation={props.showTextRepresentation}
|
||||
/>
|
||||
</CardFrame>
|
||||
);
|
||||
@@ -201,12 +207,13 @@ function NoteAttributes({ note }: { note: FNote }) {
|
||||
return <span className="note-list-attributes" ref={ref} />;
|
||||
}
|
||||
|
||||
export function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes }: {
|
||||
export function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes, showTextRepresentation }: {
|
||||
note: FNote;
|
||||
trim?: boolean;
|
||||
noChildrenList?: boolean;
|
||||
highlightedTokens: string[] | null | undefined;
|
||||
includeArchivedNotes: boolean;
|
||||
showTextRepresentation?: boolean;
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
|
||||
@@ -230,7 +237,8 @@ export function NoteContent({ note, trim, noChildrenList, highlightedTokens, inc
|
||||
trim,
|
||||
noChildrenList,
|
||||
noIncludedNotes: true,
|
||||
includeArchivedNotes
|
||||
includeArchivedNotes,
|
||||
showTextRepresentation
|
||||
})
|
||||
.then(({ $renderedContent, type }) => {
|
||||
if (!contentRef.current) return;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,6 +27,7 @@ const VIEW_MODE_ICON_MAPPINGS: Record<Exclude<ViewMode, "default">, string> = {
|
||||
"contextual-help": "bx bx-help-circle",
|
||||
"note-map": "bx bxs-network-chart",
|
||||
attachments: "bx bx-paperclip",
|
||||
ocr: "bx bx-text"
|
||||
};
|
||||
|
||||
export default function TabSwitcher() {
|
||||
|
||||
@@ -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" | "readOnlyOCRText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
|
||||
|
||||
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined);
|
||||
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
|
||||
@@ -78,6 +78,11 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
|
||||
className: "note-detail-readonly-code",
|
||||
printable: true
|
||||
},
|
||||
readOnlyOCRText: {
|
||||
view: () => import("./type_widgets/ReadOnlyTextRepresentation"),
|
||||
className: "note-detail-ocr-text",
|
||||
printable: true
|
||||
},
|
||||
editableCode: {
|
||||
view: async () => (await import("./type_widgets/code/Code")).EditableCode,
|
||||
className: "note-detail-code",
|
||||
@@ -147,5 +152,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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ interface SliderProps {
|
||||
onChange(newValue: number);
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
@@ -162,6 +162,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
|
||||
<CommandItem command="openNoteExternally" icon="bx bx-file-find" disabled={isSearchOrBook || !isElectron} text={t("note_actions.open_note_externally")} title={t("note_actions.open_note_externally_title")} />
|
||||
<CommandItem command="openNoteCustom" icon="bx bx-customize" disabled={isSearchOrBook || isMac || !isElectron} text={t("note_actions.open_note_custom")} />
|
||||
<CommandItem command="showNoteSource" icon="bx bx-code" disabled={!hasSource} text={t("note_actions.note_source")} />
|
||||
<CommandItem command="showNoteOCRText" icon="bx bx-text" disabled={!["image", "file"].includes(noteType)} text={t("note_actions.view_ocr_text")} />
|
||||
{(syncServerHost && isElectron) &&
|
||||
<CommandItem command="openNoteOnServer" icon="bx bx-world" disabled={!syncServerHost} text={t("note_actions.open_note_on_server")} />
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
329
apps/client/src/widgets/sidebar/SidebarChat.tsx
Normal file
329
apps/client/src/widgets/sidebar/SidebarChat.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
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.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}
|
||||
rows={2}
|
||||
activeNoteId={activeNoteId ?? undefined}
|
||||
activeNoteTitle={activeNote?.title}
|
||||
onSubmit={handleSubmit}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</RightPanelWidget>
|
||||
);
|
||||
}
|
||||
@@ -27,8 +27,10 @@ import { FormDropdownDivider, FormListItem } from "../react/FormList";
|
||||
import HelpButton from "../react/HelpButton";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import Modal from "../react/Modal";
|
||||
import NoteLink from "../react/NoteLink";
|
||||
import { ParentComponent, refToJQuerySelector } from "../react/react_utils";
|
||||
import { TextRepresentation } from "./ReadOnlyTextRepresentation";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
|
||||
/**
|
||||
@@ -141,6 +143,8 @@ export function AttachmentDetail({ note, viewScope }: TypeWidgetProps) {
|
||||
|
||||
function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment, isFullDetail?: boolean }) {
|
||||
const contentWrapper = useRef<HTMLDivElement>(null);
|
||||
const [ ocrModalShown, setOcrModalShown ] = useState(false);
|
||||
const supportsOcr = attachment.role === "image" || attachment.role === "file";
|
||||
|
||||
function refresh() {
|
||||
content_renderer.getRenderedContent(attachment, { imageHasZoom: isFullDetail })
|
||||
@@ -181,7 +185,11 @@ function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment,
|
||||
<div className="attachment-detail-widget">
|
||||
<div className={`attachment-detail-wrapper ${isFullDetail ? "full-detail" : "list-view"} ${attachment.utcDateScheduledForErasureSince ? "scheduled-for-deletion" : ""}`}>
|
||||
<div className="attachment-title-line">
|
||||
<AttachmentActions attachment={attachment} copyAttachmentLinkToClipboard={copyAttachmentLinkToClipboard} />
|
||||
<AttachmentActions
|
||||
attachment={attachment}
|
||||
copyAttachmentLinkToClipboard={copyAttachmentLinkToClipboard}
|
||||
onShowOcr={supportsOcr ? () => setOcrModalShown(true) : undefined}
|
||||
/>
|
||||
<h4 className="attachment-title">
|
||||
{!isFullDetail ? (
|
||||
<NoteLink
|
||||
@@ -207,6 +215,22 @@ function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment,
|
||||
{attachment.utcDateScheduledForErasureSince && <DeletionAlert utcDateScheduledForErasureSince={attachment.utcDateScheduledForErasureSince} />}
|
||||
<div ref={contentWrapper} className="attachment-content-wrapper" />
|
||||
</div>
|
||||
|
||||
{supportsOcr && (
|
||||
<Modal
|
||||
className="ocr-text-modal"
|
||||
title={t("ocr.extracted_text_title")}
|
||||
show={ocrModalShown}
|
||||
onHidden={() => setOcrModalShown(false)}
|
||||
size="lg"
|
||||
scrollable
|
||||
>
|
||||
<TextRepresentation
|
||||
textUrl={`ocr/attachments/${attachment.attachmentId}/text`}
|
||||
processUrl={`ocr/process-attachment/${attachment.attachmentId}`}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -228,7 +252,7 @@ function DeletionAlert({ utcDateScheduledForErasureSince }: { utcDateScheduledFo
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentActions({ attachment, copyAttachmentLinkToClipboard }: { attachment: FAttachment, copyAttachmentLinkToClipboard: () => void }) {
|
||||
function AttachmentActions({ attachment, copyAttachmentLinkToClipboard, onShowOcr }: { attachment: FAttachment, copyAttachmentLinkToClipboard: () => void, onShowOcr?: () => void }) {
|
||||
const isElectron = utils.isElectron();
|
||||
const fileUploadRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -262,6 +286,12 @@ function AttachmentActions({ attachment, copyAttachmentLinkToClipboard }: { atta
|
||||
icon="bx bx-link"
|
||||
onClick={copyAttachmentLinkToClipboard}
|
||||
>{t("attachments_actions.copy_link_to_clipboard")}</FormListItem>
|
||||
{onShowOcr && (
|
||||
<FormListItem
|
||||
icon="bx bx-text"
|
||||
onClick={onShowOcr}
|
||||
>{t("ocr.view_extracted_text")}</FormListItem>
|
||||
)}
|
||||
<FormDropdownDivider />
|
||||
|
||||
<FormListItem
|
||||
|
||||
@@ -4,7 +4,7 @@ import AppearanceSettings from "./options/appearance";
|
||||
import ShortcutSettings from "./options/shortcuts";
|
||||
import TextNoteSettings from "./options/text_notes";
|
||||
import CodeNoteSettings from "./options/code_notes";
|
||||
import ImageSettings from "./options/images";
|
||||
import MediaSettings from "./options/media";
|
||||
import SpellcheckSettings from "./options/spellcheck";
|
||||
import PasswordSettings from "./options/password";
|
||||
import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication";
|
||||
@@ -14,18 +14,19 @@ 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" | "_optionsMedia" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced" | "_optionsLlm";
|
||||
|
||||
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetProps) => JSX.Element> = {
|
||||
_optionsAppearance: AppearanceSettings,
|
||||
_optionsShortcuts: ShortcutSettings,
|
||||
_optionsTextNotes: TextNoteSettings,
|
||||
_optionsCodeNotes: CodeNoteSettings,
|
||||
_optionsImages: ImageSettings,
|
||||
_optionsMedia: MediaSettings,
|
||||
_optionsSpellcheck: SpellcheckSettings,
|
||||
_optionsPassword: PasswordSettings,
|
||||
_optionsMFA: MultiFactorAuthenticationSettings,
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
.text-representation {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.text-representation-header {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-representation-loading {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.text-representation-content {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
background-color: var(--accented-background-color);
|
||||
min-height: 100px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.text-representation-meta {
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
margin-top: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-representation-empty {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.text-representation-process-btn {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.text-representation-error {
|
||||
color: var(--error-color);
|
||||
background-color: var(--error-background-color);
|
||||
border: 1px solid var(--error-border-color);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import "./ReadOnlyTextRepresentation.css";
|
||||
|
||||
import type { OCRProcessResponse, TextRepresentationResponse } from "@triliumnext/commons";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import appContext from "../../components/app_context";
|
||||
import { t } from "../../services/i18n";
|
||||
import server from "../../services/server";
|
||||
import toast from "../../services/toast";
|
||||
import { randomString } from "../../services/utils";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
|
||||
type State =
|
||||
| { kind: "loading" }
|
||||
| { kind: "loaded"; text: string }
|
||||
| { kind: "empty" }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
interface TextRepresentationProps {
|
||||
/** The API path to fetch OCR text from (e.g. `ocr/notes/{id}/text`). */
|
||||
textUrl: string;
|
||||
/** The API path to trigger OCR processing (e.g. `ocr/process-note/{id}`). */
|
||||
processUrl: string;
|
||||
}
|
||||
|
||||
export default function ReadOnlyTextRepresentation({ note }: TypeWidgetProps) {
|
||||
return (
|
||||
<TextRepresentation
|
||||
textUrl={`ocr/notes/${note.noteId}/text`}
|
||||
processUrl={`ocr/process-note/${note.noteId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextRepresentation({ textUrl, processUrl }: TextRepresentationProps) {
|
||||
const [ state, setState ] = useState<State>({ kind: "loading" });
|
||||
const [ processing, setProcessing ] = useState(false);
|
||||
|
||||
async function fetchText() {
|
||||
setState({ kind: "loading" });
|
||||
|
||||
try {
|
||||
const response = await server.get<TextRepresentationResponse>(textUrl);
|
||||
|
||||
if (!response.success) {
|
||||
setState({ kind: "error", message: response.message || t("ocr.failed_to_load") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.hasOcr || !response.text) {
|
||||
setState({ kind: "empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ kind: "loaded", text: response.text });
|
||||
} catch (error: any) {
|
||||
console.error("Error loading text representation:", error);
|
||||
setState({ kind: "error", message: error.message || t("ocr.failed_to_load") });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchText(); }, [ textUrl ]);
|
||||
|
||||
async function processOCR() {
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await server.post<OCRProcessResponse>(processUrl, { forceReprocess: true });
|
||||
if (response.success) {
|
||||
const result = response.result;
|
||||
const minConfidence = response.minConfidence ?? 0;
|
||||
|
||||
// Check if text was filtered due to low confidence
|
||||
if (result && !result.text && result.confidence > 0 && minConfidence > 0) {
|
||||
const confidencePercent = Math.round(result.confidence * 100);
|
||||
const thresholdPercent = Math.round(minConfidence * 100);
|
||||
toast.showPersistent({
|
||||
id: `ocr-low-confidence-${randomString(8)}`,
|
||||
icon: "bx bx-info-circle",
|
||||
message: t("ocr.text_filtered_low_confidence", {
|
||||
confidence: confidencePercent,
|
||||
threshold: thresholdPercent
|
||||
}),
|
||||
timeout: 15000,
|
||||
buttons: [{
|
||||
text: t("ocr.open_media_settings"),
|
||||
onClick: ({ dismissToast }) => {
|
||||
appContext.tabManager.openInNewTab("_optionsMedia", null, true);
|
||||
dismissToast();
|
||||
}
|
||||
}]
|
||||
});
|
||||
} else {
|
||||
toast.showMessage(t("ocr.processing_complete"));
|
||||
}
|
||||
setTimeout(fetchText, 500);
|
||||
} else {
|
||||
toast.showError(response.message || t("ocr.processing_failed"));
|
||||
}
|
||||
} catch {
|
||||
// Server errors (4xx/5xx) are already shown as toasts by server.ts.
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-representation note-detail-printable">
|
||||
<div className="text-representation-header">
|
||||
<span className="bx bx-text" />{" "}{t("ocr.extracted_text_title")}
|
||||
</div>
|
||||
|
||||
{state.kind === "loading" && (
|
||||
<div className="text-representation-loading">
|
||||
<span className="bx bx-loader-alt bx-spin" />{" "}{t("ocr.loading_text")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.kind === "loaded" && (
|
||||
<>
|
||||
<div className="text-representation-content">
|
||||
{state.text}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === "empty" && (
|
||||
<>
|
||||
<div className="text-representation-empty">
|
||||
<span className="bx bx-info-circle" />{" "}{t("ocr.no_text_available")}
|
||||
</div>
|
||||
<div className="text-representation-meta">
|
||||
{t("ocr.no_text_explanation")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === "error" && (
|
||||
<div className="text-representation-error">
|
||||
<span className="bx bx-error" />{" "}{state.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.kind !== "loading" && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary text-representation-process-btn"
|
||||
disabled={processing}
|
||||
onClick={processOCR}
|
||||
>
|
||||
{processing
|
||||
? <><span className="bx bx-loader-alt bx-spin" />{" "}{t("ocr.processing")}</>
|
||||
: <><span className="bx bx-play" />{" "}{t("ocr.process_now")}</>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,6 @@ body.desktop .note-detail-split .note-detail-code-editor {
|
||||
.note-detail-split.svg-editor .render-container svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
169
apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.css
Normal file
169
apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.css
Normal file
@@ -0,0 +1,169 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
240
apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx
Normal file
240
apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import "./ChatInputBar.css";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
320
apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.css
Normal file
320
apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.css
Normal file
@@ -0,0 +1,320 @@
|
||||
/* Message wrapper and bubble */
|
||||
.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;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.llm-chat-message-wrapper-assistant {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
.expandable-card.llm-chat-citations-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Citations table (inside an expandable card) */
|
||||
.llm-chat-citations-list {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list td {
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list tr + tr td {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-citation-title {
|
||||
max-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.llm-chat-citation-title a {
|
||||
color: var(--link-color, #007bff);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.llm-chat-citation-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.llm-chat-citation-site {
|
||||
white-space: nowrap;
|
||||
color: var(--muted-text-color);
|
||||
font-size: 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.llm-chat-thinking-card.expandable-card {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-style: dashed;
|
||||
margin-right: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.llm-chat-thinking-card .expandable-section-summary {
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-thinking-content {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted-text-color);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
261
apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx
Normal file
261
apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import "./ChatMessage.css";
|
||||
|
||||
import DOMPurify from "dompurify";
|
||||
import { Marked } from "marked";
|
||||
import { useEffect, useMemo, useRef } from "preact/hooks";
|
||||
|
||||
import { type LlmCitation, createWikiLinkExtension } from "@triliumnext/commons";
|
||||
|
||||
import link from "../../../services/link.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import utils from "../../../services/utils.js";
|
||||
import { ExpandableCard, ExpandableSection } from "./ExpandableCard.js";
|
||||
import { type ContentBlock, getMessageText, type StoredMessage, type TextBlock, type ToolCallBlock } from "./llm_chat_types.js";
|
||||
import ToolCallCard from "./ToolCallCard.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 with client-side URL format
|
||||
const markedInstance = new Marked({
|
||||
breaks: true, // Convert \n to <br>
|
||||
gfm: true // GitHub Flavored Markdown
|
||||
});
|
||||
markedInstance.use({
|
||||
extensions: [createWikiLinkExtension({ formatHref: (id) => `#root/${id}` })]
|
||||
});
|
||||
|
||||
/** Parse markdown to HTML. */
|
||||
function renderMarkdown(markdown: string): string {
|
||||
return markedInstance.parse(markdown) as string;
|
||||
}
|
||||
|
||||
/** Renders markdown content with reference link title loading. */
|
||||
function MarkdownContent({ html, isStreaming }: { html: string; isStreaming?: boolean }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const referenceLinks = containerRef.current.querySelectorAll<HTMLAnchorElement>("a.reference-link");
|
||||
for (const el of referenceLinks) {
|
||||
link.loadReferenceLinkTitle($(el), el.href);
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="llm-chat-markdown"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
|
||||
/>
|
||||
{isStreaming && <span className="llm-chat-cursor" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: StoredMessage;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
type ContentGroup =
|
||||
| { type: "text"; block: TextBlock; index: number }
|
||||
| { type: "tool_calls"; blocks: ToolCallBlock[]; index: number };
|
||||
|
||||
/** Extract domain + TLD from a hostname (e.g. "www.example.co.uk" → "example.co.uk"). */
|
||||
function extractDomain(hostname: string): string {
|
||||
return hostname.replace(/^www\./, "");
|
||||
}
|
||||
|
||||
function getUniqueSiteCount(citations: LlmCitation[]): number {
|
||||
const domains = new Set<string>();
|
||||
for (const c of citations) {
|
||||
if (c.url) {
|
||||
try {
|
||||
domains.add(extractDomain(new URL(c.url).hostname));
|
||||
} catch { /* ignore invalid URLs */ }
|
||||
}
|
||||
}
|
||||
return domains.size;
|
||||
}
|
||||
|
||||
function CitationsSection({ citations }: { citations: LlmCitation[] }) {
|
||||
const siteCount = getUniqueSiteCount(citations);
|
||||
const summary = t("llm_chat.sources_summary", { count: citations.length, sites: siteCount });
|
||||
|
||||
return (
|
||||
<ExpandableCard className="llm-chat-citations-card">
|
||||
<ExpandableSection icon="bx bx-link" label={summary}>
|
||||
<table className="llm-chat-citations-list">
|
||||
<tbody>
|
||||
{citations.map((citation, idx) => {
|
||||
const title = citation.title || citation.citedText?.slice(0, 80) || `Source ${idx + 1}`;
|
||||
let domain: string | null = null;
|
||||
if (citation.url) {
|
||||
try {
|
||||
domain = extractDomain(new URL(citation.url).hostname);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={idx}>
|
||||
<td className="llm-chat-citation-title">
|
||||
{citation.url ? (
|
||||
<a href={citation.url} target="_blank" rel="noopener noreferrer" title={title}>
|
||||
{title}
|
||||
</a>
|
||||
) : (
|
||||
<span>{title}</span>
|
||||
)}
|
||||
</td>
|
||||
{domain && (
|
||||
<td className="llm-chat-citation-site">{domain}</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</ExpandableSection>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatMessage({ message, isStreaming }: Props) {
|
||||
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 card
|
||||
if (isThinking) {
|
||||
return (
|
||||
<div className="llm-chat-message-wrapper llm-chat-message-wrapper-assistant">
|
||||
<ExpandableCard className="llm-chat-thinking-card">
|
||||
<ExpandableSection icon="bx bx-brain" label={t("llm_chat.thought_process")}>
|
||||
<div className="llm-chat-thinking-content">
|
||||
{textContent}
|
||||
{isStreaming && <span className="llm-chat-cursor" />}
|
||||
</div>
|
||||
</ExpandableSection>
|
||||
</ExpandableCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasBlockContent = Array.isArray(message.content);
|
||||
|
||||
return (
|
||||
<div className={`llm-chat-message-wrapper llm-chat-message-wrapper-${message.role}`}>
|
||||
<div className={messageClasses}>
|
||||
{isError && <div className="llm-chat-message-role">Error</div>}
|
||||
<div className="llm-chat-message-content">
|
||||
{message.role === "assistant" && !isError ? (
|
||||
hasBlockContent ? (
|
||||
renderContentBlocks(message.content as ContentBlock[], isStreaming)
|
||||
) : (
|
||||
<MarkdownContent html={renderedContent || ""} isStreaming={isStreaming} />
|
||||
)
|
||||
) : (
|
||||
textContent
|
||||
)}
|
||||
</div>
|
||||
{message.citations && message.citations.length > 0 && (
|
||||
<CitationsSection citations={message.citations} />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Group content blocks so that consecutive tool_calls are merged into one entry. */
|
||||
function groupContentBlocks(blocks: ContentBlock[]): ContentGroup[] {
|
||||
const groups: ContentGroup[] = [];
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
if (block.type === "tool_call") {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last?.type === "tool_calls") {
|
||||
last.blocks.push(block);
|
||||
} else {
|
||||
groups.push({ type: "tool_calls", blocks: [block], index: i });
|
||||
}
|
||||
} else {
|
||||
groups.push({ type: "text", block, index: i });
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) {
|
||||
return groupContentBlocks(blocks).map((group) => {
|
||||
if (group.type === "text") {
|
||||
const html = renderMarkdown(group.block.content);
|
||||
const isLastBlock = group.index === blocks.length - 1;
|
||||
return (
|
||||
<div key={group.index}>
|
||||
<MarkdownContent html={html} isStreaming={isStreaming && isLastBlock} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ToolCallCard key={group.index} toolCalls={group.blocks.map((b) => b.toolCall)} />;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* Expandable card — bordered container for collapsible sections */
|
||||
.expandable-card {
|
||||
margin: 0.5rem 0;
|
||||
max-width: 80%;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Expandable section — collapsible details within a card */
|
||||
.expandable-section + .expandable-section {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.expandable-section-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expandable-section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.expandable-section-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.expandable-section-summary > .bx {
|
||||
font-size: 1rem;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.expandable-section-chevron {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.expandable-section[open] .expandable-section-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.expandable-section-body {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import "./ExpandableCard.css";
|
||||
|
||||
import type { ComponentChildren } from "preact";
|
||||
|
||||
interface ExpandableSectionProps {
|
||||
icon: string;
|
||||
label: ComponentChildren;
|
||||
className?: string;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
/** A collapsible section within an ExpandableCard. */
|
||||
export function ExpandableSection({ icon, label, className, children }: ExpandableSectionProps) {
|
||||
return (
|
||||
<details className={`expandable-section ${className ?? ""}`}>
|
||||
<summary className="expandable-section-summary">
|
||||
<span className={icon} />
|
||||
<span className="expandable-section-label">{label}</span>
|
||||
<span className="bx bx-chevron-down expandable-section-chevron" />
|
||||
</summary>
|
||||
<div className="expandable-section-body">
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExpandableCardProps {
|
||||
className?: string;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
/** A bordered card that groups one or more ExpandableSections. */
|
||||
export function ExpandableCard({ className, children }: ExpandableCardProps) {
|
||||
return (
|
||||
<div className={`expandable-card ${className ?? ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css
Normal file
13
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.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;
|
||||
}
|
||||
103
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx
Normal file
103
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
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.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>
|
||||
);
|
||||
}
|
||||
113
apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.css
Normal file
113
apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* Tool call specific styles (card/section structure is in ExpandableCard.css) */
|
||||
|
||||
.llm-chat-tool-call-detail {
|
||||
font-weight: 400;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-note-ref {
|
||||
font-weight: 400;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Section body (input + result) */
|
||||
.llm-chat-tool-call-input,
|
||||
.llm-chat-tool-call-result {
|
||||
padding: 0.5rem 0.75rem;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-result {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.expandable-section-body pre {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--main-background-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
}
|
||||
|
||||
.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;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Tool call key-value table */
|
||||
.llm-chat-tool-call-table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
background: var(--main-background-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table td {
|
||||
padding: 0.25rem 0;
|
||||
padding-right: 0.75rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table-key {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
width: 0;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table-value pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Nested tables */
|
||||
.llm-chat-tool-call-table-value .llm-chat-tool-call-table {
|
||||
background: none;
|
||||
width: auto;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table-array {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table-array > .llm-chat-tool-call-table {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.llm-chat-tool-call-table-array > .llm-chat-tool-call-table + .llm-chat-tool-call-table {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
/* Tool call error styling */
|
||||
.llm-chat-tool-call-error .expandable-section-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);
|
||||
}
|
||||
213
apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.tsx
Normal file
213
apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import "./ToolCallCard.css";
|
||||
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import { NewNoteLink } from "../../react/NoteLink.js";
|
||||
import { ExpandableCard, ExpandableSection } from "./ExpandableCard.js";
|
||||
import type { ToolCall } from "./llm_chat_types.js";
|
||||
|
||||
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-loader-alt bx-spin";
|
||||
|
||||
const name = toolCall.toolName;
|
||||
if (name.includes("search")) return "bx bx-search";
|
||||
if (name.includes("note")) return "bx bx-note";
|
||||
if (name.includes("attribute")) return "bx bx-purchase-tag";
|
||||
if (name.includes("attachment")) return "bx bx-paperclip";
|
||||
if (name.includes("skill")) return "bx bx-book-open";
|
||||
if (name.includes("web")) return "bx bx-globe";
|
||||
return "bx bx-wrench";
|
||||
}
|
||||
|
||||
/** Try to parse a JSON string into a structured value. */
|
||||
function tryParseJson(data: unknown): unknown {
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Check if a value is a plain object (not null, not array). */
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const MAX_TABLE_DEPTH = 2;
|
||||
|
||||
/** Render a single value — recurse for objects/arrays up to max depth. */
|
||||
function ValueCell({ value, depth }: { value: unknown; depth: number }) {
|
||||
if (value === null || value === undefined) return <pre />;
|
||||
|
||||
// Beyond max depth, fall back to JSON.
|
||||
if (depth >= MAX_TABLE_DEPTH) {
|
||||
if (isPlainObject(value) || Array.isArray(value)) {
|
||||
return <pre>{JSON.stringify(value, null, 2)}</pre>;
|
||||
}
|
||||
return <pre>{String(value)}</pre>;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return <KeyValueTable data={value} depth={depth} />;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <pre>{"[]"}</pre>;
|
||||
|
||||
// Array of objects: render each as a nested table.
|
||||
if (value.every(isPlainObject)) {
|
||||
return (
|
||||
<div className="llm-chat-tool-call-table-array">
|
||||
{value.map((item, idx) => (
|
||||
<KeyValueTable key={idx} data={item} depth={depth} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Array of primitives: comma-separated.
|
||||
return <pre>{value.map(String).join(", ")}</pre>;
|
||||
}
|
||||
|
||||
return <pre>{String(value)}</pre>;
|
||||
}
|
||||
|
||||
/** Renders a data object as a recursive two-column key-value table. */
|
||||
function KeyValueTable({ data, className, depth = 0 }: { data: unknown; className?: string; depth?: number }) {
|
||||
const obj = tryParseJson(data);
|
||||
|
||||
if (!isPlainObject(obj)) {
|
||||
const raw = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
||||
return <pre className={className}>{raw}</pre>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className={`llm-chat-tool-call-table ${className ?? ""}`}>
|
||||
<tbody>
|
||||
{Object.entries(obj).map(([key, value]) => (
|
||||
<tr key={key}>
|
||||
<td className="llm-chat-tool-call-table-key">{key}</td>
|
||||
<td className="llm-chat-tool-call-table-value">
|
||||
<ValueCell value={value} depth={depth + 1} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the label content for a tool call section. */
|
||||
function ToolCallLabel({ toolCall }: { toolCall: ToolCall }) {
|
||||
const { noteId: refNoteId, parentNoteId: refParentId, detailText } = getToolCallContext(toolCall);
|
||||
const hasError = toolCall.isError;
|
||||
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
{hasError && <span className="llm-chat-tool-call-error-badge">{t("llm_chat.tool_error")}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single tool call section within a ToolCallCard. */
|
||||
function ToolCallSection({ toolCall }: { toolCall: ToolCall }) {
|
||||
const hasError = toolCall.isError;
|
||||
|
||||
return (
|
||||
<ExpandableSection
|
||||
icon={toolCallIcon(toolCall)}
|
||||
label={<ToolCallLabel toolCall={toolCall} />}
|
||||
className={hasError ? "llm-chat-tool-call-error" : ""}
|
||||
>
|
||||
<div className="llm-chat-tool-call-input">
|
||||
<strong>{t("llm_chat.input")}</strong>
|
||||
<KeyValueTable data={toolCall.input} />
|
||||
</div>
|
||||
{toolCall.result && (
|
||||
<div className={`llm-chat-tool-call-result ${hasError ? "llm-chat-tool-call-result-error" : ""}`}>
|
||||
<strong>{hasError ? t("llm_chat.error") : t("llm_chat.result")}</strong>
|
||||
<KeyValueTable data={toolCall.result} />
|
||||
</div>
|
||||
)}
|
||||
</ExpandableSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** A card that groups one or more sequential tool calls together. */
|
||||
export default function ToolCallCard({ toolCalls }: { toolCalls: ToolCall[] }) {
|
||||
return (
|
||||
<ExpandableCard>
|
||||
{toolCalls.map((tc, idx) => (
|
||||
<ToolCallSection key={tc.id ?? idx} toolCall={tc} />
|
||||
))}
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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[] {
|
||||
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;
|
||||
/** Token usage for this response */
|
||||
usage?: LlmUsage;
|
||||
}
|
||||
|
||||
export interface LlmChatContent {
|
||||
version: 1;
|
||||
messages: StoredMessage[];
|
||||
selectedModel?: string;
|
||||
enableWebSearch?: boolean;
|
||||
enableNoteTools?: boolean;
|
||||
enableExtendedThinking?: boolean;
|
||||
}
|
||||
407
apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts
Normal file
407
apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import type { LlmCitation, LlmMessage, LlmModelInfo, LlmUsage } from "@triliumnext/commons";
|
||||
import { RefObject } from "preact";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
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;
|
||||
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 [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, 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;
|
||||
|
||||
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]);
|
||||
},
|
||||
onThinking: (text) => {
|
||||
thinkingContent += text;
|
||||
setStreamingThinking(thinkingContent);
|
||||
},
|
||||
onToolUse: (toolName, toolInput) => {
|
||||
contentBlocks.push({
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
id: randomString(),
|
||||
toolName,
|
||||
input: toolInput
|
||||
}
|
||||
});
|
||||
setStreamingBlocks([...contentBlocks]);
|
||||
},
|
||||
onToolResult: (toolName, result, isError) => {
|
||||
// Replace the matching block with a new object so Preact sees the change.
|
||||
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) {
|
||||
contentBlocks[i] = {
|
||||
type: "tool_call",
|
||||
toolCall: { ...block.toolCall, result, isError }
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
setStreamingBlocks([...contentBlocks]);
|
||||
},
|
||||
onCitation: (citation) => {
|
||||
// Deduplicate by URL
|
||||
if (!citation.url || !citations.some(c => c.url === citation.url)) {
|
||||
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);
|
||||
},
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [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,
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -505,6 +505,47 @@ ishikawa-beta
|
||||
Environment
|
||||
Subject moved too quickly
|
||||
Too dark
|
||||
`
|
||||
},
|
||||
{
|
||||
name: t("mermaid.sample_treeview"),
|
||||
content: `\
|
||||
treeView-beta
|
||||
"src"
|
||||
"components"
|
||||
"Button.tsx"
|
||||
"Modal.tsx"
|
||||
"services"
|
||||
"api.ts"
|
||||
"utils.ts"
|
||||
"index.ts"
|
||||
"package.json"
|
||||
"README.md"
|
||||
`
|
||||
},
|
||||
{
|
||||
name: t("mermaid.sample_wardley"),
|
||||
content: `\
|
||||
wardley-beta
|
||||
title Tea Shop
|
||||
|
||||
anchor Customers [0.95, 0.63]
|
||||
anchor Business [0.95, 0.27]
|
||||
|
||||
component Cup of Tea [0.79, 0.61]
|
||||
component Tea [0.63, 0.81]
|
||||
component Cup [0.57, 0.46]
|
||||
component Water [0.52, 0.89]
|
||||
component Kettle [0.47, 0.53]
|
||||
component Power [0.36, 0.72]
|
||||
|
||||
Customers -> Cup of Tea
|
||||
Business -> Cup of Tea
|
||||
Cup of Tea -> Tea
|
||||
Cup of Tea -> Cup
|
||||
Cup of Tea -> Water
|
||||
Water -> Kettle
|
||||
Kettle -> Power
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { AnonymizedDbResponse, DatabaseAnonymizeResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
||||
|
||||
import { experimentalFeatures } from "../../../services/experimental_features";
|
||||
import { experimentalFeatures, type ExperimentalFeatureId } from "../../../services/experimental_features";
|
||||
import { t } from "../../../services/i18n";
|
||||
import server from "../../../services/server";
|
||||
import toast from "../../../services/toast";
|
||||
import Button from "../../react/Button";
|
||||
import Column from "../../react/Column";
|
||||
import FormText from "../../react/FormText";
|
||||
import FormToggle from "../../react/FormToggle";
|
||||
import { useTriliumOptionJson } from "../../react/hooks";
|
||||
import CheckboxList from "./components/CheckboxList";
|
||||
import OptionsRow from "./components/OptionsRow";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
|
||||
export default function AdvancedSettings() {
|
||||
@@ -180,19 +181,39 @@ function VacuumDatabaseOptions() {
|
||||
}
|
||||
|
||||
function ExperimentalOptions() {
|
||||
const [ enabledExperimentalFeatures, setEnabledExperimentalFeatures ] = useTriliumOptionJson<string[]>("experimentalFeatures", true);
|
||||
const filteredExperimentalFeatures = useMemo(() => experimentalFeatures.filter(e => e.id !== "new-layout"), []);
|
||||
const [enabledFeatures, setEnabledFeatures] = useTriliumOptionJson<ExperimentalFeatureId[]>("experimentalFeatures", true);
|
||||
const filteredFeatures = useMemo(() => experimentalFeatures.filter(e => e.id !== "new-layout"), []);
|
||||
|
||||
return (filteredExperimentalFeatures.length > 0 &&
|
||||
const toggleFeature = useCallback((featureId: ExperimentalFeatureId, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
setEnabledFeatures([...enabledFeatures, featureId]);
|
||||
} else {
|
||||
setEnabledFeatures(enabledFeatures.filter(id => id !== featureId));
|
||||
}
|
||||
}, [enabledFeatures, setEnabledFeatures]);
|
||||
|
||||
if (filteredFeatures.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("experimental_features.title")}>
|
||||
<FormText>{t("experimental_features.disclaimer")}</FormText>
|
||||
|
||||
<CheckboxList
|
||||
values={filteredExperimentalFeatures}
|
||||
keyProperty="id"
|
||||
titleProperty="name"
|
||||
currentValue={enabledExperimentalFeatures} onChange={setEnabledExperimentalFeatures}
|
||||
/>
|
||||
{filteredFeatures.map((feature) => (
|
||||
<OptionsRow
|
||||
key={feature.id}
|
||||
name={`experimental-${feature.id}`}
|
||||
label={feature.name}
|
||||
description={feature.description}
|
||||
>
|
||||
<FormToggle
|
||||
switchOnName="" switchOffName=""
|
||||
currentValue={enabledFeatures.includes(feature.id)}
|
||||
onChange={(enabled) => toggleFeature(feature.id, enabled)}
|
||||
/>
|
||||
</OptionsRow>
|
||||
))}
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,42 @@
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5em 0;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75em 0;
|
||||
}
|
||||
|
||||
.option-row > label {
|
||||
width: 40%;
|
||||
.option-row-label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.option-row-label > label {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.option-row-input {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-row > select,
|
||||
.option-row > .dropdown {
|
||||
width: 60%;
|
||||
.option-row-input > select,
|
||||
.option-row-input > .dropdown {
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.option-row > .dropdown button {
|
||||
.option-row-input > .dropdown button {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.option-row-description {
|
||||
line-height: 1.3;
|
||||
margin-top: 0.25em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.option-row:last-of-type {
|
||||
border-bottom: unset;
|
||||
}
|
||||
|
||||
@@ -5,18 +5,24 @@ import { useUniqueName } from "../../../react/hooks";
|
||||
interface OptionsRowProps {
|
||||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
children: VNode;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
export default function OptionsRow({ name, label, children, centered }: OptionsRowProps) {
|
||||
export default function OptionsRow({ name, label, description, children, centered }: OptionsRowProps) {
|
||||
const id = useUniqueName(name);
|
||||
const childWithId = cloneElement(children, { id });
|
||||
|
||||
return (
|
||||
<div className={`option-row ${centered ? "centered" : ""}`}>
|
||||
{label && <label for={id}>{label}</label>}
|
||||
{childWithId}
|
||||
<div className="option-row-label">
|
||||
{label && <label for={id}>{label}</label>}
|
||||
{description && <small className="option-row-description">{description}</small>}
|
||||
</div>
|
||||
<div className="option-row-input">
|
||||
{childWithId}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { t } from "../../../services/i18n";
|
||||
import FormCheckbox from "../../react/FormCheckbox";
|
||||
import FormGroup from "../../react/FormGroup";
|
||||
import { FormTextBoxWithUnit } from "../../react/FormTextBox";
|
||||
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
|
||||
export default function ImageSettings() {
|
||||
const [ downloadImagesAutomatically, setDownloadImagesAutomatically ] = useTriliumOptionBool("downloadImagesAutomatically");
|
||||
const [ compressImages, setCompressImages ] = useTriliumOptionBool("compressImages");
|
||||
const [ imageMaxWidthHeight, setImageMaxWidthHeight ] = useTriliumOption("imageMaxWidthHeight");
|
||||
const [ imageJpegQuality, setImageJpegQuality ] = useTriliumOption("imageJpegQuality");
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("images.images_section_title")}>
|
||||
<FormGroup name="download-images-automatically" description={t("images.download_images_description")}>
|
||||
<FormCheckbox
|
||||
label={t("images.download_images_automatically")}
|
||||
currentValue={downloadImagesAutomatically} onChange={setDownloadImagesAutomatically}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<hr/>
|
||||
|
||||
<FormCheckbox
|
||||
name="image-compression-enabled"
|
||||
label={t("images.enable_image_compression")}
|
||||
currentValue={compressImages} onChange={setCompressImages}
|
||||
/>
|
||||
|
||||
<FormGroup name="image-max-width-height" label={t("images.max_image_dimensions")} disabled={!compressImages}>
|
||||
<FormTextBoxWithUnit
|
||||
type="number" min="1"
|
||||
unit={t("images.max_image_dimensions_unit")}
|
||||
currentValue={imageMaxWidthHeight} onChange={setImageMaxWidthHeight}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup name="image-jpeg-quality" label={t("images.jpeg_quality_description")} disabled={!compressImages}>
|
||||
<FormTextBoxWithUnit
|
||||
min="10" max="100" type="number"
|
||||
unit={t("units.percentage")}
|
||||
currentValue={imageJpegQuality} onChange={setImageJpegQuality}
|
||||
/>
|
||||
</FormGroup>
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
158
apps/client/src/widgets/type_widgets/options/llm.tsx
Normal file
158
apps/client/src/widgets/type_widgets/options/llm.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useMemo, useState } from "preact/hooks";
|
||||
|
||||
import dialog from "../../../services/dialog";
|
||||
import { isExperimentalFeatureEnabled } from "../../../services/experimental_features";
|
||||
import { t } from "../../../services/i18n";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import Button from "../../react/Button";
|
||||
import FormToggle from "../../react/FormToggle";
|
||||
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import OptionsRow from "./components/OptionsRow";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
import AddProviderModal, { type LlmProviderConfig, PROVIDER_TYPES } from "./llm/AddProviderModal";
|
||||
|
||||
export default function LlmSettings() {
|
||||
if (!isExperimentalFeatureEnabled("llm")) {
|
||||
return (
|
||||
<OptionsSection title={t("llm.settings_title")}>
|
||||
<p className="form-text">{t("llm.feature_not_enabled")}</p>
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProviderSettings />
|
||||
<McpSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderSettings() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function getMcpEndpointUrl() {
|
||||
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
|
||||
return `${window.location.protocol}//localhost:${port}/mcp`;
|
||||
}
|
||||
|
||||
function McpSettings() {
|
||||
const [mcpEnabled, setMcpEnabled] = useTriliumOptionBool("mcpEnabled");
|
||||
const endpointUrl = useMemo(() => getMcpEndpointUrl(), []);
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("llm.mcp_title")}>
|
||||
<OptionsRow name="mcp-enabled" label={t("llm.mcp_enabled")} description={t("llm.mcp_enabled_description")}>
|
||||
<FormToggle
|
||||
switchOnName="" switchOffName=""
|
||||
currentValue={mcpEnabled}
|
||||
onChange={setMcpEnabled}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
{mcpEnabled && (
|
||||
<OptionsRow name="mcp-endpoint" label={t("llm.mcp_endpoint_title")} description={t("llm.mcp_endpoint_description")}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={endpointUrl}
|
||||
readOnly
|
||||
/>
|
||||
</OptionsRow>
|
||||
)}
|
||||
</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
|
||||
);
|
||||
}
|
||||
176
apps/client/src/widgets/type_widgets/options/media.tsx
Normal file
176
apps/client/src/widgets/type_widgets/options/media.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../../services/i18n";
|
||||
import server from "../../../services/server";
|
||||
import toast from "../../../services/toast";
|
||||
import { FormTextBoxWithUnit } from "../../react/FormTextBox";
|
||||
import FormToggle from "../../react/FormToggle";
|
||||
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import Slider from "../../react/Slider";
|
||||
import OptionsRow from "./components/OptionsRow";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
import RelatedSettings from "./components/RelatedSettings";
|
||||
|
||||
export default function MediaSettings() {
|
||||
return (
|
||||
<>
|
||||
<ImageSettings />
|
||||
<OcrSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSettings() {
|
||||
const [ downloadImagesAutomatically, setDownloadImagesAutomatically ] = useTriliumOptionBool("downloadImagesAutomatically");
|
||||
const [ compressImages, setCompressImages ] = useTriliumOptionBool("compressImages");
|
||||
const [ imageMaxWidthHeight, setImageMaxWidthHeight ] = useTriliumOption("imageMaxWidthHeight");
|
||||
const [ imageJpegQuality, setImageJpegQuality ] = useTriliumOption("imageJpegQuality");
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("images.images_section_title")}>
|
||||
<OptionsRow name="download-images-automatically" label={t("images.download_images_automatically")} description={t("images.download_images_description")}>
|
||||
<FormToggle
|
||||
switchOnName="" switchOffName=""
|
||||
currentValue={downloadImagesAutomatically}
|
||||
onChange={setDownloadImagesAutomatically}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="image-compression-enabled" label={t("images.enable_image_compression")} description={t("images.enable_image_compression_description")}>
|
||||
<FormToggle
|
||||
switchOnName="" switchOffName=""
|
||||
currentValue={compressImages}
|
||||
onChange={setCompressImages}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="image-max-width-height" label={t("images.max_image_dimensions")} description={t("images.max_image_dimensions_description")}>
|
||||
<FormTextBoxWithUnit
|
||||
type="number" min="1"
|
||||
disabled={!compressImages}
|
||||
unit={t("images.max_image_dimensions_unit")}
|
||||
currentValue={imageMaxWidthHeight} onChange={setImageMaxWidthHeight}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="image-jpeg-quality" label={`${t("images.jpeg_quality")} (${imageJpegQuality ?? 75}%)`} description={t("images.jpeg_quality_description")}>
|
||||
<Slider
|
||||
min={10} max={100} step={5}
|
||||
value={parseInt(imageJpegQuality ?? "75", 10)}
|
||||
onChange={(v) => setImageJpegQuality(String(v))}
|
||||
/>
|
||||
</OptionsRow>
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function OcrSettings() {
|
||||
const [ ocrAutoProcess, setOcrAutoProcess ] = useTriliumOptionBool("ocrAutoProcessImages");
|
||||
const [ ocrMinConfidence, setOcrMinConfidence ] = useTriliumOption("ocrMinConfidence");
|
||||
|
||||
return (
|
||||
<>
|
||||
<OptionsSection title={t("images.ocr_section_title")}>
|
||||
<OptionsRow name="ocr-auto-process" label={t("images.ocr_auto_process")} description={t("images.ocr_auto_process_description")}>
|
||||
<FormToggle
|
||||
switchOnName="" switchOffName=""
|
||||
currentValue={ocrAutoProcess}
|
||||
onChange={setOcrAutoProcess}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="ocr-min-confidence" label={`${t("images.ocr_min_confidence")} (${Math.round(parseFloat(ocrMinConfidence ?? "0.75") * 100)}%)`} description={t("images.ocr_confidence_description")}>
|
||||
<Slider
|
||||
min={0} max={100} step={5}
|
||||
value={Math.round(parseFloat(ocrMinConfidence ?? "0.75") * 100)}
|
||||
onChange={(v) => setOcrMinConfidence(String(v / 100))}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<BatchProcessing />
|
||||
</OptionsSection>
|
||||
|
||||
<RelatedSettings items={[
|
||||
{
|
||||
title: t("images.ocr_related_content_languages"),
|
||||
targetPage: "_optionsLocalization"
|
||||
}
|
||||
]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface BatchProgress {
|
||||
inProgress: boolean;
|
||||
total: number;
|
||||
processed: number;
|
||||
percentage?: number;
|
||||
}
|
||||
|
||||
function BatchProcessing() {
|
||||
const [ progress, setProgress ] = useState<BatchProgress | null>(null);
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval>>(null);
|
||||
|
||||
const pollProgress = useCallback(() => {
|
||||
server.get<BatchProgress>("ocr/batch-progress").then((data) => {
|
||||
setProgress(data);
|
||||
if (!data.inProgress && pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
toast.showMessage(t("images.batch_ocr_completed", { processed: data.processed }));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Clean up polling on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function startBatch() {
|
||||
try {
|
||||
const result = await server.post<{ success: boolean; message?: string }>("ocr/batch-process");
|
||||
if (result.success) {
|
||||
toast.showMessage(t("images.batch_ocr_starting"));
|
||||
pollingRef.current = setInterval(pollProgress, 2000);
|
||||
pollProgress();
|
||||
} else {
|
||||
toast.showError(result.message || t("images.batch_ocr_error", { error: "Unknown" }));
|
||||
}
|
||||
} catch {
|
||||
// Server errors are already shown as toasts by server.ts.
|
||||
}
|
||||
}
|
||||
|
||||
const isRunning = progress?.inProgress ?? false;
|
||||
|
||||
return (
|
||||
<OptionsRow name="batch-ocr" label={t("images.batch_ocr_title")} description={t("images.batch_ocr_description")}>
|
||||
{isRunning ? (
|
||||
<div style={{ width: "100%" }}>
|
||||
<div className="progress" style={{ height: "24px" }}>
|
||||
<div
|
||||
className="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style={{ width: `${progress?.percentage ?? 0}%` }}
|
||||
>
|
||||
{t("images.batch_ocr_progress", { processed: progress?.processed ?? 0, total: progress?.total ?? 0 })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={startBatch}
|
||||
>
|
||||
<span className="bx bx-play" />{" "}{t("images.batch_ocr_start")}
|
||||
</button>
|
||||
)}
|
||||
</OptionsRow>
|
||||
);
|
||||
}
|
||||
@@ -205,7 +205,7 @@ export default function CKEditorWithWatchdog({ containerRef: externalContainerRe
|
||||
watchdog.on("stateChange", () => onWatchdogStateChange(watchdog));
|
||||
}
|
||||
|
||||
await watchdog.create(container);
|
||||
await watchdog.create(container, {});
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import utils from "../../../services/utils.js";
|
||||
import options from "../../../services/options.js";
|
||||
import IconAlignCenter from "@ckeditor/ckeditor5-icons/theme/icons/align-center.svg?raw";
|
||||
import { IconAlignCenter } from "@ckeditor/ckeditor5-icons";
|
||||
|
||||
const TEXT_FORMATTING_GROUP = {
|
||||
label: "Text formatting",
|
||||
|
||||
@@ -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: "",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||
import { FuseV1Options, FuseVersion } from "@electron/fuses";
|
||||
import { LOCALES } from "@triliumnext/commons";
|
||||
import { existsSync } from "fs";
|
||||
import fs from "fs-extra";
|
||||
@@ -166,6 +167,17 @@ const config: ForgeConfig = {
|
||||
{
|
||||
name: "@electron-forge/plugin-auto-unpack-natives",
|
||||
config: {}
|
||||
},
|
||||
{
|
||||
name: "@electron-forge/plugin-fuses",
|
||||
config: {
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true
|
||||
}
|
||||
}
|
||||
],
|
||||
hooks: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/desktop",
|
||||
"version": "0.102.1",
|
||||
"version": "0.102.2",
|
||||
"description": "Build your personal knowledge base with Trilium Notes",
|
||||
"private": true,
|
||||
"main": "src/main.ts",
|
||||
@@ -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",
|
||||
@@ -26,16 +27,9 @@
|
||||
"better-sqlite3": "12.8.0",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-dl": "4.0.0",
|
||||
"electron-squirrel-startup": "1.0.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jquery-hotkeys": "0.2.2"
|
||||
"electron-squirrel-startup": "1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "41.0.4",
|
||||
"@electron-forge/cli": "7.11.1",
|
||||
"@electron-forge/maker-deb": "7.11.1",
|
||||
"@electron-forge/maker-dmg": "7.11.1",
|
||||
@@ -44,6 +38,13 @@
|
||||
"@electron-forge/maker-squirrel": "7.11.1",
|
||||
"@electron-forge/maker-zip": "7.11.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "7.11.1",
|
||||
"@electron-forge/plugin-fuses": "7.11.1",
|
||||
"@electron/fuses": "1.8.0",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "40.8.5",
|
||||
"prebuild-install": "7.1.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/edit-docs",
|
||||
"version": "0.102.1",
|
||||
"version": "0.102.2",
|
||||
"private": true,
|
||||
"description": "Desktop version of Trilium which imports the demo database (presented to new users at start-up) or the user guide and other documentation and saves the modifications for committing.",
|
||||
"dependencies": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "41.0.4",
|
||||
"electron": "41.1.1",
|
||||
"fs-extra": "11.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "17.3.1"
|
||||
"dotenv": "17.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/server",
|
||||
"version": "0.102.1",
|
||||
"version": "0.102.2",
|
||||
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
|
||||
"private": true,
|
||||
"main": "./src/main.ts",
|
||||
@@ -30,10 +30,17 @@
|
||||
"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.55",
|
||||
"@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",
|
||||
"js-yaml": "4.1.1",
|
||||
"node-html-parser": "7.1.0",
|
||||
"sucrase": "3.35.1"
|
||||
"sucrase": "3.35.1",
|
||||
"unpdf": "1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
@@ -55,6 +62,7 @@
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/html": "1.0.4",
|
||||
"@types/ini": "4.1.1",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
@@ -70,9 +78,7 @@
|
||||
"@types/xml2js": "0.4.14",
|
||||
"archiver": "7.0.1",
|
||||
"async-mutex": "0.5.0",
|
||||
"axios": "1.13.6",
|
||||
"bindings": "1.5.0",
|
||||
"bootstrap": "5.3.8",
|
||||
"axios": "1.14.0",
|
||||
"chardet": "2.1.1",
|
||||
"cheerio": "1.2.0",
|
||||
"chokidar": "5.0.0",
|
||||
@@ -83,34 +89,33 @@
|
||||
"debounce": "3.0.0",
|
||||
"debug": "4.4.3",
|
||||
"ejs": "5.0.1",
|
||||
"electron": "41.0.4",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron": "41.1.1",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
"express": "5.2.1",
|
||||
"express-http-proxy": "2.1.2",
|
||||
"express-openid-connect": "2.20.0",
|
||||
"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",
|
||||
"helmet": "8.1.0",
|
||||
"html": "1.0.0",
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "8.0.0",
|
||||
"https-proxy-agent": "8.0.0",
|
||||
"i18next": "25.10.10",
|
||||
"i18next-fs-backend": "2.6.1",
|
||||
"http-proxy-agent": "9.0.0",
|
||||
"https-proxy-agent": "9.0.0",
|
||||
"i18next": "26.0.3",
|
||||
"i18next-fs-backend": "2.6.3",
|
||||
"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.5",
|
||||
"mime-types": "3.0.2",
|
||||
"multer": "2.1.1",
|
||||
"normalize-strings": "1.1.1",
|
||||
"officeparser": "6.0.7",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.4",
|
||||
@@ -121,14 +126,14 @@
|
||||
"strip-bom": "5.0.0",
|
||||
"striptags": "3.2.0",
|
||||
"supertest": "7.2.2",
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"tesseract.js": "7.0.0",
|
||||
"time2fa": "1.4.2",
|
||||
"tmp": "0.2.5",
|
||||
"turnish": "1.8.0",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "8.0.2",
|
||||
"vite": "8.0.3",
|
||||
"ws": "8.20.0",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.1"
|
||||
"yauzl": "3.3.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
177
apps/server/spec/etapi/mcp.spec.ts
Normal file
177
apps/server/spec/etapi/mcp.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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("get_note");
|
||||
expect(toolNames).toContain("get_note_content");
|
||||
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("gets note metadata by ID", async () => {
|
||||
const response = await mcpPost(app)
|
||||
.send(jsonRpc("tools/call", {
|
||||
name: "get_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.type).toBeDefined();
|
||||
expect(parsed.attributes).toBeDefined();
|
||||
});
|
||||
|
||||
it("reads note content by ID", async () => {
|
||||
const response = await mcpPost(app)
|
||||
.send(jsonRpc("tools/call", {
|
||||
name: "get_note_content",
|
||||
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`);
|
||||
@@ -107,6 +107,7 @@ CREATE TABLE IF NOT EXISTS "recent_notes"
|
||||
CREATE TABLE IF NOT EXISTS "blobs" (
|
||||
`blobId` TEXT NOT NULL,
|
||||
`content` TEXT NULL DEFAULT NULL,
|
||||
`textRepresentation` TEXT DEFAULT NULL,
|
||||
`dateModified` TEXT NOT NULL,
|
||||
`utcDateModified` TEXT NOT NULL,
|
||||
PRIMARY KEY(`blobId`)
|
||||
@@ -146,6 +147,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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user