mirror of
https://github.com/zadam/trilium.git
synced 2026-01-06 15:32:24 +01:00
Compare commits
161 Commits
v0.97.2
...
feat/llm-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb2ace41b0 | ||
|
|
778f13e2e6 | ||
|
|
bb3d0f0319 | ||
|
|
cec627a744 | ||
|
|
2958ae4587 | ||
|
|
8da904cf55 | ||
|
|
b37d9b4b3d | ||
|
|
ac415c1007 | ||
|
|
d38ca72e08 | ||
|
|
16622f43e3 | ||
|
|
f89c202fcc | ||
|
|
97ec882528 | ||
|
|
a1e596b81b | ||
|
|
3db145b6e6 | ||
|
|
0d898385f6 | ||
|
|
89fcfabd3c | ||
|
|
eeeecb3988 | ||
|
|
28ababcbb9 | ||
|
|
f382943af3 | ||
|
|
fa38332a6c | ||
|
|
5a58fcde96 | ||
|
|
62d048433b | ||
|
|
db4ba53449 | ||
|
|
da20916767 | ||
|
|
b1e12182ce | ||
|
|
80b2061935 | ||
|
|
8ce92f8c93 | ||
|
|
05cd8cb547 | ||
|
|
6e7d0bc51b | ||
|
|
b9aede23e6 | ||
|
|
1d52988826 | ||
|
|
ebe29f41f9 | ||
|
|
598591a2da | ||
|
|
32c2860b68 | ||
|
|
3e1f74ae93 | ||
|
|
81a8908b98 | ||
|
|
892dfe2340 | ||
|
|
fd175eb8a8 | ||
|
|
c98f6d96d5 | ||
|
|
35b628e799 | ||
|
|
49b79c016d | ||
|
|
25a9a8a724 | ||
|
|
313a61ec48 | ||
|
|
a2eab03ee2 | ||
|
|
a563b1c9a0 | ||
|
|
20018b9c21 | ||
|
|
0a9bd5f6d1 | ||
|
|
911fee0213 | ||
|
|
ffe4b53eee | ||
|
|
cd5a68d230 | ||
|
|
95a2a69e0a | ||
|
|
360b5d6de4 | ||
|
|
bf50883e40 | ||
|
|
8e04690568 | ||
|
|
ae0af8b9c7 | ||
|
|
a03a0f8a75 | ||
|
|
f0f27a9065 | ||
|
|
181d5ee36a | ||
|
|
2758a230ac | ||
|
|
a46d32ed75 | ||
|
|
b2bcae8b74 | ||
|
|
49d662afba | ||
|
|
2a27666c53 | ||
|
|
f2d45cb780 | ||
|
|
c4b91c9777 | ||
|
|
fa06f56f5d | ||
|
|
519b962af3 | ||
|
|
31e6ac2349 | ||
|
|
ed3ba2745f | ||
|
|
f5b7648d6d | ||
|
|
2d537b82f6 | ||
|
|
073354fe04 | ||
|
|
165d093928 | ||
|
|
e8cf3f4a10 | ||
|
|
c36b00994b | ||
|
|
76b856bfe5 | ||
|
|
7b084035a3 | ||
|
|
59fbdaa879 | ||
|
|
1046321117 | ||
|
|
00fc92764b | ||
|
|
dea8bc307e | ||
|
|
18a4fbaa4b | ||
|
|
3efc4b13d5 | ||
|
|
952456a69c | ||
|
|
bde8e17fe6 | ||
|
|
9023ba1d0a | ||
|
|
61f9a86685 | ||
|
|
5520cfed5d | ||
|
|
43df984732 | ||
|
|
3f398c1a00 | ||
|
|
ad35e3b48f | ||
|
|
73ee44e177 | ||
|
|
18414cd155 | ||
|
|
652d78ac68 | ||
|
|
9a3ab05d73 | ||
|
|
fe238b8afd | ||
|
|
94492c7535 | ||
|
|
47caf970a1 | ||
|
|
3e75ab39c2 | ||
|
|
72aacdbf6f | ||
|
|
5461dafe02 | ||
|
|
30f9f66b8b | ||
|
|
19de803142 | ||
|
|
11b247fe07 | ||
|
|
faa40494d8 | ||
|
|
796802aea0 | ||
|
|
06af5cf6d5 | ||
|
|
81a99c1e44 | ||
|
|
1b384f35d2 | ||
|
|
c1259f2ea2 | ||
|
|
92d9c82d97 | ||
|
|
064f0ef921 | ||
|
|
e9a9b462d4 | ||
|
|
98888d5f1d | ||
|
|
6a2a096348 | ||
|
|
bf34ef2009 | ||
|
|
9cddb9ac1d | ||
|
|
d72d3db2a0 | ||
|
|
14b3bea203 | ||
|
|
05c26d17d3 | ||
|
|
51360d855a | ||
|
|
ae7d03e3c7 | ||
|
|
87e1ce64d1 | ||
|
|
f9c7c5637b | ||
|
|
5d55b0b0a8 | ||
|
|
b2d7fbbcad | ||
|
|
fbc6734e08 | ||
|
|
a83172390f | ||
|
|
4b1fd5e4a0 | ||
|
|
51495b282f | ||
|
|
b645d21fcd | ||
|
|
8f99ce7d14 | ||
|
|
6eb650bb22 | ||
|
|
a7f5702221 | ||
|
|
efeb9b90ca | ||
|
|
3361a2e4ab | ||
|
|
425ade5212 | ||
|
|
384ab1d1f3 | ||
|
|
70b1a37285 | ||
|
|
61a878e2a0 | ||
|
|
319cb8384c | ||
|
|
dd7ee05388 | ||
|
|
f740edae91 | ||
|
|
464c2bdf28 | ||
|
|
8007bac8b8 | ||
|
|
4c01d7d8f1 | ||
|
|
42ee351487 | ||
|
|
e0383c49cb | ||
|
|
6fbc5b2b14 | ||
|
|
5562559b0b | ||
|
|
c119ffe478 | ||
|
|
27847ab720 | ||
|
|
755b1ed42f | ||
|
|
4e36dc8e5e | ||
|
|
8bc70a4190 | ||
|
|
d798d29e92 | ||
|
|
6e0fee6cb3 | ||
|
|
e0e1f0796b | ||
|
|
e98954c555 | ||
|
|
87fd6afec6 | ||
|
|
dccd6477d2 |
2
.github/workflows/main-docker.yml
vendored
2
.github/workflows/main-docker.yml
vendored
@@ -223,7 +223,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -107,7 +107,7 @@ jobs:
|
||||
docs/Release Notes
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
merge-multiple: true
|
||||
pattern: release-*
|
||||
|
||||
11
.github/workflows/unblock_signing.yml
vendored
Normal file
11
.github/workflows/unblock_signing.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
name: Unblock signing
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unblock-win-signing:
|
||||
runs-on: win-signing
|
||||
steps:
|
||||
- run: |
|
||||
cat ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
|
||||
rm ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
|
||||
@@ -46,7 +46,7 @@
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "16.1.1",
|
||||
"marked": "16.1.2",
|
||||
"mermaid": "11.9.0",
|
||||
"mind-elixir": "5.0.4",
|
||||
"normalize.css": "8.0.1",
|
||||
@@ -59,6 +59,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "3.5.32",
|
||||
"@types/leaflet": "1.9.20",
|
||||
|
||||
214
apps/client/src/translations/ca/translation.json
Normal file
214
apps/client/src/translations/ca/translation.json
Normal file
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Sobre Trilium Notes",
|
||||
"close": "Tanca",
|
||||
"homepage": "Pàgina principal:"
|
||||
},
|
||||
"add_link": {
|
||||
"note": "Nota",
|
||||
"close": "Tanca"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"close": "Tanca",
|
||||
"prefix": "Prefix: ",
|
||||
"save": "Desa"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"close": "Tanca",
|
||||
"labels": "Etiquetes",
|
||||
"relations": "Relacions",
|
||||
"notes": "Notes",
|
||||
"other": "Altres"
|
||||
},
|
||||
"clone_to": {
|
||||
"close": "Tanca"
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Confirmació",
|
||||
"close": "Tanca",
|
||||
"cancel": "Cancel·la",
|
||||
"ok": "OK"
|
||||
},
|
||||
"delete_notes": {
|
||||
"close": "Tanca",
|
||||
"cancel": "Cancel·la",
|
||||
"ok": "OK"
|
||||
},
|
||||
"export": {
|
||||
"close": "Tanca",
|
||||
"export": "Exporta"
|
||||
},
|
||||
"help": {
|
||||
"close": "Tanca",
|
||||
"troubleshooting": "Solució de problemes",
|
||||
"other": "Altres"
|
||||
},
|
||||
"import": {
|
||||
"close": "Tanca",
|
||||
"options": "Opcions",
|
||||
"import": "Importa"
|
||||
},
|
||||
"include_note": {
|
||||
"close": "Tanca",
|
||||
"label_note": "Nota"
|
||||
},
|
||||
"info": {
|
||||
"closeButton": "Tanca",
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "Tanca"
|
||||
},
|
||||
"markdown_import": {
|
||||
"close": "Tanca"
|
||||
},
|
||||
"move_to": {
|
||||
"close": "Tanca"
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"close": "Tanca",
|
||||
"templates": "Plantilles:"
|
||||
},
|
||||
"password_not_set": {
|
||||
"close": "Tanca"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Sol·licitud",
|
||||
"close": "Tanca",
|
||||
"defaultTitle": "Sol·licitud"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Tanca"
|
||||
},
|
||||
"recent_changes": {
|
||||
"close": "Tanca",
|
||||
"undelete_link": "recuperar"
|
||||
},
|
||||
"revisions": {
|
||||
"close": "Tanca",
|
||||
"restore_button": "Restaura",
|
||||
"delete_button": "Suprimeix",
|
||||
"download_button": "Descarrega",
|
||||
"mime": "MIME: ",
|
||||
"preview": "Vista prèvia:"
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"close": "Tanca",
|
||||
"title": "títol",
|
||||
"ascending": "ascendent",
|
||||
"descending": "descendent",
|
||||
"folders": "Carpetes"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"close": "Tanca",
|
||||
"options": "Opcions",
|
||||
"upload": "Puja"
|
||||
},
|
||||
"attribute_detail": {
|
||||
"name": "Nom",
|
||||
"value": "Valor",
|
||||
"promoted": "Destacat",
|
||||
"promoted_alias": "Àlies",
|
||||
"multiplicity": "Multiplicitat",
|
||||
"label_type": "Tipus",
|
||||
"text": "Text",
|
||||
"number": "Número",
|
||||
"boolean": "Booleà",
|
||||
"date": "Data",
|
||||
"time": "Hora",
|
||||
"url": "URL",
|
||||
"precision": "Precisió",
|
||||
"digits": "dígits",
|
||||
"inheritable": "Heretable",
|
||||
"delete": "Suprimeix",
|
||||
"color_type": "Color"
|
||||
},
|
||||
"rename_label": {
|
||||
"to": "Per"
|
||||
},
|
||||
"move_note": {
|
||||
"to": "a"
|
||||
},
|
||||
"add_relation": {
|
||||
"to": "a"
|
||||
},
|
||||
"rename_relation": {
|
||||
"to": "Per"
|
||||
},
|
||||
"update_relation_target": {
|
||||
"to": "a"
|
||||
},
|
||||
"attachments_actions": {
|
||||
"download": "Descarrega"
|
||||
},
|
||||
"calendar": {
|
||||
"mon": "Dl",
|
||||
"tue": "Dt",
|
||||
"wed": "dc",
|
||||
"thu": "Dj",
|
||||
"fri": "Dv",
|
||||
"sat": "Ds",
|
||||
"sun": "Dg",
|
||||
"january": "Gener",
|
||||
"febuary": "Febrer",
|
||||
"march": "Març",
|
||||
"april": "Abril",
|
||||
"may": "Maig",
|
||||
"june": "Juny",
|
||||
"july": "Juliol",
|
||||
"august": "Agost",
|
||||
"september": "Setembre",
|
||||
"october": "Octubre",
|
||||
"november": "Novembre",
|
||||
"december": "Desembre"
|
||||
},
|
||||
"global_menu": {
|
||||
"menu": "Menú",
|
||||
"options": "Opcions",
|
||||
"zoom": "Zoom",
|
||||
"advanced": "Avançat",
|
||||
"logout": "Tanca la sessió"
|
||||
},
|
||||
"zpetne_odkazy": {
|
||||
"relation": "relació"
|
||||
},
|
||||
"note_icon": {
|
||||
"category": "Categoria:",
|
||||
"search": "Cerca:"
|
||||
},
|
||||
"basic_properties": {
|
||||
"editable": "Editable",
|
||||
"language": "Llengua"
|
||||
},
|
||||
"book_properties": {
|
||||
"grid": "Graella",
|
||||
"list": "Llista",
|
||||
"collapse": "Replega",
|
||||
"expand": "Desplega",
|
||||
"calendar": "Calendari",
|
||||
"table": "Taula",
|
||||
"board": "Tauler"
|
||||
},
|
||||
"edited_notes": {
|
||||
"deleted": "(suprimit)"
|
||||
},
|
||||
"file_properties": {
|
||||
"download": "Descarrega",
|
||||
"open": "Obre",
|
||||
"title": "Fitxer"
|
||||
},
|
||||
"image_properties": {
|
||||
"download": "Descarrega",
|
||||
"open": "Obre",
|
||||
"title": "Imatge"
|
||||
},
|
||||
"note_info_widget": {
|
||||
"created": "Creat",
|
||||
"modified": "Modificat",
|
||||
"type": "Tipus",
|
||||
"calculate": "calcula"
|
||||
},
|
||||
"note_paths": {
|
||||
"archived": "Arxivat"
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,8 @@
|
||||
"showSQLConsole": "显示 SQL 控制台",
|
||||
"other": "其他",
|
||||
"quickSearch": "定位到快速搜索框",
|
||||
"inPageSearch": "页面内搜索"
|
||||
"inPageSearch": "页面内搜索",
|
||||
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+click</kbd> - (或 <kbd>Shift+middle mouse click</kbd>) 在笔记链接打开并激活笔记在一个新的选项卡"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "导入到笔记",
|
||||
@@ -211,7 +212,8 @@
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "关闭",
|
||||
"search_button": "全文搜索 <kbd>Ctrl+回车</kbd>"
|
||||
"search_button": "全文搜索 <kbd>Ctrl+回车</kbd>",
|
||||
"search_placeholder": "按名称或类型搜索笔记 > 查看命令..."
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Markdown 导入",
|
||||
@@ -234,7 +236,9 @@
|
||||
"modal_title": "选择笔记类型",
|
||||
"close": "关闭",
|
||||
"modal_body": "选择新笔记的类型或模板:",
|
||||
"templates": "模板:"
|
||||
"templates": "模板:",
|
||||
"change_path_prompt": "更改创建新笔记的位置:",
|
||||
"search_placeholder": "按名称搜索路径(默认为空)"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "密码未设置",
|
||||
@@ -439,7 +443,8 @@
|
||||
"other_notes_with_name": "其它含有 {{attributeType}} 名为 \"{{attributeName}}\" 的的笔记",
|
||||
"and_more": "... 以及另外 {{count}} 个",
|
||||
"print_landscape": "导出为 PDF 时,将页面方向更改为横向而不是纵向。",
|
||||
"print_page_size": "导出为 PDF 时,更改页面大小。支持的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。"
|
||||
"print_page_size": "导出为 PDF 时,更改页面大小。支持的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。",
|
||||
"color_type": "颜色"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "要添加标签,只需输入例如 <code>#rock</code> 或者如果您还想添加值,则例如 <code>#year = 2020</code>",
|
||||
@@ -743,7 +748,8 @@
|
||||
"basic_properties": {
|
||||
"note_type": "笔记类型",
|
||||
"editable": "可编辑",
|
||||
"basic_properties": "基本属性"
|
||||
"basic_properties": "基本属性",
|
||||
"language": "语言"
|
||||
},
|
||||
"book_properties": {
|
||||
"view_type": "视图类型",
|
||||
@@ -754,7 +760,11 @@
|
||||
"collapse": "折叠",
|
||||
"expand": "展开",
|
||||
"invalid_view_type": "无效的查看类型 '{{type}}'",
|
||||
"calendar": "日历"
|
||||
"calendar": "日历",
|
||||
"book_properties": "集合属性",
|
||||
"table": "表格",
|
||||
"geo-map": "地理地图",
|
||||
"board": "看板"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "今天还没有编辑过的笔记...",
|
||||
@@ -831,7 +841,8 @@
|
||||
"unknown_label_type": "未知的标签类型 '{{type}}'",
|
||||
"unknown_attribute_type": "未知的属性类型 '{{type}}'",
|
||||
"add_new_attribute": "添加新属性",
|
||||
"remove_this_attribute": "移除此属性"
|
||||
"remove_this_attribute": "移除此属性",
|
||||
"remove_color": "移除此颜色标签"
|
||||
},
|
||||
"script_executor": {
|
||||
"query": "查询",
|
||||
@@ -1090,7 +1101,8 @@
|
||||
"max_width_label": "内容最大宽度(像素)",
|
||||
"apply_changes_description": "要应用内容宽度更改,请点击",
|
||||
"reload_button": "重载前端",
|
||||
"reload_description": "来自外观选项的更改"
|
||||
"reload_description": "来自外观选项的更改",
|
||||
"max_width_unit": "像素"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"title": "原生标题栏(需要重新启动应用)",
|
||||
@@ -1125,7 +1137,8 @@
|
||||
"code_auto_read_only_size": {
|
||||
"title": "自动只读大小",
|
||||
"description": "自动只读大小是指笔记超过设置的大小后自动设置为只读模式(为性能考虑)。",
|
||||
"label": "自动只读大小(代码笔记)"
|
||||
"label": "自动只读大小(代码笔记)",
|
||||
"unit": "字符"
|
||||
},
|
||||
"code_mime_types": {
|
||||
"title": "下拉菜单可用的MIME文件类型"
|
||||
@@ -1144,7 +1157,8 @@
|
||||
"download_images_description": "粘贴的 HTML 可能包含在线图片的引用,Trilium 会找到这些引用并下载图片,以便它们可以离线使用。",
|
||||
"enable_image_compression": "启用图片压缩",
|
||||
"max_image_dimensions": "图片的最大宽度/高度(超过此限制的图像将会被缩放)。",
|
||||
"jpeg_quality_description": "JPEG 质量(10 - 最差质量,100 最佳质量,建议为 50 - 85)"
|
||||
"jpeg_quality_description": "JPEG 质量(10 - 最差质量,100 最佳质量,建议为 50 - 85)",
|
||||
"max_image_dimensions_unit": "像素"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "附件清理超时",
|
||||
@@ -1176,7 +1190,8 @@
|
||||
"note_revisions_snapshot_limit_description": "笔记修订快照数限制指的是每个笔记可以保存的最大历史记录数量。其中 -1 表示没有限制,0 表示删除所有历史记录。您可以通过 #versioningLimit 标签设置单个笔记的最大修订记录数量。",
|
||||
"snapshot_number_limit_label": "笔记修订快照数量限制:",
|
||||
"erase_excess_revision_snapshots": "立即删除多余的修订快照",
|
||||
"erase_excess_revision_snapshots_prompt": "多余的修订快照已被删除。"
|
||||
"erase_excess_revision_snapshots_prompt": "多余的修订快照已被删除。",
|
||||
"snapshot_number_limit_unit": "快照"
|
||||
},
|
||||
"search_engine": {
|
||||
"title": "搜索引擎",
|
||||
@@ -1218,12 +1233,14 @@
|
||||
"title": "目录",
|
||||
"description": "当笔记中有超过一定数量的标题时,显示目录。您可以自定义此数量:",
|
||||
"disable_info": "您可以设置一个非常大的数来禁用目录。",
|
||||
"shortcut_info": "您可以在 “选项” -> “快捷键” 中配置一个键盘快捷键,以便快速切换右侧面板(包括目录)(名称为 'toggleRightPane')。"
|
||||
"shortcut_info": "您可以在 “选项” -> “快捷键” 中配置一个键盘快捷键,以便快速切换右侧面板(包括目录)(名称为 'toggleRightPane')。",
|
||||
"unit": "标题"
|
||||
},
|
||||
"text_auto_read_only_size": {
|
||||
"title": "自动只读大小",
|
||||
"description": "自动只读笔记大小是超过该大小后,笔记将以只读模式显示(出于性能考虑)。",
|
||||
"label": "自动只读大小(文本笔记)"
|
||||
"label": "自动只读大小(文本笔记)",
|
||||
"unit": "字符"
|
||||
},
|
||||
"i18n": {
|
||||
"title": "本地化",
|
||||
@@ -1374,7 +1391,8 @@
|
||||
"test_title": "同步测试",
|
||||
"test_description": "测试和同步服务器之间的连接。如果同步服务器没有初始化,会将本地文档同步到同步服务器上。",
|
||||
"test_button": "测试同步",
|
||||
"handshake_failed": "同步服务器握手失败,错误:{{message}}"
|
||||
"handshake_failed": "同步服务器握手失败,错误:{{message}}",
|
||||
"timeout_unit": "毫秒"
|
||||
},
|
||||
"api_log": {
|
||||
"close": "关闭"
|
||||
@@ -1433,7 +1451,9 @@
|
||||
"import-into-note": "导入到笔记",
|
||||
"apply-bulk-actions": "应用批量操作",
|
||||
"converted-to-attachments": "{{count}} 个笔记已被转换为附件。",
|
||||
"convert-to-attachment-confirm": "确定要将选中的笔记转换为其父笔记的附件吗?"
|
||||
"convert-to-attachment-confirm": "确定要将选中的笔记转换为其父笔记的附件吗?",
|
||||
"duplicate": "复制",
|
||||
"open-in-popup": "快速编辑"
|
||||
},
|
||||
"shared_info": {
|
||||
"shared_publicly": "此笔记已公开分享于",
|
||||
@@ -1459,7 +1479,11 @@
|
||||
"confirm-change": "当笔记内容不为空时,不建议更改笔记类型。您仍然要继续吗?",
|
||||
"geo-map": "地理地图",
|
||||
"beta-feature": "测试版",
|
||||
"task-list": "待办事项列表"
|
||||
"task-list": "任务列表",
|
||||
"ai-chat": "AI聊天",
|
||||
"new-feature": "新建",
|
||||
"collections": "集合",
|
||||
"book": "集合"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保护笔记",
|
||||
@@ -1569,7 +1593,9 @@
|
||||
},
|
||||
"clipboard": {
|
||||
"cut": "笔记已剪切到剪贴板。",
|
||||
"copied": "笔记已复制到剪贴板。"
|
||||
"copied": "笔记已复制到剪贴板。",
|
||||
"copy_failed": "由于权限问题,无法复制到剪贴板。",
|
||||
"copy_success": "已复制到剪贴板。"
|
||||
},
|
||||
"entrypoints": {
|
||||
"note-revision-created": "笔记修订已创建。",
|
||||
@@ -1620,7 +1646,8 @@
|
||||
"word_wrapping": "自动换行",
|
||||
"theme_none": "无语法高亮",
|
||||
"theme_group_light": "浅色主题",
|
||||
"theme_group_dark": "深色主题"
|
||||
"theme_group_dark": "深色主题",
|
||||
"copy_title": "复制到剪贴板"
|
||||
},
|
||||
"classic_editor_toolbar": {
|
||||
"title": "格式"
|
||||
@@ -1658,7 +1685,8 @@
|
||||
"link_context_menu": {
|
||||
"open_note_in_new_tab": "在新标签页中打开笔记",
|
||||
"open_note_in_new_split": "在新分屏中打开笔记",
|
||||
"open_note_in_new_window": "在新窗口中打开笔记"
|
||||
"open_note_in_new_window": "在新窗口中打开笔记",
|
||||
"open_note_in_popup": "快速编辑"
|
||||
},
|
||||
"electron_integration": {
|
||||
"desktop-application": "桌面应用程序",
|
||||
@@ -1678,7 +1706,8 @@
|
||||
"full-text-search": "全文搜索"
|
||||
},
|
||||
"note_tooltip": {
|
||||
"note-has-been-deleted": "笔记已被删除。"
|
||||
"note-has-been-deleted": "笔记已被删除。",
|
||||
"quick-edit": "快速编辑"
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-title": "创建一个新的子笔记并将其添加到地图中",
|
||||
@@ -1687,7 +1716,8 @@
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "打开位置",
|
||||
"remove-from-map": "从地图中移除"
|
||||
"remove-from-map": "从地图中移除",
|
||||
"add-note": "在这个位置添加一个标记"
|
||||
},
|
||||
"help-button": {
|
||||
"title": "打开相关帮助页面"
|
||||
@@ -1719,5 +1749,262 @@
|
||||
"tomorrow": "明天",
|
||||
"yesterday": "昨天"
|
||||
}
|
||||
},
|
||||
"ai_llm": {
|
||||
"not_started": "未开始",
|
||||
"title": "AI设置",
|
||||
"processed_notes": "已处理笔记",
|
||||
"total_notes": "笔记总数",
|
||||
"progress": "进度",
|
||||
"queued_notes": "排队中笔记",
|
||||
"failed_notes": "失败笔记",
|
||||
"last_processed": "最后处理时间",
|
||||
"refresh_stats": "刷新统计数据",
|
||||
"enable_ai_features": "启用AI/LLM功能",
|
||||
"enable_ai_description": "启用笔记摘要、内容生成等AI功能及其他LLM能力",
|
||||
"openai_tab": "OpenAI",
|
||||
"anthropic_tab": "Anthropic",
|
||||
"voyage_tab": "Voyage AI",
|
||||
"ollama_tab": "Ollama",
|
||||
"enable_ai": "启用AI/LLM功能",
|
||||
"enable_ai_desc": "启用笔记摘要、内容生成等AI功能及其他LLM能力",
|
||||
"provider_configuration": "AI提供商配置",
|
||||
"provider_precedence": "提供商优先级",
|
||||
"provider_precedence_description": "按优先级排序的提供商列表(用逗号分隔,例如:'openai,anthropic,ollama')",
|
||||
"temperature": "温度参数",
|
||||
"temperature_description": "控制响应的随机性(0 = 确定性输出,2 = 最大随机性)",
|
||||
"system_prompt": "系统提示词",
|
||||
"system_prompt_description": "所有AI交互使用的默认系统提示词",
|
||||
"openai_configuration": "OpenAI配置",
|
||||
"openai_settings": "OpenAI设置",
|
||||
"api_key": "API密钥",
|
||||
"url": "基础URL",
|
||||
"model": "模型",
|
||||
"openai_api_key_description": "用于访问OpenAI服务的API密钥",
|
||||
"anthropic_api_key_description": "用于访问Claude模型的Anthropic API密钥",
|
||||
"default_model": "默认模型",
|
||||
"openai_model_description": "示例:gpt-4o、gpt-4-turbo、gpt-3.5-turbo",
|
||||
"base_url": "基础URL",
|
||||
"openai_url_description": "默认:https://api.openai.com/v1",
|
||||
"anthropic_settings": "Anthropic设置",
|
||||
"anthropic_url_description": "Anthropic API的基础URL(默认:https://api.anthropic.com)",
|
||||
"anthropic_model_description": "用于聊天补全的Anthropic Claude模型",
|
||||
"voyage_settings": "Voyage AI设置",
|
||||
"ollama_settings": "Ollama设置",
|
||||
"ollama_url_description": "Ollama API的URL(默认:http://localhost:11434)",
|
||||
"ollama_model_description": "用于聊天补全的 Ollama 模型",
|
||||
"anthropic_configuration": "Anthropic配置",
|
||||
"voyage_configuration": "Voyage AI配置",
|
||||
"voyage_url_description": "默认:https://api.voyageai.com/v1",
|
||||
"ollama_configuration": "Ollama配置",
|
||||
"enable_ollama": "启用Ollama",
|
||||
"enable_ollama_description": "启用Ollama以使用本地AI模型",
|
||||
"ollama_url": "Ollama URL",
|
||||
"ollama_model": "Ollama模型",
|
||||
"refresh_models": "刷新模型",
|
||||
"refreshing_models": "刷新中...",
|
||||
"enable_automatic_indexing": "启用自动索引",
|
||||
"rebuild_index": "重建索引",
|
||||
"rebuild_index_error": "启动索引重建失败。请查看日志了解详情。",
|
||||
"note_title": "笔记标题",
|
||||
"error": "错误",
|
||||
"last_attempt": "最后尝试时间",
|
||||
"actions": "操作",
|
||||
"retry": "重试",
|
||||
"partial": "{{ percentage }}% 已完成",
|
||||
"retry_queued": "笔记已加入重试队列",
|
||||
"retry_failed": "笔记加入重试队列失败",
|
||||
"max_notes_per_llm_query": "每次查询的最大笔记数",
|
||||
"max_notes_per_llm_query_description": "AI上下文包含的最大相似笔记数量",
|
||||
"active_providers": "活跃提供商",
|
||||
"disabled_providers": "已禁用提供商",
|
||||
"remove_provider": "从搜索中移除提供商",
|
||||
"restore_provider": "将提供商恢复到搜索中",
|
||||
"similarity_threshold": "相似度阈值",
|
||||
"similarity_threshold_description": "纳入LLM查询上下文的笔记最低相似度分数(0-1)",
|
||||
"reprocess_index": "重建搜索索引",
|
||||
"reprocessing_index": "重建中...",
|
||||
"reprocess_index_started": "搜索索引优化已在后台启动",
|
||||
"reprocess_index_error": "重建搜索索引失败",
|
||||
"index_rebuild_progress": "索引重建进度",
|
||||
"index_rebuilding": "正在优化索引({{percentage}}%)",
|
||||
"index_rebuild_complete": "索引优化完成",
|
||||
"index_rebuild_status_error": "检查索引重建状态失败",
|
||||
"never": "从未",
|
||||
"processing": "处理中({{percentage}}%)",
|
||||
"incomplete": "未完成({{percentage}}%)",
|
||||
"complete": "已完成(100%)",
|
||||
"refreshing": "刷新中...",
|
||||
"auto_refresh_notice": "每 {{seconds}} 秒自动刷新",
|
||||
"note_queued_for_retry": "笔记已加入重试队列",
|
||||
"failed_to_retry_note": "重试笔记失败",
|
||||
"all_notes_queued_for_retry": "所有失败笔记已加入重试队列",
|
||||
"failed_to_retry_all": "重试笔记失败",
|
||||
"ai_settings": "AI设置",
|
||||
"api_key_tooltip": "用于访问服务的API密钥",
|
||||
"empty_key_warning": {
|
||||
"anthropic": "Anthropic API密钥为空。请输入有效的API密钥。",
|
||||
"openai": "OpenAI API密钥为空。请输入有效的API密钥。",
|
||||
"voyage": "Voyage API密钥为空。请输入有效的API密钥。",
|
||||
"ollama": "Ollama API密钥为空。请输入有效的API密钥。"
|
||||
},
|
||||
"agent": {
|
||||
"processing": "处理中...",
|
||||
"thinking": "思考中...",
|
||||
"loading": "加载中...",
|
||||
"generating": "生成中..."
|
||||
},
|
||||
"name": "AI",
|
||||
"openai": "OpenAI",
|
||||
"use_enhanced_context": "使用增强上下文",
|
||||
"enhanced_context_description": "为AI提供来自笔记及其相关笔记的更多上下文,以获得更好的响应",
|
||||
"show_thinking": "显示思考过程",
|
||||
"show_thinking_description": "显示AI的思维链过程",
|
||||
"enter_message": "输入你的消息...",
|
||||
"error_contacting_provider": "联系AI提供商失败。请检查你的设置和网络连接。",
|
||||
"error_generating_response": "生成AI响应失败",
|
||||
"index_all_notes": "为所有笔记建立索引",
|
||||
"index_status": "索引状态",
|
||||
"indexed_notes": "已索引笔记",
|
||||
"indexing_stopped": "索引已停止",
|
||||
"indexing_in_progress": "索引进行中...",
|
||||
"last_indexed": "最后索引时间",
|
||||
"n_notes_queued_0": "{{ count }} 条笔记已加入索引队列",
|
||||
"note_chat": "笔记聊天",
|
||||
"notes_indexed_0": "{{ count }} 条笔记已索引",
|
||||
"sources": "来源",
|
||||
"start_indexing": "开始索引",
|
||||
"use_advanced_context": "使用高级上下文",
|
||||
"ollama_no_url": "Ollama 未配置。请输入有效的URL。",
|
||||
"chat": {
|
||||
"root_note_title": "AI聊天记录",
|
||||
"root_note_content": "此笔记包含你保存的AI聊天对话。",
|
||||
"new_chat_title": "新聊天",
|
||||
"create_new_ai_chat": "创建新的AI聊天"
|
||||
},
|
||||
"create_new_ai_chat": "创建新的AI聊天",
|
||||
"configuration_warnings": "你的AI配置存在一些问题。请检查你的设置。",
|
||||
"experimental_warning": "LLM功能目前处于实验阶段 - 特此提醒。",
|
||||
"selected_provider": "已选提供商",
|
||||
"selected_provider_description": "选择用于聊天和补全功能的AI提供商",
|
||||
"select_model": "选择模型...",
|
||||
"select_provider": "选择提供商..."
|
||||
},
|
||||
"code-editor-options": {
|
||||
"title": "编辑器"
|
||||
},
|
||||
"custom_date_time_format": {
|
||||
"title": "自定义日期/时间格式",
|
||||
"description": "通过<kbd></kbd>或工具栏的方式可自定义日期和时间格式,有关日期/时间格式字符串中各个字符的含义,请参阅<a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a>。",
|
||||
"format_string": "日期/时间格式字符串:",
|
||||
"formatted_time": "格式化后日期/时间:"
|
||||
},
|
||||
"content_widget": {
|
||||
"unknown_widget": "未知组件:\"{{id}}\"."
|
||||
},
|
||||
"note_language": {
|
||||
"not_set": "不设置",
|
||||
"configure-languages": "设置语言..."
|
||||
},
|
||||
"content_language": {
|
||||
"title": "内容语言",
|
||||
"description": "选择一种或多种语言出现在只读或可编辑文本注释的基本属性,这将支持拼写检查或从右向左之类的功能。"
|
||||
},
|
||||
"switch_layout_button": {
|
||||
"title_vertical": "将编辑面板移至底部",
|
||||
"title_horizontal": "将编辑面板移至左侧"
|
||||
},
|
||||
"toggle_read_only_button": {
|
||||
"unlock-editing": "解锁编辑",
|
||||
"lock-editing": "锁定编辑"
|
||||
},
|
||||
"png_export_button": {
|
||||
"button_title": "将图表导出为PNG"
|
||||
},
|
||||
"svg": {
|
||||
"export_to_png": "无法将图表导出为PNG。"
|
||||
},
|
||||
"code_theme": {
|
||||
"title": "外观",
|
||||
"word_wrapping": "自动换行",
|
||||
"color-scheme": "配色方案"
|
||||
},
|
||||
"cpu_arch_warning": {
|
||||
"title": "请下载ARM64版本",
|
||||
"message_macos": "TriliumNext当前正在通过Rosetta 2转译运行,这意味着您在Apple Silicon芯片的Mac上使用的是Intel(x64)版本。这将显著影响性能和电池续航。",
|
||||
"message_windows": "TriliumNext当前正在模拟环境中运行,这意味着您在ARM架构的Windows设备上使用的是Intel(x64)版本。这将显著影响性能和电池续航。",
|
||||
"recommendation": "为获得最佳体验,请从我们的发布页面下载TriliumNext的原生ARM64版本。",
|
||||
"download_link": "下载原生版本",
|
||||
"continue_anyway": "仍然继续",
|
||||
"dont_show_again": "不再显示此警告"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"title": "功能",
|
||||
"emoji_completion_enabled": "启用表情自动补全",
|
||||
"note_completion_enabled": "启用笔记自动补全"
|
||||
},
|
||||
"table_view": {
|
||||
"new-row": "新增行",
|
||||
"new-column": "新增列",
|
||||
"sort-column-by": "按\"{{title}}\"排序",
|
||||
"sort-column-ascending": "升序",
|
||||
"sort-column-descending": "降序",
|
||||
"sort-column-clear": "清除排序",
|
||||
"hide-column": "隐藏\"{{title}}\"列",
|
||||
"show-hide-columns": "显示/隐藏列",
|
||||
"row-insert-above": "在上方插入行",
|
||||
"row-insert-below": "在下方插入行",
|
||||
"row-insert-child": "插入子笔记",
|
||||
"add-column-to-the-left": "在左侧添加列",
|
||||
"add-column-to-the-right": "在右侧添加列",
|
||||
"edit-column": "编辑列",
|
||||
"delete_column_confirmation": "确定要删除此列吗?所有笔记中对应的属性都将被移除。",
|
||||
"delete-column": "删除列",
|
||||
"new-column-label": "标签",
|
||||
"new-column-relation": "关联"
|
||||
},
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "隐藏周末",
|
||||
"display-week-numbers": "显示周数",
|
||||
"map-style": "地图样式:",
|
||||
"max-nesting-depth": "最大嵌套深度:",
|
||||
"raster": "栅格",
|
||||
"vector_light": "矢量(浅色)",
|
||||
"vector_dark": "矢量(深色)",
|
||||
"show-scale": "显示比例尺"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "删除行"
|
||||
},
|
||||
"board_view": {
|
||||
"delete-note": "删除笔记",
|
||||
"move-to": "移动到",
|
||||
"insert-above": "在上方插入",
|
||||
"insert-below": "在下方插入",
|
||||
"delete-column": "删除列",
|
||||
"delete-column-confirmation": "确定要删除此列吗?此列下所有笔记中对应的属性也将被删除。",
|
||||
"new-item": "新增项目",
|
||||
"add-column": "添加列"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "树形:{{name}}",
|
||||
"export_note_title": "导出笔记",
|
||||
"export_note_description": "导出当前笔记",
|
||||
"show_attachments_title": "显示附件",
|
||||
"show_attachments_description": "查看笔记附件",
|
||||
"search_notes_title": "搜索笔记",
|
||||
"search_notes_description": "打开高级搜索",
|
||||
"search_subtree_title": "在子树中搜索",
|
||||
"search_subtree_description": "在当前子树范围内搜索",
|
||||
"search_history_title": "显示搜索历史",
|
||||
"search_history_description": "查看之前的搜索记录",
|
||||
"configure_launch_bar_title": "配置启动栏",
|
||||
"configure_launch_bar_description": "打开启动栏配置,添加或移除项目。"
|
||||
},
|
||||
"content_renderer": {
|
||||
"open_externally": "在外部打开"
|
||||
},
|
||||
"modal": {
|
||||
"close": "关闭"
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/client/src/translations/el/translation.json
Normal file
19
apps/client/src/translations/el/translation.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Πληροφορίες για το Trilium Notes",
|
||||
"close": "Κλείσιμο",
|
||||
"homepage": "Αρχική Σελίδα:",
|
||||
"app_version": "Έκδοση εφαρμογής:",
|
||||
"db_version": "Έκδοση βάσης δεδομένων:",
|
||||
"sync_version": "Έκδοση πρωτοκόλου συγχρονισμού:",
|
||||
"build_date": "Ημερομηνία χτισίματος εφαρμογής:",
|
||||
"build_revision": "Αριθμός αναθεώρησης χτισίματος:",
|
||||
"data_directory": "Φάκελος δεδομένων:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Κρίσιμο σφάλμα",
|
||||
"message": "Συνέβη κάποιο κρίσιμο σφάλμα, το οποίο δεν επιτρέπει στην εφαρμογή χρήστη να ξεκινήσει:\n\n{{message}}\n\nΤο πιθανότερο είναι να προκλήθηκε από κάποιο script που απέτυχε απρόοπτα. Δοκιμάστε να ξεκινήσετε την εφαρμογή σε ασφαλή λειτουργία για να λύσετε το πρόβλημα."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2005,5 +2005,8 @@
|
||||
},
|
||||
"content_renderer": {
|
||||
"open_externally": "Open externally"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Close"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,8 @@
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "Cerrar",
|
||||
"search_button": "Buscar en texto completo <kbd>Ctrl+Enter</kbd>"
|
||||
"search_button": "Buscar en texto completo <kbd>Ctrl+Enter</kbd>",
|
||||
"search_placeholder": "Busque nota por su nombre o escriba > para comandos..."
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Importación de Markdown",
|
||||
@@ -442,7 +443,8 @@
|
||||
"other_notes_with_name": "Otras notas con nombre de {{attributeType}} \"{{attributeName}}\"",
|
||||
"and_more": "... y {{count}} más.",
|
||||
"print_landscape": "Al exportar a PDF, cambia la orientación de la página a paisaje en lugar de retrato.",
|
||||
"print_page_size": "Al exportar a PDF, cambia el tamaño de la página. Valores soportados: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>."
|
||||
"print_page_size": "Al exportar a PDF, cambia el tamaño de la página. Valores soportados: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
|
||||
"color_type": "Color"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "Para agregar una etiqueta, simplemente escriba, por ejemplo. <code>#rock</code> o si desea agregar también valor, p.e. <code>#año = 2020</code>",
|
||||
@@ -758,7 +760,11 @@
|
||||
"collapse": "Colapsar",
|
||||
"expand": "Expandir",
|
||||
"invalid_view_type": "Tipo de vista inválida '{{type}}'",
|
||||
"calendar": "Calendario"
|
||||
"calendar": "Calendario",
|
||||
"book_properties": "Propiedades de colección",
|
||||
"table": "Tabla",
|
||||
"geo-map": "Mapa Geo",
|
||||
"board": "Tablero"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "Aún no hay notas editadas en este día...",
|
||||
@@ -835,7 +841,8 @@
|
||||
"unknown_label_type": "Tipo de etiqueta desconocido '{{type}}'",
|
||||
"unknown_attribute_type": "Tipo de atributo desconocido '{{type}}'",
|
||||
"add_new_attribute": "Agregar nuevo atributo",
|
||||
"remove_this_attribute": "Eliminar este atributo"
|
||||
"remove_this_attribute": "Eliminar este atributo",
|
||||
"remove_color": "Eliminar la etiqueta del color"
|
||||
},
|
||||
"script_executor": {
|
||||
"query": "Consulta",
|
||||
@@ -1596,7 +1603,8 @@
|
||||
"import-into-note": "Importar a nota",
|
||||
"apply-bulk-actions": "Aplicar acciones en lote",
|
||||
"converted-to-attachments": "{{count}} notas han sido convertidas en archivos adjuntos.",
|
||||
"convert-to-attachment-confirm": "¿Está seguro que desea convertir las notas seleccionadas en archivos adjuntos de sus notas padres?"
|
||||
"convert-to-attachment-confirm": "¿Está seguro que desea convertir las notas seleccionadas en archivos adjuntos de sus notas padres?",
|
||||
"open-in-popup": "Edición rápida"
|
||||
},
|
||||
"shared_info": {
|
||||
"shared_publicly": "Esta nota está compartida públicamente en",
|
||||
@@ -1623,7 +1631,10 @@
|
||||
"geo-map": "Mapa Geo",
|
||||
"beta-feature": "Beta",
|
||||
"ai-chat": "Chat de IA",
|
||||
"task-list": "Lista de tareas"
|
||||
"task-list": "Lista de tareas",
|
||||
"book": "Colección",
|
||||
"new-feature": "Nuevo",
|
||||
"collections": "Colecciones"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Proteger la nota",
|
||||
@@ -1825,7 +1836,8 @@
|
||||
"link_context_menu": {
|
||||
"open_note_in_new_tab": "Abrir nota en una pestaña nueva",
|
||||
"open_note_in_new_split": "Abrir nota en una nueva división",
|
||||
"open_note_in_new_window": "Abrir nota en una nueva ventana"
|
||||
"open_note_in_new_window": "Abrir nota en una nueva ventana",
|
||||
"open_note_in_popup": "Edición rápida"
|
||||
},
|
||||
"electron_integration": {
|
||||
"desktop-application": "Aplicación de escritorio",
|
||||
@@ -1845,7 +1857,8 @@
|
||||
"full-text-search": "Búsqueda de texto completo"
|
||||
},
|
||||
"note_tooltip": {
|
||||
"note-has-been-deleted": "La nota ha sido eliminada."
|
||||
"note-has-been-deleted": "La nota ha sido eliminada.",
|
||||
"quick-edit": "Edición rápida"
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Crear una nueva subnota y agregarla al mapa",
|
||||
@@ -1854,7 +1867,8 @@
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "Abrir ubicación",
|
||||
"remove-from-map": "Eliminar del mapa"
|
||||
"remove-from-map": "Eliminar del mapa",
|
||||
"add-note": "Agregar un marcador en esta ubicación"
|
||||
},
|
||||
"help-button": {
|
||||
"title": "Abrir la página de ayuda relevante"
|
||||
@@ -1928,7 +1942,13 @@
|
||||
},
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "Ocultar fines de semana",
|
||||
"show-scale": "Mostrar escala"
|
||||
"show-scale": "Mostrar escala",
|
||||
"display-week-numbers": "Mostrar números de semana",
|
||||
"map-style": "Estilo de mapa:",
|
||||
"max-nesting-depth": "Máxima profundidad de anidamiento:",
|
||||
"vector_light": "Vector (claro)",
|
||||
"vector_dark": "Vector (oscuro)",
|
||||
"raster": "Trama"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Eliminar fila"
|
||||
@@ -1939,9 +1959,54 @@
|
||||
"insert-above": "Insertar arriba",
|
||||
"insert-below": "Insertar abajo",
|
||||
"delete-column": "Eliminar columna",
|
||||
"delete-column-confirmation": "¿Seguro que desea eliminar esta columna? El atributo correspondiente también se eliminará de las notas de esta columna."
|
||||
"delete-column-confirmation": "¿Seguro que desea eliminar esta columna? El atributo correspondiente también se eliminará de las notas de esta columna.",
|
||||
"add-column": "Añadir columna",
|
||||
"new-item": "Nuevo elemento"
|
||||
},
|
||||
"content_renderer": {
|
||||
"open_externally": "Abrir externamente"
|
||||
},
|
||||
"table_view": {
|
||||
"new-column": "Nueva columna",
|
||||
"new-row": "Nueva fila",
|
||||
"show-hide-columns": "Mostrar/ocultar columnas",
|
||||
"row-insert-above": "Insertar fila arriba",
|
||||
"row-insert-below": "Insertar fila debajo",
|
||||
"sort-column-by": "Ordenar por \"{{title}}\"",
|
||||
"sort-column-ascending": "Ascendiente",
|
||||
"sort-column-descending": "Descendiente",
|
||||
"sort-column-clear": "Quitar ordenación",
|
||||
"hide-column": "Ocultar columna \"{{title}}\"",
|
||||
"add-column-to-the-left": "Añadir columna a la izquierda",
|
||||
"add-column-to-the-right": "Añadir columna a la derecha",
|
||||
"edit-column": "Editar columna",
|
||||
"delete_column_confirmation": "¿Seguro que desea eliminar esta columna? Se eliminará el atributo asociado de todas las notas.",
|
||||
"new-column-label": "Etiqueta",
|
||||
"new-column-relation": "Relación",
|
||||
"delete-column": "Eliminar columna",
|
||||
"row-insert-child": "Insertar subnota"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"note_completion_enabled": "Activar autocompletado de notas",
|
||||
"emoji_completion_enabled": "Activar autocompletado de emojis",
|
||||
"title": "Funciones"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "Árbol:{{name}}",
|
||||
"export_note_title": "Exportar nota",
|
||||
"export_note_description": "Exportar nota actual",
|
||||
"show_attachments_title": "Mostrar adjuntos",
|
||||
"show_attachments_description": "Ver adjuntos de la nota",
|
||||
"search_notes_title": "Buscar notas",
|
||||
"search_notes_description": "Abrir búsqueda avanzada",
|
||||
"search_subtree_title": "Buscar en subárbol",
|
||||
"search_subtree_description": "Buscar dentro del subárbol actual",
|
||||
"search_history_title": "Mostrar historial de búsqueda",
|
||||
"search_history_description": "Ver búsquedas previas",
|
||||
"configure_launch_bar_title": "Configurar barra de inicio",
|
||||
"configure_launch_bar_description": "Abrir la configuración de la barra de inicio, para agregar o quitar elementos."
|
||||
},
|
||||
"modal": {
|
||||
"close": "Cerrar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1666,5 +1666,8 @@
|
||||
"time_selector": {
|
||||
"invalid_input": "La valeur de l'heure saisie n'est pas un nombre valide.",
|
||||
"minimum_input": "La valeur de temps saisie doit être d'au moins {{minimumSeconds}} secondes."
|
||||
},
|
||||
"multi_factor_authentication": {
|
||||
"oauth_user_email": "Courriel de l'utilisateur : "
|
||||
}
|
||||
}
|
||||
|
||||
379
apps/client/src/translations/it/translation.json
Normal file
379
apps/client/src/translations/it/translation.json
Normal file
@@ -0,0 +1,379 @@
|
||||
{
|
||||
"about": {
|
||||
"close": "Chiudi",
|
||||
"app_version": "Versione dell'app:",
|
||||
"db_version": "Versione DB:",
|
||||
"sync_version": "Versione Sync:",
|
||||
"data_directory": "Cartella dati:",
|
||||
"title": "Informazioni su Trilium Notes",
|
||||
"build_date": "Data della build:",
|
||||
"build_revision": "Revisione della build:",
|
||||
"homepage": "Homepage:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Errore critico",
|
||||
"message": "Si è verificato un errore critico che impedisce l'avvio dell'applicazione client:\n\n{{message}}\n\nQuesto è probabilmente causato da un errore di script inaspettato. Prova a avviare l'applicazione in modo sicuro e controlla il problema."
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Non si è riusciti a caricare uno script personalizzato",
|
||||
"message": "Lo script della nota con ID \"{{id}}\", dal titolo \"{{title}}\" non è stato inizializzato a causa di:\n\n{{message}}"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Impossibile inizializzare un widget",
|
||||
"message-custom": "Il widget personalizzato della nota con ID \"{{id}}\", dal titolo \"{{title}}\" non è stato inizializzato a causa di:\n\n{{message}}",
|
||||
"message-unknown": "Un widget sconosciuto non è stato inizializzato a causa di:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Aggiungi un collegamento",
|
||||
"close": "Chiudi",
|
||||
"note": "Nota",
|
||||
"search_note": "cerca una nota per nome",
|
||||
"link_title_mirrors": "il titolo del collegamento rispecchia il titolo della nota corrente",
|
||||
"link_title_arbitrary": "il titolo del collegamento può essere modificato arbitrariamente",
|
||||
"link_title": "Titolo del collegamento",
|
||||
"button_add_link": "Aggiungi il collegamento <kbd>invio</kbd>",
|
||||
"help_on_links": "Aiuto sui collegamenti"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Modifica il prefisso del ramo",
|
||||
"help_on_tree_prefix": "Aiuto sui prefissi dell'Albero",
|
||||
"close": "Chiudi",
|
||||
"prefix": "Prefisso: ",
|
||||
"save": "Salva",
|
||||
"branch_prefix_saved": "Il prefisso del ramo è stato salvato."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Azioni massive",
|
||||
"close": "Chiudi",
|
||||
"affected_notes": "Note influenzate",
|
||||
"include_descendants": "Includi i discendenti della nota selezionata",
|
||||
"available_actions": "Azioni disponibili",
|
||||
"chosen_actions": "Azioni scelte",
|
||||
"execute_bulk_actions": "Esegui le azioni massive",
|
||||
"bulk_actions_executed": "Le azioni massive sono state eseguite con successo.",
|
||||
"none_yet": "Ancora nessuna... aggiungi una azione cliccando su una di quelle disponibili sopra.",
|
||||
"labels": "Etichette",
|
||||
"relations": "Relazioni",
|
||||
"notes": "Note",
|
||||
"other": "Altro"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Clona note in...",
|
||||
"close": "Chiudi",
|
||||
"help_on_links": "Aiuto sui collegamenti",
|
||||
"notes_to_clone": "Note da clonare",
|
||||
"target_parent_note": "Nodo padre obiettivo",
|
||||
"search_for_note_by_its_name": "cerca una nota per nome",
|
||||
"cloned_note_prefix_title": "Le note clonate saranno mostrate nell'albero delle note con il dato prefisso",
|
||||
"prefix_optional": "Prefisso (opzionale)",
|
||||
"clone_to_selected_note": "Clona sotto la nota selezionata <kbd>invio</kbd>",
|
||||
"no_path_to_clone_to": "Nessun percorso per clonare dentro.",
|
||||
"note_cloned": "La nota \"{{clonedTitle}}\" è stata clonata in \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"close": "Chiudi",
|
||||
"cancel": "Annulla",
|
||||
"ok": "OK",
|
||||
"confirmation": "Conferma",
|
||||
"are_you_sure_remove_note": "Sei sicuro di voler rimuovere la nota \"{{title}}\" dalla mappa delle relazioni? ",
|
||||
"if_you_dont_check": "Se non lo selezioni, la nota sarà rimossa solamente dalla mappa delle relazioni.",
|
||||
"also_delete_note": "Rimuove anche la nota"
|
||||
},
|
||||
"delete_notes": {
|
||||
"ok": "OK",
|
||||
"close": "Chiudi",
|
||||
"delete_notes_preview": "Anteprima di eliminazione delle note",
|
||||
"delete_all_clones_description": "Elimina anche tutti i cloni (può essere disfatto tramite i cambiamenti recenti)",
|
||||
"erase_notes_description": "L'eliminazione normale (soft) marca le note come eliminate e potranno essere recuperate entro un certo lasso di tempo (dalla finestra dei cambiamenti recenti). Selezionando questa opzione le note si elimineranno immediatamente e non sarà possibile recuperarle.",
|
||||
"erase_notes_warning": "Elimina le note in modo permanente (non potrà essere disfatto), compresi tutti i cloni. Ciò forzerà un nuovo caricamento dell'applicazione.",
|
||||
"cancel": "Annulla",
|
||||
"notes_to_be_deleted": "Le seguenti note saranno eliminate ({{- noteCount}})",
|
||||
"no_note_to_delete": "Nessuna nota sarà eliminata (solo i cloni).",
|
||||
"broken_relations_to_be_deleted": "Le seguenti relazioni saranno interrotte ed eliminate ({{- relationCount}})",
|
||||
"deleted_relation_text": "La nota {{- note}} (da eliminare) è referenziata dalla relazione {{- relation}} originata da {{- source}}."
|
||||
},
|
||||
"info": {
|
||||
"okButton": "OK",
|
||||
"closeButton": "Chiudi"
|
||||
},
|
||||
"export": {
|
||||
"close": "Chiudi",
|
||||
"export_note_title": "Esporta la nota",
|
||||
"export_status": "Stato dell'esportazione",
|
||||
"export": "Esporta",
|
||||
"choose_export_type": "Scegli prima il tipo di esportazione, per favore",
|
||||
"export_in_progress": "Esportazione in corso: {{progressCount}}",
|
||||
"export_finished_successfully": "Esportazione terminata con successo.",
|
||||
"format_pdf": "PDF- allo scopo di stampa o esportazione."
|
||||
},
|
||||
"help": {
|
||||
"close": "Chiudi",
|
||||
"fullDocumentation": "Aiuto (la documentazione completa è disponibile <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)"
|
||||
},
|
||||
"import": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"include_note": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"markdown_import": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"move_to": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"password_not_set": {
|
||||
"close": "Chiudi",
|
||||
"body1": "Le note protette sono crittografate utilizzando una password utente, ma la password non è stata ancora impostata.",
|
||||
"body2": "Per proteggere le note, fare clic su <a class=\"open-password-options-button\" href=\"javascript:\">qui</a> per aprire la finestra di dialogo Opzioni e impostare la password."
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Chiudi"
|
||||
},
|
||||
"prompt": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"recent_changes": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"revisions": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"abstract_bulk_action": {
|
||||
"remove_this_search_action": "Rimuovi questa azione di ricerca"
|
||||
},
|
||||
"etapi": {
|
||||
"new_token_title": "Nuovo token ETAPI",
|
||||
"new_token_message": "Inserire il nuovo nome del token"
|
||||
},
|
||||
"electron_integration": {
|
||||
"zoom-factor": "Fattore di ingrandimento",
|
||||
"desktop-application": "Applicazione Desktop"
|
||||
},
|
||||
"note_autocomplete": {
|
||||
"search-for": "Cerca \"{{term}}\"",
|
||||
"create-note": "Crea e collega la nota figlia \"{{term}}\"",
|
||||
"insert-external-link": "Inserisci il collegamento esterno a \"{{term}}\"",
|
||||
"clear-text-field": "Pulisci il campo di testo",
|
||||
"show-recent-notes": "Mostra le note recenti",
|
||||
"full-text-search": "Ricerca full text"
|
||||
},
|
||||
"note_tooltip": {
|
||||
"note-has-been-deleted": "La nota è stata eliminata.",
|
||||
"quick-edit": "Modifica veloce"
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Crea una nota figlia e aggiungila alla mappa",
|
||||
"create-child-note-instruction": "Clicca sulla mappa per creare una nuova nota qui o premi Escape per uscire.",
|
||||
"unable-to-load-map": "Impossibile caricare la mappa."
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "Apri la posizione",
|
||||
"remove-from-map": "Rimuovi dalla mappa",
|
||||
"add-note": "Aggiungi un marcatore in questa posizione"
|
||||
},
|
||||
"debug": {
|
||||
"debug": "Debug"
|
||||
},
|
||||
"database_anonymization": {
|
||||
"light_anonymization": "Anonimizzazione parziale",
|
||||
"title": "Anonimizzazione del Database",
|
||||
"full_anonymization": "Anonimizzazione completa",
|
||||
"full_anonymization_description": "Questa azione creerà una nuova copia del database e lo anonimizzerà (rimuove tutti i contenuti delle note, lasciando solo la struttura e qualche metadato non sensibile) per condividerlo online allo scopo di debugging, senza paura di far trapelare i tuoi dati personali.",
|
||||
"save_fully_anonymized_database": "Salva il database completamente anonimizzato",
|
||||
"light_anonymization_description": "Questa azione creerà una nuova copia del database e lo anonimizzerà in parzialmente — in particolare, solo il contenuto delle note sarà rimosso, ma i titoli e gli attributi rimarranno. Inoltre, note con script personalizzati JS di frontend/backend e widget personalizzati lasciando rimarranno. Ciò mette a disposizione più contesto per il debug dei problemi.",
|
||||
"choose_anonymization": "Puoi decidere da solo se fornire un database completamente o parzialmente anonimizzato. Anche un database completamente anonimizzato è molto utile, sebbene in alcuni casi i database parzialmente anonimizzati possono accelerare il processo di identificazione dei bug e la loro correzione.",
|
||||
"no_anonymized_database_yet": "Nessun database ancora anonimizzato.",
|
||||
"save_lightly_anonymized_database": "Salva il database parzialmente anonimizzato",
|
||||
"successfully_created_fully_anonymized_database": "Database completamente anonimizzato creato in {{anonymizedFilePath}}",
|
||||
"successfully_created_lightly_anonymized_database": "Database parzialmente anonimizzato creato in {{anonymizedFilePath}}"
|
||||
},
|
||||
"cpu_arch_warning": {
|
||||
"title": "Per favore scarica la versione ARM64",
|
||||
"continue_anyway": "Continua Comunque",
|
||||
"dont_show_again": "Non mostrare più questo avviso",
|
||||
"download_link": "Scarica la Versione Nativa"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"title": "Caratteristiche",
|
||||
"emoji_completion_enabled": "Abilita il completamento automatico delle Emoji",
|
||||
"note_completion_enabled": "Abilita il completamento automatico delle note"
|
||||
},
|
||||
"table_view": {
|
||||
"new-row": "Nuova riga",
|
||||
"new-column": "Nuova colonna",
|
||||
"sort-column-by": "Ordina per \"{{title}}\"",
|
||||
"sort-column-ascending": "Ascendente",
|
||||
"sort-column-descending": "Discendente",
|
||||
"sort-column-clear": "Cancella l'ordinamento",
|
||||
"hide-column": "Nascondi la colonna \"{{title}}\"",
|
||||
"show-hide-columns": "Mostra/nascondi le colonne",
|
||||
"row-insert-above": "Inserisci una riga sopra",
|
||||
"row-insert-below": "Inserisci una riga sotto"
|
||||
},
|
||||
"abstract_search_option": {
|
||||
"remove_this_search_option": "Rimuovi questa opzione di ricerca",
|
||||
"failed_rendering": "Opzione di ricerca di rendering non riuscita: {{dto}} con errore: {{error}} {{stack}}"
|
||||
},
|
||||
"ancestor": {
|
||||
"label": "Antenato"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Aggiungi etichetta",
|
||||
"label_name_placeholder": "nome dell'etichetta",
|
||||
"new_value_placeholder": "nuovo valore",
|
||||
"to_value": "al valore"
|
||||
},
|
||||
"update_label_value": {
|
||||
"to_value": "al valore",
|
||||
"label_name_placeholder": "nome dell'etichetta"
|
||||
},
|
||||
"delete_label": {
|
||||
"delete_label": "Elimina etichetta",
|
||||
"label_name_placeholder": "nome dell'etichetta",
|
||||
"label_name_title": "Sono ammessi i caratteri alfanumerici, il carattere di sottolineato e i due punti."
|
||||
},
|
||||
"tree-context-menu": {
|
||||
"move-to": "Muovi in...",
|
||||
"cut": "Taglia"
|
||||
},
|
||||
"electron_context_menu": {
|
||||
"cut": "Taglia",
|
||||
"copy": "Copia",
|
||||
"paste": "Incolla",
|
||||
"copy-link": "Copia collegamento",
|
||||
"paste-as-plain-text": "Incolla come testo semplice"
|
||||
},
|
||||
"editing": {
|
||||
"editor_type": {
|
||||
"multiline-toolbar": "Mostra la barra degli strumenti su più linee se non entra."
|
||||
}
|
||||
},
|
||||
"edit_button": {
|
||||
"edit_this_note": "Modifica questa nota"
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Scorciatoie"
|
||||
},
|
||||
"shared_switch": {
|
||||
"toggle-on-title": "Condividi la nota",
|
||||
"toggle-off-title": "Non condividere la nota"
|
||||
},
|
||||
"search_string": {
|
||||
"search_prefix": "Cerca:"
|
||||
},
|
||||
"attachment_detail": {
|
||||
"open_help_page": "Apri la pagina di aiuto sugli allegati"
|
||||
},
|
||||
"search_definition": {
|
||||
"ancestor": "antenato",
|
||||
"debug": "debug",
|
||||
"action": "azione",
|
||||
"add_search_option": "Aggiungi un opzione di ricerca:",
|
||||
"search_string": "cerca la stringa",
|
||||
"limit": "limite"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"board_view": {
|
||||
"insert-below": "Inserisci sotto",
|
||||
"delete-column": "Elimina la colonna",
|
||||
"delete-column-confirmation": "Sei sicuro di vole eliminare questa colonna? Il corrispondente attributo sarà eliminato anche nelle note sotto questa colonna."
|
||||
},
|
||||
"backup": {
|
||||
"enable_weekly_backup": "Abilita le archiviazioni settimanali",
|
||||
"enable_monthly_backup": "Abilita le archiviazioni mensili",
|
||||
"backup_recommendation": "Si raccomanda di mantenere attive le archiviazioni, sebbene ciò possa rendere l'avvio dell'applicazione lento con database grandi e/o dispositivi di archiviazione lenti.",
|
||||
"backup_now": "Archivia adesso",
|
||||
"backup_database_now": "Archivia il database adesso",
|
||||
"existing_backups": "Backup esistenti",
|
||||
"date-and-time": "Data e ora",
|
||||
"path": "Percorso",
|
||||
"database_backed_up_to": "Il database è stato archiviato in {{backupFilePath}}",
|
||||
"enable_daily_backup": "Abilita le archiviazioni giornaliere",
|
||||
"no_backup_yet": "Ancora nessuna archiviazione"
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Aggiorna"
|
||||
},
|
||||
"consistency_checks": {
|
||||
"find_and_fix_button": "Trova e correggi i problemi di coerenza",
|
||||
"finding_and_fixing_message": "In cerca e correzione dei problemi di coerenza...",
|
||||
"issues_fixed_message": "Qualsiasi problema di coerenza che possa essere stato trovato ora è corretto."
|
||||
},
|
||||
"database_integrity_check": {
|
||||
"check_button": "Controllo dell'integrità del database",
|
||||
"checking_integrity": "Controllo dell'integrità del database in corso...",
|
||||
"title": "Controllo di Integrità del database",
|
||||
"description": "Controllerà che il database non sia corrotto a livello SQLite. Può durare un po' di tempo, a seconda della grandezza del DB.",
|
||||
"integrity_check_failed": "Controllo di integrità fallito: {{results}}"
|
||||
},
|
||||
"sync": {
|
||||
"title": "Sincronizza",
|
||||
"force_full_sync_button": "Forza una sincronizzazione completa",
|
||||
"failed": "Sincronizzazione fallita: {{message}}"
|
||||
},
|
||||
"sync_2": {
|
||||
"config_title": "Configurazione per la Sincronizzazione",
|
||||
"proxy_label": "Server Proxy per la sincronizzazione (opzionale)",
|
||||
"test_title": "Test di sincronizzazione",
|
||||
"timeout": "Timeout per la sincronizzazione",
|
||||
"timeout_unit": "millisecondi",
|
||||
"save": "Salva",
|
||||
"help": "Aiuto"
|
||||
},
|
||||
"search_engine": {
|
||||
"save_button": "Salva"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
},
|
||||
"tab_row": {
|
||||
"close_tab": "Chiudi la scheda",
|
||||
"add_new_tab": "Aggiungi una nuova scheda",
|
||||
"close": "Chiudi",
|
||||
"close_other_tabs": "Chiudi le altre schede",
|
||||
"close_right_tabs": "Chiudi le schede a destra",
|
||||
"close_all_tabs": "Chiudi tutte le schede",
|
||||
"reopen_last_tab": "Riapri l'ultima scheda chiusa",
|
||||
"move_tab_to_new_window": "Sposta questa scheda in una nuova finestra",
|
||||
"copy_tab_to_new_window": "Copia questa scheda in una nuova finestra",
|
||||
"new_tab": "Nuova scheda"
|
||||
},
|
||||
"toc": {
|
||||
"table_of_contents": "Sommario"
|
||||
},
|
||||
"table_of_contents": {
|
||||
"title": "Sommario"
|
||||
},
|
||||
"tray": {
|
||||
"title": "Vassoio di Sistema",
|
||||
"enable_tray": "Abilita il vassoio (Trilium necessita di essere riavviato affinché la modifica abbia effetto)"
|
||||
},
|
||||
"heading_style": {
|
||||
"title": "Stile dell'Intestazione",
|
||||
"plain": "Normale",
|
||||
"underline": "Sottolineato",
|
||||
"markdown": "Stile Markdown"
|
||||
},
|
||||
"highlights_list": {
|
||||
"title": "Punti salienti"
|
||||
},
|
||||
"highlights_list_2": {
|
||||
"title": "Punti salienti",
|
||||
"options": "Opzioni"
|
||||
},
|
||||
"quick-search": {
|
||||
"placeholder": "Ricerca rapida",
|
||||
"searching": "Ricerca in corso..."
|
||||
}
|
||||
}
|
||||
23
apps/client/src/translations/ja/translation.json
Normal file
23
apps/client/src/translations/ja/translation.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Trilium Notesについて",
|
||||
"close": "閉じる",
|
||||
"homepage": "ホームページ:",
|
||||
"app_version": "アプリのヴァージョン:",
|
||||
"db_version": "データベースのヴァージョン:",
|
||||
"sync_version": "同期のヴァージョン:",
|
||||
"build_date": "Build の日時:",
|
||||
"build_revision": "Build のヴァージョン:",
|
||||
"data_directory": "データの場所:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "致命的なエラー",
|
||||
"message": "致命的なエラーのせいでアプリをスタートできません:\n\n{{message}}\n\nおそらくスクリプトが予期しないバグを含んでいると思われます。アプリをセーフモードでスタートしてみて下さい。"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "ウィジェットを初期化できませんでした",
|
||||
"message-custom": "ノートID”{{id}}”, ノートタイトル “{{title}}” のカスタムウィジェットを初期化できませんでした:\n\n{{message}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,414 @@
|
||||
"theme_none": "Sem destaque de sintaxe",
|
||||
"theme_group_light": "Temas claros",
|
||||
"theme_group_dark": "Temas escuros"
|
||||
},
|
||||
"about": {
|
||||
"title": "Sobre o Trilium Notes",
|
||||
"close": "Fechar",
|
||||
"homepage": "Página inicial:",
|
||||
"app_version": "Versão do App:",
|
||||
"db_version": "Versão do db:",
|
||||
"sync_version": "Versão de sincronização:",
|
||||
"build_date": "Data de compilação:",
|
||||
"build_revision": "Revisão da compilação:",
|
||||
"data_directory": "Diretório de dados:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Erro crítico",
|
||||
"message": "Ocorreu um erro crítico que impede a inicialização do aplicativo cliente:\n\n{{message}}\n\nIsso provavelmente foi causado por um script que falhou de maneira inesperada. Tente iniciar o aplicativo no modo seguro e resolva o problema."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Falha ao inicializar um widget",
|
||||
"message-custom": "O widget personalizado da nota com ID \"{{id}}\", intitulada \"{{title}}\", não pôde ser inicializado devido a:\n\n{{message}}",
|
||||
"message-unknown": "Widget desconhecido não pôde ser inicializado devido a:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Falha para carregar o script customizado",
|
||||
"message": "O script da nota com ID \"{{id}}\", intitulada \"{{title}}\", não pôde ser executado devido a:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Adicionar link",
|
||||
"help_on_links": "Ajuda sobre links",
|
||||
"close": "Fechar",
|
||||
"note": "Nota",
|
||||
"search_note": "pesquisar nota pelo nome",
|
||||
"link_title_mirrors": "o título do link reflete o título atual da nota",
|
||||
"link_title_arbitrary": "o título do link pode ser alterado livremente",
|
||||
"link_title": "Titulo do link",
|
||||
"button_add_link": "Adicionar link <kbd>enter</kbd>"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"close": "Fechar",
|
||||
"prefix": "Prefixo: ",
|
||||
"save": "Salvar",
|
||||
"edit_branch_prefix": "Editar Prefixo do Branch",
|
||||
"help_on_tree_prefix": "Ajuda sobre o prefixo da árvore de notas",
|
||||
"branch_prefix_saved": "O prefixo de ramificação foi salvo."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Ações em massa",
|
||||
"close": "Fechar",
|
||||
"affected_notes": "Notas Afetadas",
|
||||
"include_descendants": "Incluir notas filhas das notas selecionadas",
|
||||
"available_actions": "Ações disponíveis",
|
||||
"chosen_actions": "Ações selecionadas",
|
||||
"execute_bulk_actions": "Executar ações em massa",
|
||||
"bulk_actions_executed": "As ações em massa foram concluídas com sucesso.",
|
||||
"none_yet": "Nenhuma até agora... adicione uma ação clicando em uma das disponíveis acima.",
|
||||
"labels": "Etiquetas",
|
||||
"relations": "Relações",
|
||||
"notes": "Notas",
|
||||
"other": "Outros"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Clonar notas para...",
|
||||
"close": "Fechar",
|
||||
"help_on_links": "Ajuda sobre links",
|
||||
"notes_to_clone": "Notas para clonar",
|
||||
"search_for_note_by_its_name": "pesquisar nota pelo nome",
|
||||
"cloned_note_prefix_title": "A nota clonada aparecerá na árvore de notas com o prefixo fornecido",
|
||||
"prefix_optional": "Prefixo (opcional)",
|
||||
"no_path_to_clone_to": "Nenhum caminho para clonar.",
|
||||
"target_parent_note": "Nota pai-alvo",
|
||||
"clone_to_selected_note": "Clonar para a nota selecionada <kbd>enter</kbd>",
|
||||
"note_cloned": "A nota \"{{clonedTitle}}\" foi clonada para \"{{targetTitle}}\""
|
||||
},
|
||||
"ai_llm": {
|
||||
"n_notes_queued_0": "{{ count }} nota enfileirada para indexação",
|
||||
"n_notes_queued_1": "{{ count }} notas enfileiradas para indexação",
|
||||
"n_notes_queued_2": "{{ count }} notas enfileiradas para indexação",
|
||||
"notes_indexed_0": "{{ count }} nota indexada",
|
||||
"notes_indexed_1": "{{ count }} notas indexadas",
|
||||
"notes_indexed_2": "{{ count }} notas indexadas"
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Confirmação",
|
||||
"close": "Fechar",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "OK",
|
||||
"are_you_sure_remove_note": "Tem certeza de que deseja remover a nota '{{title}}' do mapa de relações? ",
|
||||
"if_you_dont_check": "Se você não marcar isso, a nota será removida apenas do mapa de relações.",
|
||||
"also_delete_note": "Também excluir a nota"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_notes_preview": "Excluir pré-visualização de notas",
|
||||
"close": "Fechar",
|
||||
"delete_all_clones_description": "Excluir também todos os clones (pode ser desfeito em alterações recentes)",
|
||||
"erase_notes_description": "A exclusão normal (suave) apenas marca as notas como excluídas, permitindo que sejam recuperadas (no diálogo de alterações recentes) dentro de um período de tempo. Se esta opção for marcada, as notas serão apagadas imediatamente e não será possível restaurá-las.",
|
||||
"erase_notes_warning": "Apagar notas permanentemente (não pode ser desfeito), incluindo todos os clones. Isso forçará o recarregamento do aplicativo.",
|
||||
"notes_to_be_deleted": "As seguintes notas serão excluídas ({{- noteCount}})",
|
||||
"no_note_to_delete": "Nenhuma nota será excluída (apenas os clones).",
|
||||
"broken_relations_to_be_deleted": "As seguintes relações serão quebradas e excluídas ({{- relationCount}})",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "OK",
|
||||
"deleted_relation_text": "A nota {{- note}} (a ser excluída) está referenciada pela relação {{- relation}} originada de {{- source}}."
|
||||
},
|
||||
"export": {
|
||||
"export_note_title": "Exportar nota",
|
||||
"close": "Fechar",
|
||||
"export_type_subtree": "Esta nota e todos os seus descendentes",
|
||||
"format_html": "HTML – recomendado, pois mantém toda a formatação",
|
||||
"format_html_zip": "HTML em arquivo ZIP – recomendado, pois isso preserva toda a formatação.",
|
||||
"format_markdown": "Markdown – isso preserva a maior parte da formatação.",
|
||||
"format_opml": "OPML - formato de intercâmbio de outliners apenas para texto. Formatação, imagens e arquivos não estão incluídos.",
|
||||
"opml_version_1": "OPML v1.0 – apenas texto simples",
|
||||
"opml_version_2": "OPML v2.0 – permite também HTML",
|
||||
"export_type_single": "Apenas esta nota, sem seus descendentes",
|
||||
"export": "Exportar",
|
||||
"choose_export_type": "Por favor, escolha primeiro o tipo de exportação",
|
||||
"export_status": "Status da exportação",
|
||||
"export_in_progress": "Exportação em andamento: {{progressCount}}",
|
||||
"export_finished_successfully": "Exportação concluída com sucesso.",
|
||||
"format_pdf": "PDF – para impressão ou compartilhamento."
|
||||
},
|
||||
"help": {
|
||||
"fullDocumentation": "Ajuda (a documentação completa está disponível <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
|
||||
"close": "Fechar",
|
||||
"noteNavigation": "Navegação de notas",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> – subir/descer na lista de notas",
|
||||
"collapseExpand": "<kbd>ESQUERDA</kbd>, <kbd>DIREITA</kbd> – recolher/expandir nó",
|
||||
"notSet": "não definido",
|
||||
"goBackForwards": "voltar / avançar no histórico",
|
||||
"showJumpToNoteDialog": "mostrar diálogo <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Ir para\"</a>",
|
||||
"scrollToActiveNote": "rolar até a nota atual",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> – ir para a nota pai",
|
||||
"collapseWholeTree": "recolher toda a árvore de notas",
|
||||
"collapseSubTree": "recolher subárvore",
|
||||
"tabShortcuts": "Atalhos de abas",
|
||||
"newTabNoteLink": "<kbd>Ctrl+clique</kbd> – (ou <kbd>clique com o botão do meio do mouse</kbd>) em um link de nota abre a nota em uma nova aba",
|
||||
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+clique</kbd> – (ou <kbd>Shift+clique com o botão do meio do mouse</kbd>) em um link de nota abre e ativa a nota em uma nova aba",
|
||||
"onlyInDesktop": "Apenas na versão para desktop (compilação Electron)",
|
||||
"openEmptyTab": "abrir aba vazia",
|
||||
"closeActiveTab": "fechar aba ativa",
|
||||
"activateNextTab": "ativar próxima aba",
|
||||
"activatePreviousTab": "ativar aba anterior",
|
||||
"creatingNotes": "Criando notas",
|
||||
"createNoteAfter": "criar nova nota após a nota atual",
|
||||
"createNoteInto": "criar nova subnota dentro da nota atual",
|
||||
"editBranchPrefix": "editar <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">prefixo</a> do clone da nota ativa",
|
||||
"movingCloningNotes": "Movendo / clonando notas",
|
||||
"moveNoteUpDown": "mover nota para cima/baixo na lista de notas",
|
||||
"moveNoteUpHierarchy": "mover nota para cima na hierarquia",
|
||||
"multiSelectNote": "selecionar múltiplas notas acima/abaixo",
|
||||
"selectAllNotes": "selecionar todas as notas no nível atual",
|
||||
"selectNote": "<kbd>Shift+clique</kbd> - selecionar nota",
|
||||
"copyNotes": "copiar nota ativa (ou seleção atual) para a área de transferência (usado para <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">clonar</a>)",
|
||||
"cutNotes": "recortar nota atual (ou seleção atual) para a área de transferência (usado para mover notas)",
|
||||
"pasteNotes": "colar nota(s) como subnota dentro da nota ativa (o que pode ser mover ou clonar dependendo se foi copiado ou recortado para a área de transferência)",
|
||||
"deleteNotes": "excluir nota / subárvore",
|
||||
"editingNotes": "Edição de notas",
|
||||
"editNoteTitle": "no painel de árvore, a navegação mudará do painel de árvore para o título da nota. Pressionar Enter no título da nota mudará o foco para o editor de texto. <kbd>Ctrl+.</kbd> mudará o foco de volta do editor para o painel de árvore.",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - criar / editar link externo",
|
||||
"createInternalLink": "criar link interno",
|
||||
"followLink": "seguir link sob o cursor",
|
||||
"insertDateTime": "inserir data e hora atual na posição do cursor",
|
||||
"jumpToTreePane": "ir para a árvore de notas e rolar até a nota ativa",
|
||||
"markdownAutoformat": "Autoformatação estilo Markdown",
|
||||
"headings": "<code>##</code>, <code>###</code>, <code>####</code> etc. seguidos de espaço para títulos",
|
||||
"bulletList": "<code>*</code> ou <code>-</code> seguidos de espaço para lista com marcadores",
|
||||
"numberedList": "<code>1.</code> ou <code>1)</code> seguidos de espaço para lista numerada",
|
||||
"blockQuote": "comece uma linha com <code>></code> seguido de espaço para citação em bloco",
|
||||
"troubleshooting": "Solução de problemas",
|
||||
"reloadFrontend": "recarregar o frontend do Trilium",
|
||||
"showDevTools": "mostrar ferramentas de desenvolvedor",
|
||||
"showSQLConsole": "mostrar console SQL",
|
||||
"other": "Outros",
|
||||
"quickSearch": "focar no campo de pesquisa rápida",
|
||||
"inPageSearch": "pesquisa na página"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Importar para a nota",
|
||||
"close": "Fechar",
|
||||
"chooseImportFile": "Escolher arquivo para importar",
|
||||
"importDescription": "O conteúdo do(s) arquivo(s) selecionado(s) será importado como nota(s) filha(s) em",
|
||||
"options": "Opções",
|
||||
"safeImportTooltip": "Arquivos de exportação Trilium<code> .zip</code> podem conter scripts executáveis que podem apresentar comportamentos prejudiciais. A importação segura desativará a execução automática de todos os scripts importados. Desmarque “Importação segura” apenas se o arquivo de importação contiver scripts executáveis e você confiar totalmente no conteúdo do arquivo importado.",
|
||||
"safeImport": "Importação segura",
|
||||
"explodeArchivesTooltip": "Se esta opção estiver marcada, o Trilium irá ler arquivos <code>.zip</code>, <code>.enex</code> e <code>.opml</code> e criar notas a partir dos arquivos contidos nesses arquivos compactados. Se estiver desmarcada, o Trilium irá anexar os próprios arquivos compactados à nota.",
|
||||
"explodeArchives": "Ler conteúdos de arquivos <code>.zip</code>, <code>.enex</code> e <code>.opml</code>.",
|
||||
"shrinkImagesTooltip": "<p>Se você marcar esta opção, o Trilium tentará reduzir o tamanho das imagens importadas por meio de escala e otimização, o que pode afetar a qualidade visual das imagens. Se desmarcada, as imagens serão importadas sem alterações.</p><p>Isso não se aplica a importações de arquivos <code>.zip</code> com metadados, pois presume-se que esses arquivos já estejam otimizados.</p>",
|
||||
"shrinkImages": "Reduzir imagens",
|
||||
"textImportedAsText": "Importar arquivos HTML, Markdown e TXT como notas de texto caso não esteja claro pelos metadados",
|
||||
"codeImportedAsCode": "Importar arquivos de código reconhecidos (por exemplo, <code>.json</code>) como notas de código caso não esteja claro pelos metadados",
|
||||
"replaceUnderscoresWithSpaces": "Substituir sublinhados por espaços nos nomes das notas importadas",
|
||||
"import": "Importar",
|
||||
"failed": "Falha na importação: {{message}}.",
|
||||
"html_import_tags": {
|
||||
"title": "Tags de importação HTML",
|
||||
"description": "Configurar quais tags HTML devem ser preservadas ao importar notas. As tags que não estiverem nesta lista serão removidas durante a importação. Algumas tags (como 'script') são sempre removidas por motivos de segurança.",
|
||||
"placeholder": "Digite as tags HTML, uma por linha",
|
||||
"reset_button": "Redefinir para lista padrão"
|
||||
},
|
||||
"import-status": "Status da importação",
|
||||
"in-progress": "Importação em andamento: {{progress}}",
|
||||
"successful": "Importação concluída com sucesso."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Incluir nota",
|
||||
"close": "Fechar",
|
||||
"label_note": "Nota",
|
||||
"placeholder_search": "pesquisar nota pelo nome",
|
||||
"box_size_prompt": "Dimensão da caixa da nota incluída:",
|
||||
"box_size_small": "pequeno (~ 10 linhas)",
|
||||
"box_size_medium": "médio (~ 30 linhas)",
|
||||
"box_size_full": "completo (a caixa exibe o texto completo)",
|
||||
"button_include": "Incluir nota <kbd>enter</kbd>"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Mensagem informativa",
|
||||
"closeButton": "Fechar",
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "Pesquise uma nota pelo nome ou digite > para comandos...",
|
||||
"close": "Fechar",
|
||||
"search_button": "Pesquisar em texto completo <kbd>Ctrl+Enter</kbd>"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Importar Markdown",
|
||||
"close": "Fechar",
|
||||
"modal_body_text": "Por motivos de segurança (sandbox do navegador), o JavaScript não pode acessar diretamente a área de transferência. Por favor, cole o conteúdo Markdown na área de texto abaixo e clique em Importar",
|
||||
"import_button": "Importar Ctrl+Enter",
|
||||
"import_success": "O conteúdo Markdown foi importado para o documento."
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Mover notas para...",
|
||||
"close": "Fechar",
|
||||
"notes_to_move": "Notas para mover",
|
||||
"target_parent_note": "Nota pai-alvo",
|
||||
"search_placeholder": "pesquisar nota pelo nome",
|
||||
"move_button": "Mover para a nota selecionada <kbd>enter</kbd>",
|
||||
"error_no_path": "Nenhum caminho para mover.",
|
||||
"move_success_message": "As notas selecionadas foram movidas para "
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"change_path_prompt": "Alterar onde criar a nova nota:",
|
||||
"search_placeholder": "buscar caminho pelo nome (valor padrão se não for preenchido)",
|
||||
"modal_title": "Escolher tipo de nota",
|
||||
"close": "Fechar",
|
||||
"modal_body": "Escolha o tipo/modelo da nova nota:",
|
||||
"templates": "Modelos:"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "A senha não está definida",
|
||||
"close": "Fechar",
|
||||
"body1": "Notas protegidas são criptografadas usando uma senha do usuário, mas a senha ainda não foi definida.",
|
||||
"body2": "Para poder proteger notas, clique <a class=\"open-password-options-button\" href=\"javascript:\">aqui</a> para abrir a caixa de diálogo de Opções e definir sua senha."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Prompt",
|
||||
"close": "Fechar",
|
||||
"ok": "OK <kbd>enter</kbd>",
|
||||
"defaultTitle": "Prompt"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"modal_title": "Sessão Protegida",
|
||||
"help_title": "Ajuda sobre notas protegidas",
|
||||
"close_label": "Fechar",
|
||||
"form_label": "Para prosseguir com a ação solicitada, você precisa iniciar uma sessão protegida digitando a senha:",
|
||||
"start_button": "Iniciar sessão protegida <kbd>enter</kbd>"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Alterações recentes",
|
||||
"erase_notes_button": "Remover permanentemente as notas excluídas agora",
|
||||
"close": "Fechar",
|
||||
"deleted_notes_message": "As notas excluídas foram removidas permanentemente.",
|
||||
"no_changes_message": "Nenhuma alteração ainda...",
|
||||
"undelete_link": "Restaurar",
|
||||
"confirm_undelete": "Você deseja restaurar esta nota e suas subnotas?"
|
||||
},
|
||||
"revisions": {
|
||||
"note_revisions": "Versões da nota",
|
||||
"delete_all_revisions": "Excluir todas as versões desta nota",
|
||||
"delete_all_button": "Excluir todas as versões",
|
||||
"help_title": "Ajuda sobre as versões da nota",
|
||||
"close": "Fechar",
|
||||
"revision_last_edited": "Esta versão foi editada pela última vez em {{date}}",
|
||||
"confirm_delete_all": "Você quer excluir todas as versões desta nota?",
|
||||
"no_revisions": "Ainda não há versões para esta nota...",
|
||||
"restore_button": "Recuperar",
|
||||
"confirm_restore": "Deseja restaurar esta versão? Isso irá substituir o título e o conteúdo atuais da nota por esta versão.",
|
||||
"delete_button": "Excluir",
|
||||
"confirm_delete": "Deseja excluir esta versão?",
|
||||
"revisions_deleted": "As versões da nota foram removidas.",
|
||||
"revision_restored": "A versão da nota foi restaurada.",
|
||||
"revision_deleted": "A versão da nota foi excluída.",
|
||||
"snapshot_interval": "Intervalo de captura das versões da nota: {{seconds}}s.",
|
||||
"maximum_revisions": "Limite de capturas das versões da nota: {{number}}.",
|
||||
"settings": "Configurações de versões da nota",
|
||||
"download_button": "Download",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "Tamanho do arquivo:",
|
||||
"preview": "Visualizar:",
|
||||
"preview_not_available": "A visualização não está disponível para este tipo de nota."
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"sort_children_by": "Ordenar notas filhas por...",
|
||||
"close": "Fechar",
|
||||
"sorting_criteria": "Critérios de ordenação",
|
||||
"title": "título",
|
||||
"date_created": "data de criação",
|
||||
"date_modified": "data de modificação",
|
||||
"sorting_direction": "Direção de ordenação",
|
||||
"ascending": "crescente",
|
||||
"descending": "decrescente",
|
||||
"folders": "Pastas",
|
||||
"sort_folders_at_top": "colocar pastas no topo",
|
||||
"natural_sort": "Ordenação Natural",
|
||||
"sort_with_respect_to_different_character_sorting": "classificar de acordo com diferentes regras de ordenação de caracteres e colação em diferentes idiomas ou regiões.",
|
||||
"natural_sort_language": "Linguagem da ordenação natural",
|
||||
"the_language_code_for_natural_sort": "O código do idioma para ordenação natural, por exemplo, \"zh-CN\" para chinês.",
|
||||
"sort": "Ordenar <kbd>enter</kbd>"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Enviar anexos para a nota",
|
||||
"close": "Fechar",
|
||||
"choose_files": "Escolher arquivos",
|
||||
"files_will_be_uploaded": "Os arquivos serão enviados como anexos para",
|
||||
"options": "Opções",
|
||||
"shrink_images": "Reduzir imagens",
|
||||
"upload": "Enviar",
|
||||
"tooltip": "Se você marcar esta opção, o Trilium tentará reduzir as imagens enviadas redimensionando e otimizando, o que pode afetar a qualidade visual percebida. Se desmarcada, as imagens serão enviadas sem alterações."
|
||||
},
|
||||
"attribute_detail": {
|
||||
"attr_detail_title": "Título Detalhado do Atributo",
|
||||
"close_button_title": "Cancelar alterações e fechar",
|
||||
"attr_is_owned_by": "O atributo pertence a",
|
||||
"attr_name_title": "O nome do atributo pode ser composto apenas por caracteres alfanuméricos, dois-pontos e sublinhado",
|
||||
"name": "Nome",
|
||||
"value": "Valor",
|
||||
"target_note_title": "Relação é uma conexão nomeada entre a nota de origem e a nota de destino.",
|
||||
"target_note": "Nota de destino",
|
||||
"promoted_title": "O atributo promovido é exibido de forma destacada na nota.",
|
||||
"promoted": "Promovido",
|
||||
"promoted_alias_title": "O nome a ser exibido na interface de atributos promovidos.",
|
||||
"promoted_alias": "Alias",
|
||||
"multiplicity_title": "Multiplicidade define quantos atributos com o mesmo nome podem ser criados — no máximo 1 ou mais de 1.",
|
||||
"multiplicity": "Multiplicidade",
|
||||
"single_value": "Valor único",
|
||||
"multi_value": "Valor múltiplo",
|
||||
"label_type_title": "O tipo do rótulo ajudará o Trilium a escolher a interface adequada para inserir o valor do rótulo.",
|
||||
"label_type": "Tipo",
|
||||
"text": "Texto",
|
||||
"number": "Número",
|
||||
"boolean": "Booleano",
|
||||
"date": "Data",
|
||||
"date_time": "Data e Hora",
|
||||
"time": "Hora",
|
||||
"url": "URL",
|
||||
"precision_title": "Qual número de dígitos após o ponto decimal deve estar disponível na interface de configuração de valor.",
|
||||
"precision": "Precisão",
|
||||
"digits": "dígitos",
|
||||
"inverse_relation_title": "Configuração opcional para definir a qual relação esta é oposta. Exemplo: Pai - Filho são relações inversas entre si.",
|
||||
"inverse_relation": "Relação inversa",
|
||||
"inheritable_title": "O atributo herdável será transmitido para todos os descendentes deste ramo.",
|
||||
"inheritable": "Herdável",
|
||||
"save_and_close": "Salvar e fechar <kbd>Ctrl+Enter</kbd>",
|
||||
"delete": "Excluir",
|
||||
"related_notes_title": "Outras notas com este rótulo",
|
||||
"more_notes": "Mais notas",
|
||||
"label": "Detalhe do rótulo",
|
||||
"label_definition": "Detalhe da definição do rótulo",
|
||||
"relation": "Detalhe da relação",
|
||||
"relation_definition": "Detalhe da definição da relação",
|
||||
"disable_versioning": "desativa a versão automática. Útil, por exemplo, para notas grandes, mas sem importância – como grandes bibliotecas JS usadas para scripts",
|
||||
"calendar_root": "marca a nota que deve ser usada como raiz para notas diárias. Apenas uma deve ser marcada assim.",
|
||||
"archived": "notas com este rótulo não serão exibidas por padrão nos resultados de busca (também nos diálogos Ir para, Adicionar link, etc).",
|
||||
"exclude_from_export": "notas (junto com sua subárvore) não serão incluídas em nenhuma exportação de notas",
|
||||
"run": "define em quais eventos o script deve ser executado. Os valores possíveis são:\n<ul>\n<li>frontendStartup - quando o frontend do Trilium inicia (ou é atualizado), mas não no celular.</li>\n<li>mobileStartup - quando o frontend do Trilium inicia (ou é atualizado), no celular.</li>\n<li>backendStartup - quando o backend do Trilium inicia</li>\n<li>hourly - executa uma vez por hora. Você pode usar o rótulo adicional <code>runAtHour</code> para especificar em qual hora.</li>\n<li>daily - executa uma vez por dia</li>\n</ul>",
|
||||
"run_on_instance": "Define em qual instância do Trilium isso deve ser executado. Por padrão, todas as instâncias.",
|
||||
"run_at_hour": "Em qual hora isso deve ser executado. Deve ser usado junto com <code>#run=hourly</code>. Pode ser definido várias vezes para executar mais de uma vez ao dia.",
|
||||
"disable_inclusion": "scripts com este rótulo não serão incluídos na execução do script pai.",
|
||||
"sorted": "mantém as notas filhas ordenadas alfabeticamente pelo título",
|
||||
"sort_direction": "ASC (padrão) ou DESC",
|
||||
"sort_folders_first": "Pastas (notas com filhos) devem ser ordenadas no topo",
|
||||
"top": "mantenha a nota fornecida no topo em seu pai (aplica-se apenas a pais ordenados)",
|
||||
"hide_promoted_attributes": "Ocultar atributos promovidos nesta nota",
|
||||
"read_only": "o editor está em modo somente leitura. Funciona apenas para notas de texto e código.",
|
||||
"auto_read_only_disabled": "notas de texto/código podem ser automaticamente configuradas para modo de leitura quando são muito grandes. Você pode desabilitar esse comportamento por nota adicionando este rótulo à nota",
|
||||
"app_css": "marca notas CSS que são carregadas no aplicativo Trilium e, portanto, podem ser usadas para modificar a aparência do Trilium.",
|
||||
"app_theme": "marca notas CSS que são temas completos do Trilium e, portanto, estão disponíveis nas opções do Trilium.",
|
||||
"app_theme_base": "defina como \"next\", \"next-light\" ou \"next-dark\" para usar o tema TriliumNext correspondente (auto, claro ou escuro) como base para um tema personalizado, em vez do tema legado.",
|
||||
"css_class": "o valor deste rótulo é então adicionado como classe CSS ao nó que representa a nota específica na árvore. Isso pode ser útil para temas avançados. Pode ser usado em notas de modelo.",
|
||||
"icon_class": "o valor deste rótulo é adicionado como uma classe CSS ao ícone na árvore, o que pode ajudar a distinguir visualmente as notas na árvore. Um exemplo pode ser bx bx-home – os ícones são retirados do boxicons. Pode ser usado em notas de modelo.",
|
||||
"page_size": "número de itens por página na listagem de notas",
|
||||
"custom_request_handler": "veja <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Manipulador de requisição personalizada</a>",
|
||||
"custom_resource_provider": "veja <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Manipulador de requisição personalizada</a>",
|
||||
"widget": "marca esta nota como um widget personalizado que será adicionado à árvore de componentes do Trilium",
|
||||
"workspace": "marca esta nota como um espaço de trabalho, o que permite fácil hoisting",
|
||||
"workspace_icon_class": "define a classe CSS do ícone box que será usada na aba quando esta nota for hoisted",
|
||||
"workspace_tab_background_color": "cor CSS usada na aba da nota quando esta nota é hoisted",
|
||||
"workspace_calendar_root": "Define a raiz do calendário por espaço de trabalho",
|
||||
"workspace_template": "Esta nota aparecerá na seleção de modelos disponíveis ao criar uma nova nota, mas apenas quando estiver destacada em um espaço de trabalho que contenha este modelo",
|
||||
"search_home": "novas notas de pesquisa serão criadas como filhas desta nota",
|
||||
"workspace_search_home": "novas notas de pesquisa serão criadas como filhas desta nota quando ela for destacada para algum ancestral desta nota de área de trabalho",
|
||||
"inbox": "localização padrão da caixa de entrada para novas notas – quando você cria uma nota usando o botão \"nova nota\" na barra lateral, as notas serão criadas como notas filhas na nota marcada com o rótulo <code>#inbox</code>.",
|
||||
"workspace_inbox": "local padrão da caixa de entrada para novas notas quando esta nota for destacada para algum ancestral desta nota de área de trabalho",
|
||||
"sql_console_home": "localização padrão das notas do console SQL",
|
||||
"bookmark_folder": "nota com este rótulo aparecerá nos favoritos como uma pasta (permitindo acesso aos seus filhos)",
|
||||
"share_hidden_from_tree": "esta nota está oculta na árvore de navegação à esquerda, mas ainda pode ser acessada via sua URL",
|
||||
"share_external_link": "a nota funcionará como um link para um site externo na árvore de compartilhamento"
|
||||
}
|
||||
}
|
||||
|
||||
1260
apps/client/src/translations/ru/translation.json
Normal file
1260
apps/client/src/translations/ru/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -166,5 +166,352 @@
|
||||
"other": "Ostalo",
|
||||
"quickSearch": "fokus na unos za brzu pretragu",
|
||||
"inPageSearch": "pretraga unutar stranice"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Uvezi u belešku",
|
||||
"close": "Zatvori",
|
||||
"chooseImportFile": "Izaberi datoteku za uvoz",
|
||||
"importDescription": "Sadržaj izabranih datoteka će biti uvezen kao podbeleške u",
|
||||
"options": "Opcije",
|
||||
"safeImportTooltip": "Trilium <code>.zip</code> izvozne datoteke mogu da sadrže izvršne skripte koje mogu imati štetno ponašanje. Bezbedan uvoz će deaktivirati automatsko izvršavanje svih uvezenih skripti. Isključite \"Bezbedan uvoz\" samo ako uvezena arhiva treba da sadrži izvršne skripte i ako potpuno verujete sadržaju uvezene datoteke.",
|
||||
"safeImport": "Bezbedan uvoz",
|
||||
"explodeArchivesTooltip": "Ako je ovo označeno onda će Trilium pročitati <code>.zip</code>, <code>.enex</code> i <code>.opml</code> datoteke i napraviti beleške od datoteka unutar tih arhiva. Ako nije označeno, Trilium će same arhive priložiti belešci.",
|
||||
"explodeArchives": "Pročitaj sadržaj <code>.zip</code>, <code>.enex</code> i <code>.opml</code> arhiva.",
|
||||
"shrinkImagesTooltip": "<p>Ako označite ovu opciju, Trilium će pokušati da smanji uvezene slike skaliranjem i optimizacijom što će možda uticati na kvalitet slike. Ako nije označeno, slike će biti uvezene bez promena.</p><p>Ovo se ne primenjuje na <code>.zip</code> uvoze sa metapodacima jer se tada podrazumeva da su te datoteke već optimizovane.</p>",
|
||||
"shrinkImages": "Smanji slike",
|
||||
"textImportedAsText": "Uvezi HTML, Markdown i TXT kao tekstualne beleške ako je nejasno iz metapodataka",
|
||||
"codeImportedAsCode": "Uvezi prepoznate datoteke sa kodom (poput <code>.json</code>) ako beleške sa kodom ako nije jasno iz metapodataka",
|
||||
"replaceUnderscoresWithSpaces": "Zameni podvlake sa razmacima u nazivima uvezenih beleški",
|
||||
"import": "Uvezi",
|
||||
"failed": "Uvoz nije uspeo: {{message}}.",
|
||||
"html_import_tags": {
|
||||
"title": "HTML oznake za uvoz",
|
||||
"description": "Podesite koje HTML oznake trebaju biti sačuvane kada se uvoze beleške. Oznake koje se ne nalaze na listi će biti uklonjene tokom uvoza. Pojedine oznake (poput 'script') se uvek uklanjaju zbog bezbednosti.",
|
||||
"placeholder": "Unesite HTML oznake, po jednu u svaki red",
|
||||
"reset_button": "Vrati na podrazumevanu listu"
|
||||
},
|
||||
"import-status": "Status uvoza",
|
||||
"in-progress": "Uvoz u toku: {{progress}}",
|
||||
"successful": "Uvoz je uspešno završen."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Uključi belešku",
|
||||
"close": "Zatvori",
|
||||
"label_note": "Beleška",
|
||||
"placeholder_search": "pretraži belešku po njenom imenu",
|
||||
"box_size_prompt": "Veličina kutije priložene beleške:",
|
||||
"box_size_small": "mala (~ 10 redova)",
|
||||
"box_size_medium": "srednja (~ 30 redova)",
|
||||
"box_size_full": "puna (kutija prikazuje ceo tekst)",
|
||||
"button_include": "Uključi belešku <kbd>enter</kbd>"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Informativna poruka",
|
||||
"closeButton": "Zatvori",
|
||||
"okButton": "U redu"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "Pretraži belešku po njenom imenu ili unesi > za komande...",
|
||||
"close": "Zatvori",
|
||||
"search_button": "Pretraga u punom tekstu <kbd>Ctrl+Enter</kbd>"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Uvoz za Markdown",
|
||||
"close": "Zatvori",
|
||||
"modal_body_text": "Zbog Sandbox-a pretraživača nije moguće direktno učitati privremenu memoriju iz JavaScript-a. Molimo vas da nalepite Markdown za uvoz u tekstualno polje ispod i kliknete na dugme za uvoz",
|
||||
"import_button": "Uvoz Ctrl+Enter",
|
||||
"import_success": "Markdown sadržaj je učitan u dokument."
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Premesti beleške u ...",
|
||||
"close": "Zatvori",
|
||||
"notes_to_move": "Beleške za premeštanje",
|
||||
"target_parent_note": "Ciljana nadbeleška",
|
||||
"search_placeholder": "potraži belešku po njenom imenu",
|
||||
"move_button": "Pređi na izabranu belešku <kbd>enter</kbd>",
|
||||
"error_no_path": "Nema putanje za premeštanje.",
|
||||
"move_success_message": "Izabrane beleške su premeštene u "
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"change_path_prompt": "Promenite gde će se napraviti nova beleška:",
|
||||
"search_placeholder": "pretraži putanju po njenom imenu (podrazumevano ako je prazno)",
|
||||
"modal_title": "Izaberite tip beleške",
|
||||
"close": "Zatvori",
|
||||
"modal_body": "Izaberite tip beleške / šablon za novu belešku:",
|
||||
"templates": "Šabloni:"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "Lozinka nije podešena",
|
||||
"close": "Zatvori",
|
||||
"body1": "Zaštićene beleške su enkriptovane sa korisničkom lozinkom, ali lozinka još uvek nije podešena.",
|
||||
"body2": "Za biste mogli da sačuvate beleške, kliknite <a class=\"open-password-options-button\" href=\"javascript:\">ovde</a> da otvorite dijalog sa Opcijama i podesite svoju lozinku."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Upit",
|
||||
"close": "Zatvori",
|
||||
"ok": "U redu <kbd>enter</kbd>",
|
||||
"defaultTitle": "Upit"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"modal_title": "Zaštićena sesija",
|
||||
"help_title": "Pomoć za Zaštićene beleške",
|
||||
"close_label": "Zatvori",
|
||||
"form_label": "Da biste nastavili sa traženom akcijom moraćete započeti zaštićenu sesiju tako što ćete uneti lozinku:",
|
||||
"start_button": "Započni zaštićenu sesiju <kbd>enter</kbd>"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Nedavne promene",
|
||||
"erase_notes_button": "Obriši izabrane beleške odmah",
|
||||
"close": "Zatvori",
|
||||
"deleted_notes_message": "Obrisane beleške su uklonjene.",
|
||||
"no_changes_message": "Još uvek nema izmena...",
|
||||
"undelete_link": "poništi brisanje",
|
||||
"confirm_undelete": "Da li želite da poništite brisanje ove beleške i njenih podbeleški?"
|
||||
},
|
||||
"revisions": {
|
||||
"note_revisions": "Revizije beleški",
|
||||
"delete_all_revisions": "Obriši sve revizije ove beleške",
|
||||
"delete_all_button": "Obriši sve revizije",
|
||||
"help_title": "Pomoć za Revizije beleški",
|
||||
"close": "Zatvori",
|
||||
"revision_last_edited": "Ova revizija je poslednji put izmenjena {{date}}",
|
||||
"confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?",
|
||||
"no_revisions": "Još uvek nema revizija za ovu belešku...",
|
||||
"restore_button": "Vrati",
|
||||
"confirm_restore": "Da li želite da vratite ovu reviziju? Ovo će prepisati trenutan naslov i sadržaj beleške sa ovom revizijom.",
|
||||
"delete_button": "Obriši",
|
||||
"confirm_delete": "Da li želite da obrišete ovu reviziju?",
|
||||
"revisions_deleted": "Revizije beleške su obrisane.",
|
||||
"revision_restored": "Revizija beleške je vraćena.",
|
||||
"revision_deleted": "Revizija beleške je obrisana.",
|
||||
"snapshot_interval": "Interval snimanja revizije beleške: {{seconds}}s.",
|
||||
"maximum_revisions": "Ograničenje broja slika revizije beleške: {{number}}.",
|
||||
"settings": "Podešavanja revizija beleški",
|
||||
"download_button": "Preuzmi",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "Veličina datoteke:",
|
||||
"preview": "Pregled:",
|
||||
"preview_not_available": "Pregled nije dostupan za ovaj tip beleške."
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"sort_children_by": "Sortiranje podbeleški po...",
|
||||
"close": "Zatvori",
|
||||
"sorting_criteria": "Kriterijum za sortiranje",
|
||||
"title": "naslov",
|
||||
"date_created": "datum kreiranja",
|
||||
"date_modified": "datum izmene",
|
||||
"sorting_direction": "Smer sortiranja",
|
||||
"ascending": "uzlazni",
|
||||
"descending": "silazni",
|
||||
"folders": "Fascikle",
|
||||
"sort_folders_at_top": "sortiraj fascikle na vrh",
|
||||
"natural_sort": "Prirodno sortiranje",
|
||||
"sort_with_respect_to_different_character_sorting": "sortiranje sa poštovanjem različitih pravila sortiranja karaktera i kolacija u različitim jezicima ili regionima.",
|
||||
"natural_sort_language": "Jezik za prirodno sortiranje",
|
||||
"the_language_code_for_natural_sort": "Kod jezika za prirodno sortiranje, npr. \"zh-CN\" za Kineski.",
|
||||
"sort": "Sortiraj <kbd>enter</kbd>"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Otpremite priloge uz belešku",
|
||||
"close": "Zatvori",
|
||||
"choose_files": "Izaberite datoteke",
|
||||
"files_will_be_uploaded": "Datoteke će biti otpremljene kao prilozi u",
|
||||
"options": "Opcije",
|
||||
"shrink_images": "Smanji slike",
|
||||
"upload": "Otpremi",
|
||||
"tooltip": "Ako je označeno, Trilium će pokušati da smanji otpremljene slike skaliranjem i optimizacijom što može uticati na kvalitet slike. Ako nije označeno, slike će biti otpremljene bez izmena."
|
||||
},
|
||||
"attribute_detail": {
|
||||
"attr_detail_title": "Naslov detalja atributa",
|
||||
"close_button_title": "Otkaži izmene i zatvori",
|
||||
"attr_is_owned_by": "Atribut je u vlasništvu",
|
||||
"attr_name_title": "Naziv atributa može biti sastavljen samo od alfanumeričkih znakova, dvotačke i donje crte",
|
||||
"name": "Naziv",
|
||||
"value": "Vrednost",
|
||||
"target_note_title": "Relacija je imenovana veza između izvorne beleške i ciljne beleške.",
|
||||
"target_note": "Ciljna beleška",
|
||||
"promoted_title": "Promovisani atribut je istaknut na belešci.",
|
||||
"promoted": "Promovisan",
|
||||
"promoted_alias_title": "Naziv koji će biti prikazan u korisničkom interfejsu promovisanih atributa.",
|
||||
"promoted_alias": "Pseudonim",
|
||||
"multiplicity_title": "Multiplicitet definiše koliko atributa sa istim nazivom se može napraviti - najviše 1 ili više od 1.",
|
||||
"multiplicity": "Multiplicitet",
|
||||
"single_value": "Jednostruka vrednost",
|
||||
"multi_value": "Višestruka vrednost",
|
||||
"label_type_title": "Tip oznake će pomoći Triliumu da izabere odgovarajući interfejs za unos vrednosti oznake.",
|
||||
"label_type": "Tip",
|
||||
"text": "Tekst",
|
||||
"number": "Broj",
|
||||
"boolean": "Boolean",
|
||||
"date": "Datum",
|
||||
"date_time": "Datum i vreme",
|
||||
"time": "Vreme",
|
||||
"url": "URL",
|
||||
"precision_title": "Broj cifara posle zareza treba biti dostupan u interfejsu za postavljanje vrednosti.",
|
||||
"precision": "Preciznost",
|
||||
"digits": "cifre",
|
||||
"inverse_relation_title": "Opciono podešavanje za definisanje kojoj relaciji je ova suprotna. Primer: Otac - Sin su inverzne relacije jedna drugoj.",
|
||||
"inverse_relation": "Inverzna relacija",
|
||||
"inheritable_title": "Atributi koji mogu da se nasleđuju će biti nasleđeni od strane svih potomaka unutar ovog stabla.",
|
||||
"inheritable": "Nasledno",
|
||||
"save_and_close": "Sačuvaj i zatvori <kbd>Ctrl+Enter</kbd>",
|
||||
"delete": "Obriši",
|
||||
"related_notes_title": "Druge beleške sa ovom oznakom",
|
||||
"more_notes": "Još beleški",
|
||||
"label": "Detalji oznake",
|
||||
"label_definition": "Detalji definicije oznake",
|
||||
"relation": "Detalji relacije",
|
||||
"relation_definition": "Detalji definicije relacije",
|
||||
"disable_versioning": "onemogućava auto-verzionisanje. Korisno za npr. velike, ali nebitne beleške - poput velikih JS biblioteka koje se koriste za skripte",
|
||||
"calendar_root": "obeležava belešku koju treba koristiti kao osnova za dnevne beleške. Samo jedna beleška treba da bude označena kao takva.",
|
||||
"archived": "beleške sa ovom oznakom neće biti podrazumevano vidljive u rezultatima pretrage (kao ni u dijalozima za Idi na, Dodaj link, itd.).",
|
||||
"exclude_from_export": "beleške (sa svojim podstablom) neće biti uključene u bilo koji izvoz beleški",
|
||||
"run": "definiše u kojim događajima se skripta pokreće. Moguće vrednosti su:\n<ul>\n<li>frontendStartup - kada se pokrene Trilium frontend (ili se osveži), ali ne na mobilnom uređaju.</li>\n<li>mobileStartup - kada se pokrene Trilium frontend (ili se osveži), na mobilnom uređaju..</li>\n<li>backendStartup - kada se Trilium backend pokrene</li>\n<li>hourly - pokreće se svaki sat. Može se koristiti dodatna oznaka <code>runAtHour</code> da se označi u kom satu.</li>\n<li>daily - pokreće se jednom dnevno</li>\n</ul>",
|
||||
"run_on_instance": "Definiše u kojoj instanci Trilium-a ovo treba da se pokreće. Podrazumevano podešavanje je na svim instancama.",
|
||||
"run_at_hour": "U kom satu ovo treba da se pokreće. Treba se koristiti zajedno sa <code>#run=hourly</code>. Može biti definisano više puta za više pokretanja u toku dana.",
|
||||
"disable_inclusion": "skripte sa ovom oznakom neće biti uključene u izvršavanju nadskripte.",
|
||||
"sorted": "čuva podbeleške sortirane alfabetski po naslovu",
|
||||
"sort_direction": "Uzlazno (podrazumevano) ili silazno",
|
||||
"sort_folders_first": "Fascikle (beleške sa podbeleškama) treba da budu sortirane na vrhu",
|
||||
"top": "zadrži datu belešku na vrhu njene nadbeleške (primenjuje se samo na sortiranim nadbeleškama)",
|
||||
"hide_promoted_attributes": "Sakrij promovisane atribute na ovoj belešci",
|
||||
"read_only": "uređivač je u režimu samo za čitanje. Radi samo za tekst i beleške sa kodom.",
|
||||
"auto_read_only_disabled": "beleške sa tekstom/kodom se mogu automatski podesiti u režim za čitanje kada su prevelike. Ovo ponašanje možete onemogućiti pojedinačno za belešku dodavanjem ove oznake na belešku",
|
||||
"app_css": "označava CSS beleške koje nisu učitane u Trilium aplikaciju i zbog toga se mogu koristiti za menjanje izgleda Triliuma.",
|
||||
"app_theme": "označava CSS beleške koje su pune Trilium teme i stoga su dostupne u Trilium podešavanjima.",
|
||||
"app_theme_base": "podesite na „sledeće“, „sledeće-svetlo“ ili „sledeće-tamno“ da biste koristili odgovarajuću TriliumNext temu (automatsku, svetlu ili tamnu) kao osnovu za prilagođenu temu, umesto podrazumevane teme.",
|
||||
"css_class": "vrednost ove oznake se zatim dodaje kao CSS klasa čvoru koji predstavlja datu belešku u stablu. Ovo može biti korisno za napredno temiranje. Može se koristiti u šablonima beleški.",
|
||||
"workspace": "označava ovu belešku kao radni prostor što omogućava lako podizanje",
|
||||
"workspace_icon_class": "definiše CSS klasu ikone okvira koja će se koristiti u kartici kada se podigne na ovoj belešci",
|
||||
"workspace_tab_background_color": "CSS boja korišćena u kartici beleške kada se prebaci na ovu belešku",
|
||||
"workspace_calendar_root": "Definiše koren kalendara za svaki radni prostor",
|
||||
"workspace_template": "Ova beleška će se pojaviti u izboru dostupnih šablona prilikom kreiranja nove beleške, ali samo kada se podigne u radni prostor koji sadrži ovaj šablon",
|
||||
"search_home": "nove beleške o pretrazi biće kreirane kao podređeni delovi ove beleške",
|
||||
"workspace_search_home": "nove beleške o pretrazi biće kreirane kao podređeni delovi ove beleške kada se podignu na nekog pretka ove beleške iz radnog prostora",
|
||||
"inbox": "podrazumevana lokacija u prijemnom sandučetu za nove beleške - kada kreirate belešku pomoću dugmeta „nova beleška“ u bočnoj traci, beleške će biti kreirane kao podbeleške u belešci označenoj sa oznakom <code>#inbox</code>.",
|
||||
"workspace_inbox": "podrazumevana lokacija prijemnog sandučeta za nove beleške kada se prebace na nekog pretka ove beleške iz radnog prostora",
|
||||
"sql_console_home": "podrazmevana lokacija beleški SQL konzole",
|
||||
"bookmark_folder": "beleška sa ovom oznakom će se pojaviti u obeleživačima kao fascikla (omogućavajući pristup njenim podređenim fasciklama)",
|
||||
"share_hidden_from_tree": "ova beleška je skrivena u levom navigacionom stablu, ali je i dalje dostupna preko svoje URL adrese",
|
||||
"share_external_link": "beleška će služiti kao veza ka eksternoj veb stranici u stablu deljenja",
|
||||
"share_alias": "definišite alias pomoću kog će beleška biti dostupna na https://your_trilium_host/share/[your_alias]",
|
||||
"share_omit_default_css": "CSS kod podrazumevane stranice za deljenje će biti izostavljen. Koristite ga kada pravite opsežne promene stila.",
|
||||
"share_root": "obeležava belešku koja se prikazuje na /share korenu.",
|
||||
"share_description": "definišite tekst koji će se dodati HTML meta oznaci za opis",
|
||||
"share_raw": "beleška će biti prikazana u svom sirovom (raw) formatu, bez HTML omotača",
|
||||
"share_disallow_robot_indexing": "zabraniće robotsko indeksiranje ove beleške putem zaglavlja <code>X-Robots-Tag: noindex</code>",
|
||||
"share_credentials": "potrebni su kredencijali za pristup ovoj deljenoj belešci. Očekuje se da vrednost bude u formatu „korisničko ime:lozinka“. Ne zaboravite da ovo označite kao nasledno da bi se primenilo na podbeleške/slike.",
|
||||
"share_index": "beleška sa ovom oznakom će izlistati sve korene deljenih beleški",
|
||||
"display_relations": "imena relacija razdvojenih zarezima koja treba da budu prikazana. Sva ostala će biti skrivena.",
|
||||
"hide_relations": "imena relacija razdvojenih zarezima koja treba da budu skrivena. Sva ostala će biti prikazana.",
|
||||
"title_template": "podrazumevani naslov beleški kreiranih kao deca ove beleške. Vrednost se procenjuje kao JavaScript string \n i stoga se može obogatiti dinamičkim sadržajem putem ubrizganih promenljivih <code>now</code> and <code>parentNote</code>. Primeri:\n \n <ul>\n <li><code>${parentNote.getLabelValue('authorName')}'s literary works</code></li>\n <li><code>Log for ${now.format('YYYY-MM-DD HH:mm:ss')}</code></li>\n </ul>\n \n Pogledati <a href=\"https://triliumnext.github.io/Docs/Wiki/default-note-title.html\">wiki sa detaljima</a>, API dokumentacija za <a href=\"https://zadam.github.io/trilium/backend_api/Note.html\">parentNote</a> i <a href=\"https://day.js.org/docs/en/display/format\">now</a> za detalje.",
|
||||
"template": "Ova beleška će biti prikazana u izboru dostupnih šablona prilikom pravljenja nove beleške",
|
||||
"toc": "<code>#toc</code> ili <code>#toc=show</code> će pristiliti Sadržaj (Table of Contents) da bude prikazan, <code>#toc=hide</code> prisiliti njegovo sakrivanje. Ako oznaka ne postoji, ponašanje će biti usklađeno sa globalnim podešavanjem",
|
||||
"color": "definiše boju beleške u stablu beleški, linkovima itd. Koristite bilo koju važeću CSS vrednost boje kao što je „crvena“ ili #a13d5f",
|
||||
"keyboard_shortcut": "Definiše prečicu na tastaturi koja će odmah preći na ovu belešku. Primer: „ctrl+alt+e“. Potrebno je ponovno učitavanje frontenda da bi promena stupila na snagu.",
|
||||
"keep_current_hoisting": "Otvaranje ove veze neće promeniti podizanje čak i ako beleška nije prikazana u trenutno podignutom podstablu.",
|
||||
"execute_button": "Naslov dugmeta koje će izvršiti trenutnu belešku sa kodom",
|
||||
"execute_description": "Duži opis trenutne beleške sa kodom prikazan je zajedno sa dugmetom za izvršavanje",
|
||||
"exclude_from_note_map": "Beleške sa ovom oznakom biće skrivene sa mape beleški",
|
||||
"new_notes_on_top": "Nove beleške će biti napravljene na vrhu matične beleške, a ne na dnu.",
|
||||
"hide_highlight_widget": "Sakrij vidžet sa listom istaknutih",
|
||||
"run_on_note_creation": "izvršava se kada se beleška napravi na serverskoj strani. Koristite ovu relaciju ako želite da pokrenete skriptu za sve beleške napravljene u okviru određenog podstabla. U tom slučaju, kreirajte je na korenu beleške podstabla i učinite je naslednom. Nova beleška napravljena unutar podstabla (bilo koje dubine) pokrenuće skriptu.",
|
||||
"run_on_child_note_creation": "izvršava se kada se napravi nova beleška ispod beleške gde je ova relacija definisana",
|
||||
"run_on_note_title_change": "izvršava se kada se promeni naslov beleške (uključuje i pravljenje beleške)",
|
||||
"run_on_note_content_change": "izvršava se kada se promeni sadržaj beleške (uključuje i pravljenje beleške).",
|
||||
"run_on_note_change": "izvršava se kada se promeni beleška (uključuje i pravljenje beleške). Ne uključuje promene sadržaja",
|
||||
"icon_class": "vrednost ove oznake se dodaje kao CSS klasa ikoni na stablu što može pomoći u vizuelnom razlikovanju beleški u stablu. Primer može biti bx bx-home - ikone su preuzete iz boxicons. Može se koristiti u šablonima beleški.",
|
||||
"page_size": "broj stavki po stranici u listi beleški",
|
||||
"custom_request_handler": "pogledajte <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Prilagođeni obrađivač zahteva</a>",
|
||||
"custom_resource_provider": "pogledajte <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Prilagođeni obrađivač zahteva</a>",
|
||||
"widget": "označava ovu belešku kao prilagođeni vidžet koji će biti dodat u stablo komponenti Trilijuma",
|
||||
"run_on_note_deletion": "izvršava se kada se beleška briše",
|
||||
"run_on_branch_creation": "izvršava se kada se pravi grana. Grana je veza između matične i podređene beleške i pravi se npr. prilikom kloniranja ili premeštanja beleške.",
|
||||
"run_on_branch_change": "izvršava se kada se grana ažurira.",
|
||||
"run_on_branch_deletion": "izvršava se kada se grana briše. Grana je veza između nadređene beleške i podređene beleške i briše se npr. prilikom premeštanja beleške (stara grana/veza se briše).",
|
||||
"run_on_attribute_creation": "izvršava se kada se pravi novi atribut za belešku koji definiše ovu relaciju",
|
||||
"run_on_attribute_change": " izvršava se kada se promeni atribut beleške koja definiše ovu relaciju. Ovo se pokreće i kada se atribut obriše",
|
||||
"relation_template": "atributi beleške će biti nasleđeni čak i bez odnosa roditelj-dete, sadržaj i podstablo beleške će biti dodati instanci beleške ako je prazna. Pogledajte dokumentaciju za detalje.",
|
||||
"inherit": "Atributi beleške će biti nasleđeni čak i bez odnosa roditelj-dete. Pogledajte relaciju šablona za sličan koncept. Pogledajte nasleđivanje atributa u dokumentaciji.",
|
||||
"render_note": "Beleške tipa „render HTML note“ će biti prikazane korišćenjem beleške za kod (HTML ili skripte) i potrebno je pomoću ove relacije ukazati na to koja beleška treba da se prikaže",
|
||||
"widget_relation": "meta ove relacije će biti izvršena i prikazana kao vidžet u bočnoj traci",
|
||||
"share_css": "CSS napomena koja će biti ubrizgana na stranicu za deljenje. CSS napomena mora biti i u deljenom podstablu. Razmotrite i korišćenje „share_hidden_from_tree“ i „share_omit_default_css“.",
|
||||
"share_js": "JavaScript beleška koja će biti ubrizgana na stranicu za deljenje. JS beleška takođe mora biti u deljenom podstablu. Razmislite o korišćenju „share_hidden_from_tree“.",
|
||||
"share_template": "Ugrađena JavaScript beleška koja će se koristiti kao šablon za prikazivanje deljene beleške. U slučaju neuspeha vraća se na podrazumevani šablon. Razmislite o korišćenju „share_hidden_from_tree“.",
|
||||
"share_favicon": "Favicon beleška koju treba postaviti na deljenu stranicu. Obično je potrebno da je podesite da deli koren i učinite je naslednom. Favicon beleška takođe mora biti u deljenom podstablu. Razmislite o korišćenju „share_hidden_from_tree“.",
|
||||
"is_owned_by_note": "je u vlasništvu beleške",
|
||||
"other_notes_with_name": "Ostale beleške sa {{attributeType}} nazivom „{{attributeName}}“",
|
||||
"and_more": "... i još {{count}}.",
|
||||
"print_landscape": "Prilikom izvoza u PDF, menja orijentaciju stranice u pejzažnu umesto uspravne.",
|
||||
"print_page_size": "Prilikom izvoza u PDF, menja veličinu stranice. Podržane vrednosti: <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": "Boja"
|
||||
},
|
||||
"ai_llm": {
|
||||
"n_notes_queued_0": "{{ count }} beleška stavljena u red za indeksiranje",
|
||||
"n_notes_queued_1": "{{ count }} beleški stavljeno u red za indeksiranje",
|
||||
"n_notes_queued_2": "{{ count }} beleški stavljeno u red za indeksiranje",
|
||||
"notes_indexed_0": "{{ count }} beleška je indeksirana",
|
||||
"notes_indexed_1": "{{ count }} beleški je indeksirano",
|
||||
"notes_indexed_2": "{{ count }} beleški je indeksirano"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "Da biste dodali oznaku, samo unesite npr. <code>#rock</code> ili ako želite da dodate i vrednost, onda npr. <code>#year = 2020</code>",
|
||||
"help_text_body2": "Za relaciju, unesite <code>~author = @</code> što bi trebalo da otvori automatsko dovršavanje gde možete potražiti željenu belešku.",
|
||||
"help_text_body3": "Alternativno, možete dodati oznaku i relaciju pomoću dugmeta <code>+</code> sa desne strane.",
|
||||
"save_attributes": "Sačuvaj atribute <enter>",
|
||||
"add_a_new_attribute": "Dodajte novi atribut",
|
||||
"add_new_label": "Dodajte novu oznaku <kbd data-command=\"addNewLabel\"></kbd>",
|
||||
"add_new_relation": "Dodajte novu relaciju <kbd data-command=\"addNewRelation\"></kbd>",
|
||||
"add_new_label_definition": "Dodajte novu definiciju oznake",
|
||||
"add_new_relation_definition": "Dodajte novu definiciju relacije",
|
||||
"placeholder": "Ovde unesite oznake i relacije"
|
||||
},
|
||||
"abstract_bulk_action": {
|
||||
"remove_this_search_action": "Ukloni ovu radnju pretrage"
|
||||
},
|
||||
"execute_script": {
|
||||
"execute_script": "Izvrši skriptu",
|
||||
"help_text": "Možete izvršiti jednostavne skripte na podudarnim beleškama.",
|
||||
"example_1": "Na primer, da biste dodali string u naslov beleške, koristite ovu malu skriptu:",
|
||||
"example_2": "Složeniji primer bi bio brisanje svih atributa podudarnih beleški:"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Dodaj oznaku",
|
||||
"label_name_placeholder": "ime oznake",
|
||||
"label_name_title": "Alfanumerički znakovi, donja crta i dvotačka su dozvoljeni znakovi.",
|
||||
"to_value": "za vrednost",
|
||||
"new_value_placeholder": "nova vrednost",
|
||||
"help_text": "Na svim podudarnim beleškama:",
|
||||
"help_text_item1": "dodajte datu oznaku ako beleška još uvek nema jednu",
|
||||
"help_text_item2": "ili izmenite vrednost postojeće oznake",
|
||||
"help_text_note": "Takođe možete pozvati ovu metodu bez vrednosti, u tom slučaju će oznaka biti dodeljena belešci bez vrednosti."
|
||||
},
|
||||
"delete_label": {
|
||||
"delete_label": "Obriši oznaku",
|
||||
"label_name_placeholder": "ime oznake",
|
||||
"label_name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi."
|
||||
},
|
||||
"rename_label": {
|
||||
"rename_label": "Preimenuj oznaku",
|
||||
"rename_label_from": "Preimenuj oznaku iz",
|
||||
"old_name_placeholder": "stari naziv",
|
||||
"to": "U",
|
||||
"new_name_placeholder": "novi naziv",
|
||||
"name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi."
|
||||
},
|
||||
"update_label_value": {
|
||||
"update_label_value": "Ažuriraj vrednost oznake",
|
||||
"label_name_placeholder": "ime oznake",
|
||||
"label_name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi.",
|
||||
"to_value": "u vrednost",
|
||||
"new_value_placeholder": "nova vrednost",
|
||||
"help_text": "Na svim podudarnim beleškama, promenite vrednost postojeće oznake.",
|
||||
"help_text_note": "Takođe možete pozvati ovu metodu bez vrednosti, u tom slučaju će oznaka biti dodeljena belešci bez vrednosti."
|
||||
},
|
||||
"delete_note": {
|
||||
"delete_note": "Obriši belešku",
|
||||
"delete_matched_notes": "Obriši podudarne beleške",
|
||||
"delete_matched_notes_description": "Ovo će obrisati podudarne beleške.",
|
||||
"undelete_notes_instruction": "Nakon brisanja, moguće ga je poništiti iz dijaloga Nedavne izmene."
|
||||
}
|
||||
}
|
||||
|
||||
71
apps/client/src/translations/tr/translation.json
Normal file
71
apps/client/src/translations/tr/translation.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"about": {
|
||||
"close": "Kapat",
|
||||
"homepage": "Giriş sayfası:",
|
||||
"app_version": "Uygulama versiyonu:",
|
||||
"db_version": "Veritabanı versiyonu:"
|
||||
},
|
||||
"add_link": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"close": "Kapat",
|
||||
"save": "Kaydet"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"clone_to": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"confirm": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"recent_changes": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"delete_notes": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"export": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"help": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"include_note": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"import": {
|
||||
"close": "Kapat",
|
||||
"chooseImportFile": "İçe aktarım dosyası",
|
||||
"importDescription": "Seçilen dosya(lar) alt not olarak içe aktarılacaktır"
|
||||
},
|
||||
"info": {
|
||||
"closeButton": "Kapat"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"markdown_import": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"move_to": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"password_not_set": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"prompt": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Kapat"
|
||||
},
|
||||
"revisions": {
|
||||
"close": "Kapat"
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import openService from "../../services/open.js";
|
||||
import server from "../../services/server.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
interface AppInfo {
|
||||
appVersion: string;
|
||||
dbVersion: number;
|
||||
syncVersion: number;
|
||||
buildDate: string;
|
||||
buildRevision: string;
|
||||
dataDirectory: string;
|
||||
}
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="about-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("about.title")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("about.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th>${t("about.homepage")}</th>
|
||||
<td><a class="tn-link" href="https://github.com/TriliumNext/Trilium" class="external">https://github.com/TriliumNext/Trilium</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.app_version")}</th>
|
||||
<td class="app-version"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.db_version")}</th>
|
||||
<td class="db-version"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.sync_version")}</th>
|
||||
<td class="sync-version"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.build_date")}</th>
|
||||
<td class="build-date"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.build_revision")}</th>
|
||||
<td><a class="tn-link build-revision external" href="" target="_blank"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.data_directory")}</th>
|
||||
<td class="data-directory"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.about-dialog a {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
export default class AboutDialog extends BasicWidget {
|
||||
private $appVersion!: JQuery<HTMLElement>;
|
||||
private $dbVersion!: JQuery<HTMLElement>;
|
||||
private $syncVersion!: JQuery<HTMLElement>;
|
||||
private $buildDate!: JQuery<HTMLElement>;
|
||||
private $buildRevision!: JQuery<HTMLElement>;
|
||||
private $dataDirectory!: JQuery<HTMLElement>;
|
||||
|
||||
doRender(): void {
|
||||
this.$widget = $(TPL);
|
||||
this.$appVersion = this.$widget.find(".app-version");
|
||||
this.$dbVersion = this.$widget.find(".db-version");
|
||||
this.$syncVersion = this.$widget.find(".sync-version");
|
||||
this.$buildDate = this.$widget.find(".build-date");
|
||||
this.$buildRevision = this.$widget.find(".build-revision");
|
||||
this.$dataDirectory = this.$widget.find(".data-directory");
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const appInfo = await server.get<AppInfo>("app-info");
|
||||
|
||||
this.$appVersion.text(appInfo.appVersion);
|
||||
this.$dbVersion.text(appInfo.dbVersion.toString());
|
||||
this.$syncVersion.text(appInfo.syncVersion.toString());
|
||||
this.$buildDate.text(formatDateTime(appInfo.buildDate));
|
||||
this.$buildRevision.text(appInfo.buildRevision);
|
||||
this.$buildRevision.attr("href", `https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`);
|
||||
if (utils.isElectron()) {
|
||||
this.$dataDirectory.html(
|
||||
$("<a></a>", {
|
||||
href: "#",
|
||||
class: "tn-link",
|
||||
text: appInfo.dataDirectory
|
||||
}).prop("outerHTML")
|
||||
);
|
||||
this.$dataDirectory.find("a").on("click", (event: JQuery.ClickEvent) => {
|
||||
event.preventDefault();
|
||||
openService.openDirectory(appInfo.dataDirectory);
|
||||
});
|
||||
} else {
|
||||
this.$dataDirectory.text(appInfo.dataDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
async openAboutDialogEvent() {
|
||||
await this.refresh();
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
}
|
||||
91
apps/client/src/widgets/dialogs/about.tsx
Normal file
91
apps/client/src/widgets/dialogs/about.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import Modal from "../react/Modal.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import server from "../../services/server.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import openService from "../../services/open.js";
|
||||
import { useState } from "preact/hooks";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import type { AppInfo } from "@triliumnext/commons";
|
||||
|
||||
function AboutDialogComponent() {
|
||||
let [appInfo, setAppInfo] = useState<AppInfo | null>(null);
|
||||
|
||||
async function onShown() {
|
||||
const appInfo = await server.get<AppInfo>("app-info");
|
||||
setAppInfo(appInfo);
|
||||
}
|
||||
|
||||
const forceWordBreak: CSSProperties = { wordBreak: "break-all" };
|
||||
|
||||
return (
|
||||
<Modal className="about-dialog" size="lg" title={t("about.title")} onShown={onShown}>
|
||||
{(appInfo !== null) ? (
|
||||
<table className="table table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{t("about.homepage")}</th>
|
||||
<td><a className="tn-link external" href="https://github.com/TriliumNext/Trilium" style={forceWordBreak}>https://github.com/TriliumNext/Trilium</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.app_version")}</th>
|
||||
<td className="app-version">{appInfo.appVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.db_version")}</th>
|
||||
<td className="db-version">{appInfo.dbVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.sync_version")}</th>
|
||||
<td className="sync-version">{appInfo.syncVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.build_date")}</th>
|
||||
<td className="build-date">{formatDateTime(appInfo.buildDate)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.build_revision")}</th>
|
||||
<td>
|
||||
<a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak}>{appInfo.buildRevision}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.data_directory")}</th>
|
||||
<td className="data-directory">
|
||||
<DirectoryLink directory={appInfo.dataDirectory} style={forceWordBreak} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="loading-spinner"></div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectoryLink({ directory, style }: { directory: string, style?: CSSProperties }) {
|
||||
if (utils.isElectron()) {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
openService.openDirectory(directory);
|
||||
};
|
||||
|
||||
return <a className="tn-link" href="#" onClick={onClick} style={style}></a>
|
||||
} else {
|
||||
return <span style={style}>{directory}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export default class AboutDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <AboutDialogComponent />;
|
||||
}
|
||||
|
||||
async openAboutDialogEvent() {
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,9 @@ export async function checkSessionExists(noteId: string): Promise<boolean> {
|
||||
* @param onContentUpdate - Callback for content updates
|
||||
* @param onThinkingUpdate - Callback for thinking updates
|
||||
* @param onToolExecution - Callback for tool execution
|
||||
* @param onProgressUpdate - Callback for progress updates
|
||||
* @param onUserInteraction - Callback for user interaction requests
|
||||
* @param onErrorRecovery - Callback for error recovery options
|
||||
* @param onComplete - Callback for completion
|
||||
* @param onError - Callback for errors
|
||||
*/
|
||||
@@ -57,6 +60,9 @@ export async function setupStreamingResponse(
|
||||
onContentUpdate: (content: string, isDone?: boolean) => void,
|
||||
onThinkingUpdate: (thinking: string) => void,
|
||||
onToolExecution: (toolData: any) => void,
|
||||
onProgressUpdate: (progressData: any) => void,
|
||||
onUserInteraction: (interactionData: any) => Promise<any>,
|
||||
onErrorRecovery: (errorData: any) => Promise<any>,
|
||||
onComplete: () => void,
|
||||
onError: (error: Error) => void
|
||||
): Promise<void> {
|
||||
@@ -66,9 +72,14 @@ export async function setupStreamingResponse(
|
||||
let timeoutId: number | null = null;
|
||||
let initialTimeoutId: number | null = null;
|
||||
let cleanupTimeoutId: number | null = null;
|
||||
let heartbeatTimeoutId: number | null = null;
|
||||
let receivedAnyMessage = false;
|
||||
let eventListener: ((event: Event) => void) | null = null;
|
||||
let lastMessageTimestamp = 0;
|
||||
|
||||
// Configuration for timeouts
|
||||
const HEARTBEAT_TIMEOUT_MS = 30000; // 30 seconds between messages
|
||||
const MAX_IDLE_TIME_MS = 60000; // 60 seconds max idle time
|
||||
|
||||
// Create a unique identifier for this response process
|
||||
const responseId = `llm-stream-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||||
@@ -101,12 +112,43 @@ export async function setupStreamingResponse(
|
||||
}
|
||||
})();
|
||||
|
||||
// Function to reset heartbeat timeout
|
||||
const resetHeartbeatTimeout = () => {
|
||||
if (heartbeatTimeoutId) {
|
||||
window.clearTimeout(heartbeatTimeoutId);
|
||||
}
|
||||
|
||||
heartbeatTimeoutId = window.setTimeout(() => {
|
||||
const idleTime = Date.now() - lastMessageTimestamp;
|
||||
console.warn(`[${responseId}] No message received for ${idleTime}ms`);
|
||||
|
||||
if (idleTime > MAX_IDLE_TIME_MS) {
|
||||
console.error(`[${responseId}] Connection appears to be stalled (idle for ${idleTime}ms)`);
|
||||
performCleanup();
|
||||
reject(new Error('Connection lost: The AI service stopped responding. Please try again.'));
|
||||
} else {
|
||||
// Send a warning but continue waiting
|
||||
console.warn(`[${responseId}] Connection may be slow, continuing to wait...`);
|
||||
resetHeartbeatTimeout(); // Reset for another check
|
||||
}
|
||||
}, HEARTBEAT_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
// Function to safely perform cleanup
|
||||
const performCleanup = () => {
|
||||
// Clear all timeouts
|
||||
if (cleanupTimeoutId) {
|
||||
window.clearTimeout(cleanupTimeoutId);
|
||||
cleanupTimeoutId = null;
|
||||
}
|
||||
if (heartbeatTimeoutId) {
|
||||
window.clearTimeout(heartbeatTimeoutId);
|
||||
heartbeatTimeoutId = null;
|
||||
}
|
||||
if (initialTimeoutId) {
|
||||
window.clearTimeout(initialTimeoutId);
|
||||
initialTimeoutId = null;
|
||||
}
|
||||
|
||||
console.log(`[${responseId}] Performing final cleanup of event listener`);
|
||||
cleanupEventListener(eventListener);
|
||||
@@ -115,13 +157,15 @@ export async function setupStreamingResponse(
|
||||
};
|
||||
|
||||
// Set initial timeout to catch cases where no message is received at all
|
||||
// Increased timeout and better error messaging
|
||||
const INITIAL_TIMEOUT_MS = 15000; // 15 seconds for initial response
|
||||
initialTimeoutId = window.setTimeout(() => {
|
||||
if (!receivedAnyMessage) {
|
||||
console.error(`[${responseId}] No initial message received within timeout`);
|
||||
console.error(`[${responseId}] No initial message received within ${INITIAL_TIMEOUT_MS}ms timeout`);
|
||||
performCleanup();
|
||||
reject(new Error('No response received from server'));
|
||||
reject(new Error('Connection timeout: The AI service is taking longer than expected to respond. Please check your connection and try again.'));
|
||||
}
|
||||
}, 10000);
|
||||
}, INITIAL_TIMEOUT_MS);
|
||||
|
||||
// Create a message handler for CustomEvents
|
||||
eventListener = (event: Event) => {
|
||||
@@ -155,6 +199,12 @@ export async function setupStreamingResponse(
|
||||
window.clearTimeout(initialTimeoutId);
|
||||
initialTimeoutId = null;
|
||||
}
|
||||
|
||||
// Start heartbeat monitoring
|
||||
resetHeartbeatTimeout();
|
||||
} else {
|
||||
// Reset heartbeat on each new message
|
||||
resetHeartbeatTimeout();
|
||||
}
|
||||
|
||||
// Handle error
|
||||
@@ -177,6 +227,28 @@ export async function setupStreamingResponse(
|
||||
onToolExecution(message.toolExecution);
|
||||
}
|
||||
|
||||
// Handle progress updates
|
||||
if (message.progressUpdate) {
|
||||
console.log(`[${responseId}] Progress update:`, message.progressUpdate);
|
||||
onProgressUpdate(message.progressUpdate);
|
||||
}
|
||||
|
||||
// Handle user interaction requests
|
||||
if (message.userInteraction) {
|
||||
console.log(`[${responseId}] User interaction request:`, message.userInteraction);
|
||||
onUserInteraction(message.userInteraction).catch(error => {
|
||||
console.error(`[${responseId}] Error handling user interaction:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle error recovery options
|
||||
if (message.errorRecovery) {
|
||||
console.log(`[${responseId}] Error recovery options:`, message.errorRecovery);
|
||||
onErrorRecovery(message.errorRecovery).catch(error => {
|
||||
console.error(`[${responseId}] Error handling error recovery:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle content updates
|
||||
if (message.content) {
|
||||
// Simply append the new content - no complex deduplication
|
||||
@@ -258,3 +330,54 @@ export async function getDirectResponse(noteId: string, messageParams: any): Pro
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send user interaction response
|
||||
* @param interactionId - The interaction ID
|
||||
* @param response - The user's response
|
||||
*/
|
||||
export async function sendUserInteractionResponse(interactionId: string, response: string): Promise<void> {
|
||||
try {
|
||||
await server.post<any>(`llm/interactions/${interactionId}/respond`, {
|
||||
response: response
|
||||
});
|
||||
console.log(`User interaction response sent: ${interactionId} -> ${response}`);
|
||||
} catch (error) {
|
||||
console.error('Error sending user interaction response:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error recovery choice
|
||||
* @param sessionId - The chat session ID
|
||||
* @param errorId - The error ID
|
||||
* @param action - The recovery action chosen
|
||||
* @param parameters - Optional parameters for the action
|
||||
*/
|
||||
export async function sendErrorRecoveryChoice(sessionId: string, errorId: string, action: string, parameters?: any): Promise<void> {
|
||||
try {
|
||||
await server.post<any>(`llm/chat/${sessionId}/error/${errorId}/recover`, {
|
||||
action: action,
|
||||
parameters: parameters
|
||||
});
|
||||
console.log(`Error recovery choice sent: ${errorId} -> ${action}`);
|
||||
} catch (error) {
|
||||
console.error('Error sending error recovery choice:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel ongoing operations
|
||||
* @param sessionId - The chat session ID
|
||||
*/
|
||||
export async function cancelChatOperations(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await server.post<any>(`llm/chat/${sessionId}/cancel`, {});
|
||||
console.log(`Chat operations cancelled for session: ${sessionId}`);
|
||||
} catch (error) {
|
||||
console.error('Error cancelling chat operations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
968
apps/client/src/widgets/llm_chat/enhanced_components.css
Normal file
968
apps/client/src/widgets/llm_chat/enhanced_components.css
Normal file
@@ -0,0 +1,968 @@
|
||||
/* Enhanced LLM Chat Components CSS */
|
||||
|
||||
/* =======================
|
||||
PROGRESS INDICATOR STYLES
|
||||
======================= */
|
||||
|
||||
.llm-progress-container {
|
||||
background: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.llm-progress-container.fade-in {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.llm-progress-container.fade-out {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.llm-progress-header {
|
||||
padding: 15px 20px 10px;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-progress-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--main-text-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.llm-progress-overall {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.llm-progress-bar-container {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.llm-progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-color), var(--accent-color-darker));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.llm-progress-percentage {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-text-color);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.llm-progress-stages {
|
||||
padding: 15px 20px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.llm-progress-stage {
|
||||
margin-bottom: 15px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.llm-progress-stage:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stage-status-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stage-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.stage-timing {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.stage-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stage-progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stage-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-color);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.stage-progress-text {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
min-width: 35px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.stage-message {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
margin-left: 30px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Stage status styles */
|
||||
.stage-pending .stage-progress-fill {
|
||||
background: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.stage-running .stage-progress-fill {
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
.stage-completed .stage-progress-fill {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.stage-failed .stage-progress-fill {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.llm-progress-footer {
|
||||
padding: 10px 20px 15px;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.llm-progress-time-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-progress-cancel-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.llm-progress-cancel-btn:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.llm-progress-cancel-btn:disabled {
|
||||
background: var(--muted-text-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* =======================
|
||||
USER INTERACTION STYLES
|
||||
======================= */
|
||||
|
||||
.llm-interaction-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.llm-interaction-overlay.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.llm-interaction-modal-container {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.llm-interaction-modal {
|
||||
background: var(--main-background-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.llm-interaction-modal.show {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 20px 15px;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header.risk-high {
|
||||
background: linear-gradient(135deg, #dc3545, #c82333);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-header.risk-medium {
|
||||
background: linear-gradient(135deg, #ffc107, #e0a800);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.modal-header.risk-low {
|
||||
background: linear-gradient(135deg, #28a745, #1e7e34);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.risk-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.risk-label {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
font-size: 14px;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tool-arguments {
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.arguments-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.arguments-content {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.argument-item {
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.argument-key {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.argument-value {
|
||||
color: var(--main-text-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.no-arguments {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.confirmation-message,
|
||||
.choice-message,
|
||||
.input-message {
|
||||
font-size: 14px;
|
||||
color: var(--main-text-color);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.choice-options {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.choice-option {
|
||||
background: var(--accented-background-color);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.choice-option:hover {
|
||||
border-color: var(--accent-color);
|
||||
background: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-weight: 600;
|
||||
color: var(--main-text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.option-description {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.input-field input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--main-background-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.input-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.2);
|
||||
}
|
||||
|
||||
.timeout-indicator {
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.timeout-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeout-countdown {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.countdown-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--main-border-color);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.countdown-fill {
|
||||
height: 100%;
|
||||
background: #ffc107;
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.countdown-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 15px 20px 20px;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-color-darker);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--muted-text-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--main-text-color);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
/* =======================
|
||||
ERROR RECOVERY STYLES
|
||||
======================= */
|
||||
|
||||
.llm-error-recovery-container {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.llm-error-recovery-item {
|
||||
background: var(--main-background-color);
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.llm-error-recovery-item.fade-out {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.error-header {
|
||||
background: linear-gradient(135deg, #dc3545, #c82333);
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-tool-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.error-attempt-info {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.error-type-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-message-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.error-message-content {
|
||||
background: var(--accented-background-color);
|
||||
border-left: 4px solid #dc3545;
|
||||
padding: 10px 12px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 14px;
|
||||
color: var(--main-text-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.error-context {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.context-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.context-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.context-content {
|
||||
background: var(--accented-background-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.param-key {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: var(--main-text-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.previous-attempts-list,
|
||||
.suggestions-list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.previous-attempts-list li,
|
||||
.suggestions-list li {
|
||||
margin-bottom: 4px;
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.auto-retry-section {
|
||||
background: linear-gradient(135deg, #ffc107, #e0a800);
|
||||
color: #212529;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.auto-retry-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.retry-countdown {
|
||||
font-weight: 700;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.auto-retry-progress {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.retry-progress-bar {
|
||||
height: 6px;
|
||||
background: rgba(33, 37, 41, 0.2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.retry-progress-fill {
|
||||
height: 100%;
|
||||
background: #dc3545;
|
||||
border-radius: 3px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
.cancel-auto-retry {
|
||||
background: rgba(33, 37, 41, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-auto-retry:hover {
|
||||
background: #212529;
|
||||
}
|
||||
|
||||
.recovery-actions {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.recovery-actions-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--main-text-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.recovery-actions-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recovery-action {
|
||||
background: var(--accented-background-color);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recovery-action:hover {
|
||||
border-color: var(--accent-color);
|
||||
background: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.action-retry:hover {
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.action-skip:hover {
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
.action-modify:hover {
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
.action-abort:hover {
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.action-alternative:hover {
|
||||
border-color: #17a2b8;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-retry .action-icon {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.action-skip .action-icon {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
.action-modify .action-icon {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.action-abort .action-icon {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.action-alternative .action-icon {
|
||||
background: #17a2b8;
|
||||
}
|
||||
|
||||
.action-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--main-text-color);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.action-description {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.action-arrow {
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.recovery-action:hover .action-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
/* =======================
|
||||
RESPONSIVE DESIGN
|
||||
======================= */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.llm-interaction-modal {
|
||||
min-width: auto;
|
||||
width: 90vw;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 15px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 15px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.llm-progress-header,
|
||||
.llm-progress-stages,
|
||||
.llm-progress-footer {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.llm-progress-footer {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.recovery-actions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================
|
||||
DARK MODE ADJUSTMENTS
|
||||
======================= */
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.llm-interaction-overlay {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.countdown-fill {
|
||||
background: #f39c12;
|
||||
}
|
||||
|
||||
.auto-retry-section {
|
||||
background: linear-gradient(135deg, #f39c12, #d68910);
|
||||
color: #212529;
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================
|
||||
ANIMATIONS
|
||||
======================= */
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-running .stage-status-icon i {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-error-recovery-item {
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-running .stage-progress-fill {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--accent-color) 0%,
|
||||
var(--accent-color-lighter) 50%,
|
||||
var(--accent-color) 100%
|
||||
);
|
||||
background-size: 200px 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
511
apps/client/src/widgets/llm_chat/enhanced_tool_integration.ts
Normal file
511
apps/client/src/widgets/llm_chat/enhanced_tool_integration.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Enhanced Tool Integration
|
||||
*
|
||||
* Integrates tool preview, feedback, and error recovery into the LLM chat experience.
|
||||
*/
|
||||
|
||||
import server from "../../services/server.js";
|
||||
import { ToolPreviewUI, type ExecutionPlanData, type UserApproval } from "./tool_preview_ui.js";
|
||||
import { ToolFeedbackUI, type ToolProgressData, type ToolStepData } from "./tool_feedback_ui.js";
|
||||
|
||||
/**
|
||||
* Enhanced tool integration configuration
|
||||
*/
|
||||
export interface EnhancedToolConfig {
|
||||
enablePreview?: boolean;
|
||||
enableFeedback?: boolean;
|
||||
enableErrorRecovery?: boolean;
|
||||
requireConfirmation?: boolean;
|
||||
autoApproveTimeout?: number;
|
||||
showHistory?: boolean;
|
||||
showStatistics?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: EnhancedToolConfig = {
|
||||
enablePreview: true,
|
||||
enableFeedback: true,
|
||||
enableErrorRecovery: true,
|
||||
requireConfirmation: true,
|
||||
autoApproveTimeout: 30000, // 30 seconds
|
||||
showHistory: true,
|
||||
showStatistics: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced Tool Integration Manager
|
||||
*/
|
||||
export class EnhancedToolIntegration {
|
||||
private config: EnhancedToolConfig;
|
||||
private previewUI?: ToolPreviewUI;
|
||||
private feedbackUI?: ToolFeedbackUI;
|
||||
private container: HTMLElement;
|
||||
private eventHandlers: Map<string, Function[]> = new Map();
|
||||
private activeExecutions: Set<string> = new Set();
|
||||
|
||||
constructor(container: HTMLElement, config?: Partial<EnhancedToolConfig>) {
|
||||
this.container = container;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the integration
|
||||
*/
|
||||
private initialize(): void {
|
||||
// Create UI containers
|
||||
this.createUIContainers();
|
||||
|
||||
// Initialize UI components
|
||||
if (this.config.enablePreview) {
|
||||
const previewContainer = this.container.querySelector('.tool-preview-area') as HTMLElement;
|
||||
if (previewContainer) {
|
||||
this.previewUI = new ToolPreviewUI(previewContainer);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.enableFeedback) {
|
||||
const feedbackContainer = this.container.querySelector('.tool-feedback-area') as HTMLElement;
|
||||
if (feedbackContainer) {
|
||||
this.feedbackUI = new ToolFeedbackUI(feedbackContainer);
|
||||
|
||||
// Set up history and stats containers if enabled
|
||||
if (this.config.showHistory) {
|
||||
const historyContainer = this.container.querySelector('.tool-history-area') as HTMLElement;
|
||||
if (historyContainer) {
|
||||
this.feedbackUI.setHistoryContainer(historyContainer);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.showStatistics) {
|
||||
const statsContainer = this.container.querySelector('.tool-stats-area') as HTMLElement;
|
||||
if (statsContainer) {
|
||||
this.feedbackUI.setStatsContainer(statsContainer);
|
||||
this.loadStatistics();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
this.loadActiveExecutions();
|
||||
this.loadCircuitBreakerStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create UI containers
|
||||
*/
|
||||
private createUIContainers(): void {
|
||||
// Add enhanced tool UI areas if they don't exist
|
||||
if (!this.container.querySelector('.tool-preview-area')) {
|
||||
const previewArea = document.createElement('div');
|
||||
previewArea.className = 'tool-preview-area mb-3';
|
||||
this.container.appendChild(previewArea);
|
||||
}
|
||||
|
||||
if (!this.container.querySelector('.tool-feedback-area')) {
|
||||
const feedbackArea = document.createElement('div');
|
||||
feedbackArea.className = 'tool-feedback-area mb-3';
|
||||
this.container.appendChild(feedbackArea);
|
||||
}
|
||||
|
||||
if (this.config.showHistory && !this.container.querySelector('.tool-history-area')) {
|
||||
const historySection = document.createElement('div');
|
||||
historySection.className = 'tool-history-section mt-3';
|
||||
historySection.innerHTML = `
|
||||
<details class="small">
|
||||
<summary class="text-muted cursor-pointer">
|
||||
<i class="bx bx-history me-1"></i>
|
||||
Execution History
|
||||
</summary>
|
||||
<div class="tool-history-area mt-2"></div>
|
||||
</details>
|
||||
`;
|
||||
this.container.appendChild(historySection);
|
||||
}
|
||||
|
||||
if (this.config.showStatistics && !this.container.querySelector('.tool-stats-area')) {
|
||||
const statsSection = document.createElement('div');
|
||||
statsSection.className = 'tool-stats-section mt-3';
|
||||
statsSection.innerHTML = `
|
||||
<details class="small">
|
||||
<summary class="text-muted cursor-pointer">
|
||||
<i class="bx bx-bar-chart me-1"></i>
|
||||
Tool Statistics
|
||||
</summary>
|
||||
<div class="tool-stats-area mt-2"></div>
|
||||
</details>
|
||||
`;
|
||||
this.container.appendChild(statsSection);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tool preview request
|
||||
*/
|
||||
public async handleToolPreview(toolCalls: any[]): Promise<UserApproval | null> {
|
||||
if (!this.config.enablePreview || !this.previewUI) {
|
||||
// Auto-approve if preview is disabled
|
||||
return {
|
||||
planId: `auto-${Date.now()}`,
|
||||
approved: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get preview from server
|
||||
const response = await server.post<ExecutionPlanData>('api/llm-tools/preview', {
|
||||
toolCalls
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
console.error('Failed to get tool preview');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show preview and wait for user approval
|
||||
return new Promise((resolve) => {
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const handleApproval = (approval: UserApproval) => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Send approval to server
|
||||
server.post(`api/llm-tools/preview/${approval.planId}/approval`, approval)
|
||||
.catch(error => console.error('Failed to record approval:', error));
|
||||
|
||||
resolve(approval);
|
||||
};
|
||||
|
||||
// Show preview UI
|
||||
this.previewUI!.showPreview(response, handleApproval);
|
||||
|
||||
// Auto-approve after timeout if configured
|
||||
if (this.config.autoApproveTimeout && response.requiresConfirmation) {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
const autoApproval: UserApproval = {
|
||||
planId: response.id,
|
||||
approved: true
|
||||
};
|
||||
handleApproval(autoApproval);
|
||||
}, this.config.autoApproveTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error handling tool preview:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tool execution tracking
|
||||
*/
|
||||
public startToolExecution(
|
||||
executionId: string,
|
||||
toolName: string,
|
||||
displayName?: string
|
||||
): void {
|
||||
if (!this.config.enableFeedback || !this.feedbackUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeExecutions.add(executionId);
|
||||
this.feedbackUI.startExecution(executionId, toolName, displayName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tool execution progress
|
||||
*/
|
||||
public updateToolProgress(data: ToolProgressData): void {
|
||||
if (!this.config.enableFeedback || !this.feedbackUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.feedbackUI.updateProgress(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tool execution step
|
||||
*/
|
||||
public addToolStep(data: ToolStepData): void {
|
||||
if (!this.config.enableFeedback || !this.feedbackUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.feedbackUI.addStep(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete tool execution
|
||||
*/
|
||||
public completeToolExecution(
|
||||
executionId: string,
|
||||
status: 'success' | 'error' | 'cancelled' | 'timeout',
|
||||
result?: any,
|
||||
error?: string
|
||||
): void {
|
||||
if (!this.config.enableFeedback || !this.feedbackUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeExecutions.delete(executionId);
|
||||
this.feedbackUI.completeExecution(executionId, status, result, error);
|
||||
|
||||
// Refresh statistics
|
||||
if (this.config.showStatistics) {
|
||||
setTimeout(() => this.loadStatistics(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel tool execution
|
||||
*/
|
||||
public async cancelToolExecution(executionId: string, reason?: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await server.post<any>(`api/llm-tools/executions/${executionId}/cancel`, {
|
||||
reason
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
this.completeToolExecution(executionId, 'cancelled', undefined, reason);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel execution:', error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load active executions
|
||||
*/
|
||||
private async loadActiveExecutions(): Promise<void> {
|
||||
if (!this.config.enableFeedback) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const executions = await server.get<any[]>('api/llm-tools/executions/active');
|
||||
|
||||
if (executions && Array.isArray(executions)) {
|
||||
executions.forEach(exec => {
|
||||
if (!this.activeExecutions.has(exec.id)) {
|
||||
this.startToolExecution(exec.id, exec.toolName);
|
||||
// Restore progress if available
|
||||
if (exec.progress) {
|
||||
this.updateToolProgress({
|
||||
executionId: exec.id,
|
||||
...exec.progress
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load active executions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load execution statistics
|
||||
*/
|
||||
private async loadStatistics(): Promise<void> {
|
||||
if (!this.config.showStatistics) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await server.get<any>('api/llm-tools/executions/stats');
|
||||
|
||||
if (stats) {
|
||||
this.displayStatistics(stats);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load statistics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display statistics
|
||||
*/
|
||||
private displayStatistics(stats: any): void {
|
||||
const container = this.container.querySelector('.tool-stats-area') as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="tool-stats-container">
|
||||
<div class="tool-stat-item">
|
||||
<div class="tool-stat-value">${stats.totalExecutions}</div>
|
||||
<div class="tool-stat-label">Total</div>
|
||||
</div>
|
||||
<div class="tool-stat-item">
|
||||
<div class="tool-stat-value text-success">${stats.successfulExecutions}</div>
|
||||
<div class="tool-stat-label">Success</div>
|
||||
</div>
|
||||
<div class="tool-stat-item">
|
||||
<div class="tool-stat-value text-danger">${stats.failedExecutions}</div>
|
||||
<div class="tool-stat-label">Failed</div>
|
||||
</div>
|
||||
<div class="tool-stat-item">
|
||||
<div class="tool-stat-value">${this.formatDuration(stats.averageDuration)}</div>
|
||||
<div class="tool-stat-label">Avg Time</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add tool-specific statistics if available
|
||||
if (stats.toolStatistics && Object.keys(stats.toolStatistics).length > 0) {
|
||||
const toolStatsHtml = Object.entries(stats.toolStatistics)
|
||||
.map(([toolName, toolStats]: [string, any]) => `
|
||||
<tr>
|
||||
<td>${toolName}</td>
|
||||
<td>${toolStats.count}</td>
|
||||
<td>${toolStats.successRate}%</td>
|
||||
<td>${this.formatDuration(toolStats.averageDuration)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML += `
|
||||
<div class="mt-3">
|
||||
<h6 class="small text-muted">Per-Tool Statistics</h6>
|
||||
<table class="table table-sm small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tool</th>
|
||||
<th>Count</th>
|
||||
<th>Success</th>
|
||||
<th>Avg Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${toolStatsHtml}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load circuit breaker status
|
||||
*/
|
||||
private async loadCircuitBreakerStatus(): Promise<void> {
|
||||
try {
|
||||
const statuses = await server.get<any[]>('api/llm-tools/circuit-breakers');
|
||||
|
||||
if (statuses && Array.isArray(statuses)) {
|
||||
this.displayCircuitBreakerStatus(statuses);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load circuit breaker status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display circuit breaker status
|
||||
*/
|
||||
private displayCircuitBreakerStatus(statuses: any[]): void {
|
||||
const openBreakers = statuses.filter(s => s.state === 'open');
|
||||
const halfOpenBreakers = statuses.filter(s => s.state === 'half_open');
|
||||
|
||||
if (openBreakers.length > 0 || halfOpenBreakers.length > 0) {
|
||||
const alertContainer = document.createElement('div');
|
||||
alertContainer.className = 'circuit-breaker-alerts mb-3';
|
||||
|
||||
if (openBreakers.length > 0) {
|
||||
alertContainer.innerHTML += `
|
||||
<div class="alert alert-danger small py-2">
|
||||
<i class="bx bx-error-circle me-1"></i>
|
||||
<strong>Circuit Breakers Open:</strong>
|
||||
${openBreakers.map(b => b.toolName).join(', ')}
|
||||
<button class="btn btn-sm btn-link reset-breakers-btn float-end py-0">
|
||||
Reset All
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (halfOpenBreakers.length > 0) {
|
||||
alertContainer.innerHTML += `
|
||||
<div class="alert alert-warning small py-2">
|
||||
<i class="bx bx-error me-1"></i>
|
||||
<strong>Circuit Breakers Half-Open:</strong>
|
||||
${halfOpenBreakers.map(b => b.toolName).join(', ')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add to container
|
||||
const existingAlerts = this.container.querySelector('.circuit-breaker-alerts');
|
||||
if (existingAlerts) {
|
||||
existingAlerts.replaceWith(alertContainer);
|
||||
} else {
|
||||
this.container.insertBefore(alertContainer, this.container.firstChild);
|
||||
}
|
||||
|
||||
// Add reset handler
|
||||
const resetBtn = alertContainer.querySelector('.reset-breakers-btn');
|
||||
resetBtn?.addEventListener('click', () => this.resetAllCircuitBreakers(openBreakers));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all circuit breakers
|
||||
*/
|
||||
private async resetAllCircuitBreakers(breakers: any[]): Promise<void> {
|
||||
for (const breaker of breakers) {
|
||||
try {
|
||||
await server.post(`api/llm-tools/circuit-breakers/${breaker.toolName}/reset`, {});
|
||||
} catch (error) {
|
||||
console.error(`Failed to reset circuit breaker for ${breaker.toolName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload status
|
||||
this.loadCircuitBreakerStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration
|
||||
*/
|
||||
private formatDuration(milliseconds: number): string {
|
||||
if (!milliseconds || milliseconds === 0) return '0ms';
|
||||
if (milliseconds < 1000) {
|
||||
return `${Math.round(milliseconds)}ms`;
|
||||
} else if (milliseconds < 60000) {
|
||||
return `${(milliseconds / 1000).toFixed(1)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.eventHandlers.clear();
|
||||
this.activeExecutions.clear();
|
||||
|
||||
if (this.feedbackUI) {
|
||||
this.feedbackUI.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enhanced tool integration
|
||||
*/
|
||||
export function createEnhancedToolIntegration(
|
||||
container: HTMLElement,
|
||||
config?: Partial<EnhancedToolConfig>
|
||||
): EnhancedToolIntegration {
|
||||
return new EnhancedToolIntegration(container, config);
|
||||
}
|
||||
451
apps/client/src/widgets/llm_chat/error_recovery_manager.ts
Normal file
451
apps/client/src/widgets/llm_chat/error_recovery_manager.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
interface ErrorRecoveryOptions {
|
||||
errorId: string;
|
||||
toolName: string;
|
||||
message: string;
|
||||
errorType: string;
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
recoveryActions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
action: 'retry' | 'skip' | 'modify' | 'abort' | 'alternative';
|
||||
parameters?: Record<string, unknown>;
|
||||
}>;
|
||||
autoRetryIn?: number; // seconds
|
||||
context?: {
|
||||
originalParams?: Record<string, unknown>;
|
||||
previousAttempts?: string[];
|
||||
suggestions?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ErrorRecoveryResponse {
|
||||
errorId: string;
|
||||
action: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Recovery Manager for LLM Chat
|
||||
* Handles sophisticated error recovery with multiple strategies and user guidance
|
||||
*/
|
||||
export class ErrorRecoveryManager {
|
||||
private activeErrors: Map<string, ErrorRecoveryOptions> = new Map();
|
||||
private responseCallbacks: Map<string, (response: ErrorRecoveryResponse) => void> = new Map();
|
||||
private container: HTMLElement;
|
||||
|
||||
constructor(parentElement: HTMLElement) {
|
||||
this.container = this.createErrorContainer();
|
||||
parentElement.appendChild(this.container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error recovery container
|
||||
*/
|
||||
private createErrorContainer(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'llm-error-recovery-container';
|
||||
container.style.display = 'none';
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error recovery options
|
||||
*/
|
||||
public async showErrorRecovery(options: ErrorRecoveryOptions): Promise<ErrorRecoveryResponse> {
|
||||
this.activeErrors.set(options.errorId, options);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.responseCallbacks.set(options.errorId, resolve);
|
||||
|
||||
const errorElement = this.createErrorElement(options);
|
||||
this.container.appendChild(errorElement);
|
||||
this.container.style.display = 'block';
|
||||
|
||||
// Start auto-retry countdown if enabled
|
||||
if (options.autoRetryIn && options.autoRetryIn > 0) {
|
||||
this.startAutoRetryCountdown(options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error recovery element
|
||||
*/
|
||||
private createErrorElement(options: ErrorRecoveryOptions): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'llm-error-recovery-item';
|
||||
element.setAttribute('data-error-id', options.errorId);
|
||||
|
||||
element.innerHTML = `
|
||||
<div class="error-header">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div class="error-title">
|
||||
<div class="error-tool-name">${options.toolName} Failed</div>
|
||||
<div class="error-attempt-info">Attempt ${options.attempt}/${options.maxAttempts}</div>
|
||||
</div>
|
||||
<div class="error-type-badge ${this.getErrorTypeBadgeClass(options.errorType)}">
|
||||
${options.errorType}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-body">
|
||||
<div class="error-message">
|
||||
<div class="error-message-label">Error Details:</div>
|
||||
<div class="error-message-content">${options.message}</div>
|
||||
</div>
|
||||
|
||||
${this.createContextSection(options.context)}
|
||||
${this.createAutoRetrySection(options.autoRetryIn)}
|
||||
|
||||
<div class="recovery-actions">
|
||||
<div class="recovery-actions-label">Recovery Options:</div>
|
||||
<div class="recovery-actions-grid">
|
||||
${this.createRecoveryActions(options)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachErrorEvents(element, options);
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create context section
|
||||
*/
|
||||
private createContextSection(context?: ErrorRecoveryOptions['context']): string {
|
||||
if (!context) return '';
|
||||
|
||||
return `
|
||||
<div class="error-context">
|
||||
${context.originalParams ? `
|
||||
<div class="context-section">
|
||||
<div class="context-label">Original Parameters:</div>
|
||||
<div class="context-content">
|
||||
${this.formatParameters(context.originalParams)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${context.previousAttempts && context.previousAttempts.length > 0 ? `
|
||||
<div class="context-section">
|
||||
<div class="context-label">Previous Attempts:</div>
|
||||
<div class="context-content">
|
||||
<ul class="previous-attempts-list">
|
||||
${context.previousAttempts.map(attempt => `<li>${attempt}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${context.suggestions && context.suggestions.length > 0 ? `
|
||||
<div class="context-section">
|
||||
<div class="context-label">Suggestions:</div>
|
||||
<div class="context-content">
|
||||
<ul class="suggestions-list">
|
||||
${context.suggestions.map(suggestion => `<li>${suggestion}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auto-retry section
|
||||
*/
|
||||
private createAutoRetrySection(autoRetryIn?: number): string {
|
||||
if (!autoRetryIn || autoRetryIn <= 0) return '';
|
||||
|
||||
return `
|
||||
<div class="auto-retry-section">
|
||||
<div class="auto-retry-info">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>Auto-retry in <span class="retry-countdown">${autoRetryIn}</span> seconds</span>
|
||||
</div>
|
||||
<div class="auto-retry-progress">
|
||||
<div class="retry-progress-bar">
|
||||
<div class="retry-progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary cancel-auto-retry">Cancel Auto-retry</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create recovery actions
|
||||
*/
|
||||
private createRecoveryActions(options: ErrorRecoveryOptions): string {
|
||||
return options.recoveryActions.map(action => {
|
||||
const actionClass = this.getActionClass(action.action);
|
||||
const icon = this.getActionIcon(action.action);
|
||||
|
||||
return `
|
||||
<div class="recovery-action ${actionClass}" data-action-id="${action.id}">
|
||||
<div class="action-icon">
|
||||
<i class="${icon}"></i>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<div class="action-label">${action.label}</div>
|
||||
${action.description ? `<div class="action-description">${action.description}</div>` : ''}
|
||||
</div>
|
||||
<div class="action-arrow">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format parameters for display
|
||||
*/
|
||||
private formatParameters(params: Record<string, unknown>): string {
|
||||
return Object.entries(params).map(([key, value]) => {
|
||||
let displayValue: string;
|
||||
if (typeof value === 'string') {
|
||||
displayValue = value.length > 50 ? value.substring(0, 50) + '...' : value;
|
||||
displayValue = `"${displayValue}"`;
|
||||
} else if (typeof value === 'object') {
|
||||
displayValue = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
displayValue = String(value);
|
||||
}
|
||||
|
||||
return `<div class="param-item">
|
||||
<span class="param-key">${key}:</span>
|
||||
<span class="param-value">${displayValue}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error type badge class
|
||||
*/
|
||||
private getErrorTypeBadgeClass(errorType: string): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
'NetworkError': 'badge-warning',
|
||||
'TimeoutError': 'badge-warning',
|
||||
'ValidationError': 'badge-danger',
|
||||
'NotFoundError': 'badge-info',
|
||||
'PermissionError': 'badge-danger',
|
||||
'RateLimitError': 'badge-warning',
|
||||
'UnknownError': 'badge-secondary'
|
||||
};
|
||||
|
||||
return typeMap[errorType] || 'badge-secondary';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action class
|
||||
*/
|
||||
private getActionClass(action: string): string {
|
||||
const actionMap: Record<string, string> = {
|
||||
'retry': 'action-retry',
|
||||
'skip': 'action-skip',
|
||||
'modify': 'action-modify',
|
||||
'abort': 'action-abort',
|
||||
'alternative': 'action-alternative'
|
||||
};
|
||||
|
||||
return actionMap[action] || 'action-default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action icon
|
||||
*/
|
||||
private getActionIcon(action: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
'retry': 'fas fa-redo',
|
||||
'skip': 'fas fa-forward',
|
||||
'modify': 'fas fa-edit',
|
||||
'abort': 'fas fa-times',
|
||||
'alternative': 'fas fa-route'
|
||||
};
|
||||
|
||||
return iconMap[action] || 'fas fa-cog';
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach error events
|
||||
*/
|
||||
private attachErrorEvents(element: HTMLElement, options: ErrorRecoveryOptions): void {
|
||||
// Recovery action clicks
|
||||
const actions = element.querySelectorAll('.recovery-action');
|
||||
actions.forEach(action => {
|
||||
action.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const actionId = target.getAttribute('data-action-id');
|
||||
if (actionId) {
|
||||
const recoveryAction = options.recoveryActions.find(a => a.id === actionId);
|
||||
if (recoveryAction) {
|
||||
this.executeRecoveryAction(options.errorId, recoveryAction);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel auto-retry
|
||||
const cancelAutoRetry = element.querySelector('.cancel-auto-retry');
|
||||
if (cancelAutoRetry) {
|
||||
cancelAutoRetry.addEventListener('click', () => {
|
||||
this.cancelAutoRetry(options.errorId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-retry countdown
|
||||
*/
|
||||
private startAutoRetryCountdown(options: ErrorRecoveryOptions): void {
|
||||
if (!options.autoRetryIn) return;
|
||||
|
||||
const element = this.container.querySelector(`[data-error-id="${options.errorId}"]`) as HTMLElement;
|
||||
if (!element) return;
|
||||
|
||||
const countdownElement = element.querySelector('.retry-countdown') as HTMLElement;
|
||||
const progressFill = element.querySelector('.retry-progress-fill') as HTMLElement;
|
||||
|
||||
let remainingTime = options.autoRetryIn;
|
||||
const totalTime = options.autoRetryIn;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
remainingTime--;
|
||||
|
||||
if (countdownElement) {
|
||||
countdownElement.textContent = remainingTime.toString();
|
||||
}
|
||||
|
||||
if (progressFill) {
|
||||
const progress = ((totalTime - remainingTime) / totalTime) * 100;
|
||||
progressFill.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
if (remainingTime <= 0) {
|
||||
clearInterval(interval);
|
||||
// Auto-execute retry
|
||||
const retryAction = options.recoveryActions.find(a => a.action === 'retry');
|
||||
if (retryAction) {
|
||||
this.executeRecoveryAction(options.errorId, retryAction);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Store interval for potential cancellation
|
||||
element.setAttribute('data-retry-interval', interval.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel auto-retry
|
||||
*/
|
||||
private cancelAutoRetry(errorId: string): void {
|
||||
const element = this.container.querySelector(`[data-error-id="${errorId}"]`) as HTMLElement;
|
||||
if (!element) return;
|
||||
|
||||
const intervalId = element.getAttribute('data-retry-interval');
|
||||
if (intervalId) {
|
||||
clearInterval(parseInt(intervalId));
|
||||
element.removeAttribute('data-retry-interval');
|
||||
}
|
||||
|
||||
// Hide auto-retry section
|
||||
const autoRetrySection = element.querySelector('.auto-retry-section') as HTMLElement;
|
||||
if (autoRetrySection) {
|
||||
autoRetrySection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute recovery action
|
||||
*/
|
||||
private executeRecoveryAction(errorId: string, action: ErrorRecoveryOptions['recoveryActions'][0]): void {
|
||||
const callback = this.responseCallbacks.get(errorId);
|
||||
if (!callback) return;
|
||||
|
||||
const response: ErrorRecoveryResponse = {
|
||||
errorId,
|
||||
action: action.action,
|
||||
parameters: action.parameters,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Clean up
|
||||
this.activeErrors.delete(errorId);
|
||||
this.responseCallbacks.delete(errorId);
|
||||
this.removeErrorElement(errorId);
|
||||
|
||||
// Call callback
|
||||
callback(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove error element
|
||||
*/
|
||||
private removeErrorElement(errorId: string): void {
|
||||
const element = this.container.querySelector(`[data-error-id="${errorId}"]`) as HTMLElement;
|
||||
if (element) {
|
||||
element.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
element.remove();
|
||||
|
||||
// Hide container if no more errors
|
||||
if (this.container.children.length === 0) {
|
||||
this.container.style.display = 'none';
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all errors
|
||||
*/
|
||||
public clearAllErrors(): void {
|
||||
this.activeErrors.clear();
|
||||
this.responseCallbacks.clear();
|
||||
this.container.innerHTML = '';
|
||||
this.container.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active error count
|
||||
*/
|
||||
public getActiveErrorCount(): number {
|
||||
return this.activeErrors.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error recovery is active
|
||||
*/
|
||||
public hasActiveErrors(): boolean {
|
||||
return this.activeErrors.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update error context (for adding new information)
|
||||
*/
|
||||
public updateErrorContext(errorId: string, newContext: Partial<ErrorRecoveryOptions['context']>): void {
|
||||
const options = this.activeErrors.get(errorId);
|
||||
if (!options) return;
|
||||
|
||||
options.context = { ...options.context, ...newContext };
|
||||
|
||||
// Re-render the context section
|
||||
const element = this.container.querySelector(`[data-error-id="${errorId}"]`) as HTMLElement;
|
||||
if (element) {
|
||||
const contextContainer = element.querySelector('.error-context') as HTMLElement;
|
||||
if (contextContainer) {
|
||||
contextContainer.outerHTML = this.createContextSection(options.context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { ErrorRecoveryOptions, ErrorRecoveryResponse };
|
||||
529
apps/client/src/widgets/llm_chat/interaction_manager.ts
Normal file
529
apps/client/src/widgets/llm_chat/interaction_manager.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
interface UserInteractionRequest {
|
||||
id: string;
|
||||
type: 'confirmation' | 'choice' | 'input' | 'tool_confirmation';
|
||||
title: string;
|
||||
message: string;
|
||||
options?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
style?: 'primary' | 'secondary' | 'warning' | 'danger';
|
||||
action?: string;
|
||||
}>;
|
||||
defaultValue?: string;
|
||||
timeout?: number; // milliseconds
|
||||
tool?: {
|
||||
name: string;
|
||||
description: string;
|
||||
arguments: Record<string, unknown>;
|
||||
riskLevel?: 'low' | 'medium' | 'high';
|
||||
};
|
||||
}
|
||||
|
||||
interface UserInteractionResponse {
|
||||
id: string;
|
||||
response: string;
|
||||
value?: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Interaction Manager for LLM Chat
|
||||
* Handles confirmations, choices, and input prompts during LLM operations
|
||||
*/
|
||||
export class InteractionManager {
|
||||
private activeInteractions: Map<string, UserInteractionRequest> = new Map();
|
||||
private responseCallbacks: Map<string, (response: UserInteractionResponse) => void> = new Map();
|
||||
private modalContainer: HTMLElement;
|
||||
private overlay: HTMLElement;
|
||||
|
||||
constructor(parentElement: HTMLElement) {
|
||||
this.createModalContainer(parentElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create modal container and overlay
|
||||
*/
|
||||
private createModalContainer(parentElement: HTMLElement): void {
|
||||
// Create overlay
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'llm-interaction-overlay';
|
||||
this.overlay.style.display = 'none';
|
||||
|
||||
// Create modal container
|
||||
this.modalContainer = document.createElement('div');
|
||||
this.modalContainer.className = 'llm-interaction-modal-container';
|
||||
|
||||
this.overlay.appendChild(this.modalContainer);
|
||||
parentElement.appendChild(this.overlay);
|
||||
|
||||
// Close on overlay click
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) {
|
||||
this.cancelAllInteractions();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.hasActiveInteractions()) {
|
||||
this.cancelAllInteractions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request user interaction
|
||||
*/
|
||||
public async requestUserInteraction(request: UserInteractionRequest): Promise<UserInteractionResponse> {
|
||||
this.activeInteractions.set(request.id, request);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Set up response callback
|
||||
this.responseCallbacks.set(request.id, resolve);
|
||||
|
||||
// Create and show modal
|
||||
const modal = this.createInteractionModal(request);
|
||||
this.showModal(modal);
|
||||
|
||||
// Set up timeout if specified
|
||||
if (request.timeout && request.timeout > 0) {
|
||||
setTimeout(() => {
|
||||
if (this.activeInteractions.has(request.id)) {
|
||||
this.handleTimeout(request.id);
|
||||
}
|
||||
}, request.timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create interaction modal based on request type
|
||||
*/
|
||||
private createInteractionModal(request: UserInteractionRequest): HTMLElement {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = `llm-interaction-modal llm-interaction-${request.type}`;
|
||||
modal.setAttribute('data-interaction-id', request.id);
|
||||
|
||||
switch (request.type) {
|
||||
case 'tool_confirmation':
|
||||
return this.createToolConfirmationModal(modal, request);
|
||||
case 'confirmation':
|
||||
return this.createConfirmationModal(modal, request);
|
||||
case 'choice':
|
||||
return this.createChoiceModal(modal, request);
|
||||
case 'input':
|
||||
return this.createInputModal(modal, request);
|
||||
default:
|
||||
return this.createGenericModal(modal, request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tool confirmation modal
|
||||
*/
|
||||
private createToolConfirmationModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
|
||||
const tool = request.tool!;
|
||||
const riskClass = tool.riskLevel ? `risk-${tool.riskLevel}` : '';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header ${riskClass}">
|
||||
<div class="modal-title">
|
||||
<i class="fas fa-tools"></i>
|
||||
Tool Execution Confirmation
|
||||
</div>
|
||||
<div class="risk-indicator ${riskClass}">
|
||||
<span class="risk-label">${(tool.riskLevel || 'medium').toUpperCase()} RISK</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="tool-info">
|
||||
<div class="tool-name">${tool.name}</div>
|
||||
<div class="tool-description">${tool.description}</div>
|
||||
</div>
|
||||
<div class="tool-arguments">
|
||||
<div class="arguments-label">Parameters:</div>
|
||||
<div class="arguments-content">
|
||||
${this.formatToolArguments(tool.arguments)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="confirmation-message">${request.message}</div>
|
||||
${this.createTimeoutIndicator(request.timeout)}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
${this.createActionButtons(request)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachButtonEvents(modal, request);
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create confirmation modal
|
||||
*/
|
||||
private createConfirmationModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
${request.title}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="confirmation-message">${request.message}</div>
|
||||
${this.createTimeoutIndicator(request.timeout)}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
${this.createActionButtons(request)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachButtonEvents(modal, request);
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create choice modal
|
||||
*/
|
||||
private createChoiceModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<i class="fas fa-list"></i>
|
||||
${request.title}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="choice-message">${request.message}</div>
|
||||
<div class="choice-options">
|
||||
${(request.options || []).map(option => `
|
||||
<div class="choice-option" data-option-id="${option.id}">
|
||||
<div class="option-label">${option.label}</div>
|
||||
${option.description ? `<div class="option-description">${option.description}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
${this.createTimeoutIndicator(request.timeout)}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary cancel-btn">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachChoiceEvents(modal, request);
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create input modal
|
||||
*/
|
||||
private createInputModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<i class="fas fa-edit"></i>
|
||||
${request.title}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="input-message">${request.message}</div>
|
||||
<div class="input-field">
|
||||
<input type="text" class="form-control" placeholder="Enter your response..."
|
||||
value="${request.defaultValue || ''}" autofocus>
|
||||
</div>
|
||||
${this.createTimeoutIndicator(request.timeout)}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary submit-btn">Submit</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachInputEvents(modal, request);
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create generic modal
|
||||
*/
|
||||
private createGenericModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">${request.title}</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="generic-message">${request.message}</div>
|
||||
${this.createTimeoutIndicator(request.timeout)}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
${this.createActionButtons(request)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachButtonEvents(modal, request);
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool arguments for display
|
||||
*/
|
||||
private formatToolArguments(args: Record<string, unknown>): string {
|
||||
const formatted = Object.entries(args).map(([key, value]) => {
|
||||
let displayValue: string;
|
||||
if (typeof value === 'string') {
|
||||
displayValue = value.length > 100 ? value.substring(0, 100) + '...' : value;
|
||||
displayValue = `"${displayValue}"`;
|
||||
} else if (typeof value === 'object') {
|
||||
displayValue = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
displayValue = String(value);
|
||||
}
|
||||
|
||||
return `<div class="argument-item">
|
||||
<span class="argument-key">${key}:</span>
|
||||
<span class="argument-value">${displayValue}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return formatted || '<div class="no-arguments">No parameters</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create action buttons based on request options
|
||||
*/
|
||||
private createActionButtons(request: UserInteractionRequest): string {
|
||||
if (request.options && request.options.length > 0) {
|
||||
return request.options.map(option => `
|
||||
<button class="btn btn-${option.style || 'secondary'} action-btn"
|
||||
data-action="${option.id}" data-response="${option.action || option.id}">
|
||||
${option.label}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
// Default confirmation buttons
|
||||
return `
|
||||
<button class="btn btn-secondary cancel-btn" data-response="cancel">Cancel</button>
|
||||
<button class="btn btn-primary confirm-btn" data-response="confirm">Confirm</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeout indicator
|
||||
*/
|
||||
private createTimeoutIndicator(timeout?: number): string {
|
||||
if (!timeout || timeout <= 0) return '';
|
||||
|
||||
return `
|
||||
<div class="timeout-indicator">
|
||||
<div class="timeout-label">Auto-cancel in:</div>
|
||||
<div class="timeout-countdown" data-timeout="${timeout}">
|
||||
<div class="countdown-bar">
|
||||
<div class="countdown-fill"></div>
|
||||
</div>
|
||||
<div class="countdown-text">${Math.ceil(timeout / 1000)}s</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show modal
|
||||
*/
|
||||
private showModal(modal: HTMLElement): void {
|
||||
this.modalContainer.innerHTML = '';
|
||||
this.modalContainer.appendChild(modal);
|
||||
this.overlay.style.display = 'flex';
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => {
|
||||
this.overlay.classList.add('show');
|
||||
modal.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
// Start timeout countdown if present
|
||||
this.startTimeoutCountdown(modal);
|
||||
|
||||
// Focus first input if present
|
||||
const firstInput = modal.querySelector('input, button') as HTMLElement;
|
||||
if (firstInput) {
|
||||
firstInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide modal
|
||||
*/
|
||||
private hideModal(): void {
|
||||
this.overlay.classList.remove('show');
|
||||
const modal = this.modalContainer.querySelector('.llm-interaction-modal') as HTMLElement;
|
||||
if (modal) {
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.overlay.style.display = 'none';
|
||||
this.modalContainer.innerHTML = '';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach button events
|
||||
*/
|
||||
private attachButtonEvents(modal: HTMLElement, request: UserInteractionRequest): void {
|
||||
const buttons = modal.querySelectorAll('.action-btn, .confirm-btn, .cancel-btn');
|
||||
buttons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const response = target.getAttribute('data-response') || 'cancel';
|
||||
this.respondToInteraction(request.id, response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach choice events
|
||||
*/
|
||||
private attachChoiceEvents(modal: HTMLElement, request: UserInteractionRequest): void {
|
||||
const options = modal.querySelectorAll('.choice-option');
|
||||
options.forEach(option => {
|
||||
option.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const optionId = target.getAttribute('data-option-id');
|
||||
if (optionId) {
|
||||
this.respondToInteraction(request.id, optionId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
const cancelBtn = modal.querySelector('.cancel-btn');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
this.respondToInteraction(request.id, 'cancel');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach input events
|
||||
*/
|
||||
private attachInputEvents(modal: HTMLElement, request: UserInteractionRequest): void {
|
||||
const input = modal.querySelector('input') as HTMLInputElement;
|
||||
const submitBtn = modal.querySelector('.submit-btn') as HTMLElement;
|
||||
const cancelBtn = modal.querySelector('.cancel-btn') as HTMLElement;
|
||||
|
||||
const submitValue = () => {
|
||||
const value = input.value.trim();
|
||||
this.respondToInteraction(request.id, 'submit', value);
|
||||
};
|
||||
|
||||
submitBtn.addEventListener('click', submitValue);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitValue();
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
this.respondToInteraction(request.id, 'cancel');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start timeout countdown
|
||||
*/
|
||||
private startTimeoutCountdown(modal: HTMLElement): void {
|
||||
const countdown = modal.querySelector('.timeout-countdown') as HTMLElement;
|
||||
if (!countdown) return;
|
||||
|
||||
const timeout = parseInt(countdown.getAttribute('data-timeout') || '0');
|
||||
if (timeout <= 0) return;
|
||||
|
||||
const startTime = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, timeout - elapsed);
|
||||
const progress = (elapsed / timeout) * 100;
|
||||
|
||||
// Update countdown bar
|
||||
const fill = countdown.querySelector('.countdown-fill') as HTMLElement;
|
||||
if (fill) {
|
||||
fill.style.width = `${Math.min(100, progress)}%`;
|
||||
}
|
||||
|
||||
// Update countdown text
|
||||
const text = countdown.querySelector('.countdown-text') as HTMLElement;
|
||||
if (text) {
|
||||
text.textContent = `${Math.ceil(remaining / 1000)}s`;
|
||||
}
|
||||
|
||||
// Stop when timeout reached
|
||||
if (remaining <= 0) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Store interval for cleanup
|
||||
countdown.setAttribute('data-interval', interval.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to interaction
|
||||
*/
|
||||
private respondToInteraction(id: string, response: string, value?: any): void {
|
||||
const callback = this.responseCallbacks.get(id);
|
||||
if (!callback) return;
|
||||
|
||||
const interactionResponse: UserInteractionResponse = {
|
||||
id,
|
||||
response,
|
||||
value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Clean up
|
||||
this.activeInteractions.delete(id);
|
||||
this.responseCallbacks.delete(id);
|
||||
this.hideModal();
|
||||
|
||||
// Call callback
|
||||
callback(interactionResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle interaction timeout
|
||||
*/
|
||||
private handleTimeout(id: string): void {
|
||||
this.respondToInteraction(id, 'timeout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all active interactions
|
||||
*/
|
||||
public cancelAllInteractions(): void {
|
||||
const activeIds = Array.from(this.activeInteractions.keys());
|
||||
activeIds.forEach(id => {
|
||||
this.respondToInteraction(id, 'cancel');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are active interactions
|
||||
*/
|
||||
public hasActiveInteractions(): boolean {
|
||||
return this.activeInteractions.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active interaction count
|
||||
*/
|
||||
public getActiveInteractionCount(): number {
|
||||
return this.activeInteractions.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { UserInteractionRequest, UserInteractionResponse };
|
||||
387
apps/client/src/widgets/llm_chat/progress_indicator.ts
Normal file
387
apps/client/src/widgets/llm_chat/progress_indicator.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
interface ProgressStage {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
progress: number; // 0-100
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
message?: string;
|
||||
estimatedDuration?: number;
|
||||
}
|
||||
|
||||
interface ProgressUpdate {
|
||||
stageId: string;
|
||||
progress: number;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
message?: string;
|
||||
estimatedTimeRemaining?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced Progress Indicator for LLM Chat Operations
|
||||
* Displays multi-stage progress with progress bars, timing, and status updates
|
||||
*/
|
||||
export class ProgressIndicator {
|
||||
private container: HTMLElement;
|
||||
private stages: Map<string, ProgressStage> = new Map();
|
||||
private overallProgress: number = 0;
|
||||
private isVisible: boolean = false;
|
||||
|
||||
constructor(parentElement: HTMLElement) {
|
||||
this.container = this.createProgressContainer();
|
||||
parentElement.appendChild(this.container);
|
||||
this.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the main progress container
|
||||
*/
|
||||
private createProgressContainer(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'llm-progress-container';
|
||||
container.innerHTML = `
|
||||
<div class="llm-progress-header">
|
||||
<div class="llm-progress-title">Processing...</div>
|
||||
<div class="llm-progress-overall">
|
||||
<div class="llm-progress-bar-container">
|
||||
<div class="llm-progress-bar-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="llm-progress-percentage">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="llm-progress-stages"></div>
|
||||
<div class="llm-progress-footer">
|
||||
<div class="llm-progress-time-info">
|
||||
<span class="elapsed-time">Elapsed: 0s</span>
|
||||
<span class="estimated-remaining">Est. remaining: --</span>
|
||||
</div>
|
||||
<button class="llm-progress-cancel-btn" title="Cancel operation">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the progress indicator
|
||||
*/
|
||||
public show(): void {
|
||||
if (!this.isVisible) {
|
||||
this.container.style.display = 'block';
|
||||
this.container.classList.add('fade-in');
|
||||
this.isVisible = true;
|
||||
this.startElapsedTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the progress indicator
|
||||
*/
|
||||
public hide(): void {
|
||||
if (this.isVisible) {
|
||||
this.container.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
this.container.style.display = 'none';
|
||||
this.container.classList.remove('fade-in', 'fade-out');
|
||||
this.isVisible = false;
|
||||
this.stopElapsedTimer();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new progress stage
|
||||
*/
|
||||
public addStage(stageId: string, label: string, estimatedDuration?: number): void {
|
||||
const stage: ProgressStage = {
|
||||
id: stageId,
|
||||
label,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
estimatedDuration
|
||||
};
|
||||
|
||||
this.stages.set(stageId, stage);
|
||||
this.renderStage(stage);
|
||||
this.updateOverallProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress for a specific stage
|
||||
*/
|
||||
public updateStageProgress(update: ProgressUpdate): void {
|
||||
const stage = this.stages.get(update.stageId);
|
||||
if (!stage) return;
|
||||
|
||||
// Update stage data
|
||||
stage.progress = Math.max(0, Math.min(100, update.progress));
|
||||
stage.status = update.status;
|
||||
stage.message = update.message;
|
||||
|
||||
// Set timing
|
||||
if (update.status === 'running' && !stage.startTime) {
|
||||
stage.startTime = Date.now();
|
||||
} else if ((update.status === 'completed' || update.status === 'failed') && stage.startTime && !stage.endTime) {
|
||||
stage.endTime = Date.now();
|
||||
}
|
||||
|
||||
this.renderStage(stage);
|
||||
this.updateOverallProgress();
|
||||
|
||||
if (update.estimatedTimeRemaining !== undefined) {
|
||||
this.updateEstimatedTime(update.estimatedTimeRemaining);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage as completed
|
||||
*/
|
||||
public completeStage(stageId: string): void {
|
||||
this.updateStageProgress({
|
||||
stageId,
|
||||
progress: 100,
|
||||
status: 'completed',
|
||||
message: 'Completed'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage as failed
|
||||
*/
|
||||
public failStage(stageId: string, message?: string): void {
|
||||
this.updateStageProgress({
|
||||
stageId,
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
message: message || 'Failed'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a specific stage
|
||||
*/
|
||||
private renderStage(stage: ProgressStage): void {
|
||||
const stagesContainer = this.container.querySelector('.llm-progress-stages') as HTMLElement;
|
||||
let stageElement = stagesContainer.querySelector(`[data-stage-id="${stage.id}"]`) as HTMLElement;
|
||||
|
||||
if (!stageElement) {
|
||||
stageElement = this.createStageElement(stage);
|
||||
stagesContainer.appendChild(stageElement);
|
||||
}
|
||||
|
||||
this.updateStageElement(stageElement, stage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new stage element
|
||||
*/
|
||||
private createStageElement(stage: ProgressStage): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'llm-progress-stage';
|
||||
element.setAttribute('data-stage-id', stage.id);
|
||||
|
||||
element.innerHTML = `
|
||||
<div class="stage-header">
|
||||
<div class="stage-status-icon">
|
||||
<i class="fas fa-circle"></i>
|
||||
</div>
|
||||
<div class="stage-label">${stage.label}</div>
|
||||
<div class="stage-timing"></div>
|
||||
</div>
|
||||
<div class="stage-progress">
|
||||
<div class="stage-progress-bar">
|
||||
<div class="stage-progress-fill"></div>
|
||||
</div>
|
||||
<div class="stage-progress-text">0%</div>
|
||||
</div>
|
||||
<div class="stage-message"></div>
|
||||
`;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stage element with current data
|
||||
*/
|
||||
private updateStageElement(element: HTMLElement, stage: ProgressStage): void {
|
||||
// Update status icon
|
||||
const icon = element.querySelector('.stage-status-icon i') as HTMLElement;
|
||||
icon.className = this.getStatusIcon(stage.status);
|
||||
|
||||
// Update progress bar
|
||||
const progressFill = element.querySelector('.stage-progress-fill') as HTMLElement;
|
||||
progressFill.style.width = `${stage.progress}%`;
|
||||
|
||||
// Update progress text
|
||||
const progressText = element.querySelector('.stage-progress-text') as HTMLElement;
|
||||
progressText.textContent = `${Math.round(stage.progress)}%`;
|
||||
|
||||
// Update message
|
||||
const messageElement = element.querySelector('.stage-message') as HTMLElement;
|
||||
messageElement.textContent = stage.message || '';
|
||||
messageElement.style.display = stage.message ? 'block' : 'none';
|
||||
|
||||
// Update timing
|
||||
const timingElement = element.querySelector('.stage-timing') as HTMLElement;
|
||||
timingElement.textContent = this.getStageTimingText(stage);
|
||||
|
||||
// Update stage status class
|
||||
element.className = `llm-progress-stage stage-${stage.status}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon for stage
|
||||
*/
|
||||
private getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'pending': return 'fas fa-circle text-muted';
|
||||
case 'running': return 'fas fa-spinner fa-spin text-primary';
|
||||
case 'completed': return 'fas fa-check-circle text-success';
|
||||
case 'failed': return 'fas fa-exclamation-circle text-danger';
|
||||
default: return 'fas fa-circle';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timing text for stage
|
||||
*/
|
||||
private getStageTimingText(stage: ProgressStage): string {
|
||||
if (stage.endTime && stage.startTime) {
|
||||
const duration = Math.round((stage.endTime - stage.startTime) / 1000);
|
||||
return `${duration}s`;
|
||||
} else if (stage.startTime) {
|
||||
const elapsed = Math.round((Date.now() - stage.startTime) / 1000);
|
||||
return `${elapsed}s`;
|
||||
} else if (stage.estimatedDuration) {
|
||||
return `~${stage.estimatedDuration / 1000}s`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update overall progress
|
||||
*/
|
||||
private updateOverallProgress(): void {
|
||||
if (this.stages.size === 0) {
|
||||
this.overallProgress = 0;
|
||||
} else {
|
||||
const totalProgress = Array.from(this.stages.values())
|
||||
.reduce((sum, stage) => sum + stage.progress, 0);
|
||||
this.overallProgress = totalProgress / this.stages.size;
|
||||
}
|
||||
|
||||
// Update overall progress bar
|
||||
const overallFill = this.container.querySelector('.llm-progress-bar-fill') as HTMLElement;
|
||||
overallFill.style.width = `${this.overallProgress}%`;
|
||||
|
||||
// Update percentage text
|
||||
const percentageText = this.container.querySelector('.llm-progress-percentage') as HTMLElement;
|
||||
percentageText.textContent = `${Math.round(this.overallProgress)}%`;
|
||||
|
||||
// Update title based on progress
|
||||
const titleElement = this.container.querySelector('.llm-progress-title') as HTMLElement;
|
||||
if (this.overallProgress >= 100) {
|
||||
titleElement.textContent = 'Completed';
|
||||
} else if (this.overallProgress > 0) {
|
||||
titleElement.textContent = 'Processing...';
|
||||
} else {
|
||||
titleElement.textContent = 'Starting...';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update estimated remaining time
|
||||
*/
|
||||
private updateEstimatedTime(seconds: number): void {
|
||||
const estimatedElement = this.container.querySelector('.estimated-remaining') as HTMLElement;
|
||||
if (seconds > 0) {
|
||||
estimatedElement.textContent = `Est. remaining: ${this.formatTime(seconds)}`;
|
||||
} else {
|
||||
estimatedElement.textContent = 'Est. remaining: --';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time in seconds to readable format
|
||||
*/
|
||||
private formatTime(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start elapsed time timer
|
||||
*/
|
||||
private elapsedTimer?: number;
|
||||
private startTime: number = Date.now();
|
||||
|
||||
private startElapsedTimer(): void {
|
||||
this.startTime = Date.now();
|
||||
this.elapsedTimer = window.setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - this.startTime) / 1000);
|
||||
const elapsedElement = this.container.querySelector('.elapsed-time') as HTMLElement;
|
||||
elapsedElement.textContent = `Elapsed: ${this.formatTime(elapsed)}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop elapsed time timer
|
||||
*/
|
||||
private stopElapsedTimer(): void {
|
||||
if (this.elapsedTimer) {
|
||||
clearInterval(this.elapsedTimer);
|
||||
this.elapsedTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stages and reset
|
||||
*/
|
||||
public reset(): void {
|
||||
this.stages.clear();
|
||||
const stagesContainer = this.container.querySelector('.llm-progress-stages') as HTMLElement;
|
||||
stagesContainer.innerHTML = '';
|
||||
this.overallProgress = 0;
|
||||
this.updateOverallProgress();
|
||||
this.stopElapsedTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cancel callback
|
||||
*/
|
||||
public onCancel(callback: () => void): void {
|
||||
const cancelBtn = this.container.querySelector('.llm-progress-cancel-btn') as HTMLElement;
|
||||
cancelBtn.onclick = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable cancel button
|
||||
*/
|
||||
public disableCancel(): void {
|
||||
const cancelBtn = this.container.querySelector('.llm-progress-cancel-btn') as HTMLButtonElement;
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.style.opacity = '0.5';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable cancel button
|
||||
*/
|
||||
public enableCancel(): void {
|
||||
const cancelBtn = this.container.querySelector('.llm-progress-cancel-btn') as HTMLButtonElement;
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { ProgressStage, ProgressUpdate };
|
||||
333
apps/client/src/widgets/llm_chat/tool_enhanced_ui.css
Normal file
333
apps/client/src/widgets/llm_chat/tool_enhanced_ui.css
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Enhanced Tool UI Styles
|
||||
* Styles for tool preview, feedback, and error recovery UI components
|
||||
*/
|
||||
|
||||
/* Tool Preview Styles */
|
||||
.tool-preview-container {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tool-preview-container.fade-out {
|
||||
animation: fadeOut 0.3s ease-out;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tool-preview-header {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-preview-item {
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-preview-item:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tool-preview-item input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-preview-item .parameter-item {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tool-preview-item .parameter-key {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-preview-item details summary {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-preview-item details summary:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tool-preview-actions button {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Tool Feedback Styles */
|
||||
.tool-execution-feedback {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-execution-feedback.fade-out {
|
||||
animation: fadeOut 0.3s ease-out;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tool-execution-feedback.border-success {
|
||||
border-color: var(--bs-success) !important;
|
||||
background-color: rgba(25, 135, 84, 0.05) !important;
|
||||
}
|
||||
|
||||
.tool-execution-feedback.border-danger {
|
||||
border-color: var(--bs-danger) !important;
|
||||
background-color: rgba(220, 53, 69, 0.05) !important;
|
||||
}
|
||||
|
||||
.tool-execution-feedback.border-warning {
|
||||
border-color: var(--bs-warning) !important;
|
||||
background-color: rgba(255, 193, 7, 0.05) !important;
|
||||
}
|
||||
|
||||
.tool-execution-feedback .progress {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tool-execution-feedback .progress-bar {
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-execution-feedback .tool-steps {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
padding-top: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-execution-feedback .tool-step {
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tool-execution-feedback .tool-step.tool-step-error {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
.tool-execution-feedback .tool-step.tool-step-warning {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
.tool-execution-feedback .tool-step.tool-step-progress {
|
||||
background-color: rgba(13, 110, 253, 0.1);
|
||||
}
|
||||
|
||||
.tool-execution-feedback .cancel-btn {
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-execution-feedback .cancel-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Real-time Progress Indicator */
|
||||
.tool-progress-realtime {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-progress-realtime::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* Tool Execution History */
|
||||
.tool-history-container {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tool-history-container .history-item {
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tool-history-container .history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Tool Statistics */
|
||||
.tool-stats-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tool-stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tool-stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.tool-stat-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
/* Error Recovery UI */
|
||||
.tool-error-recovery {
|
||||
background-color: rgba(220, 53, 69, 0.05);
|
||||
border: 1px solid var(--bs-danger);
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tool-error-recovery .error-message {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-error-recovery .error-suggestions {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tool-error-recovery .error-suggestions li {
|
||||
padding: 0.25rem 0;
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-error-recovery .error-suggestions li::before {
|
||||
content: '→';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
.tool-recovery-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tool-recovery-actions button {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Circuit Breaker Indicator */
|
||||
.circuit-breaker-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.circuit-breaker-status.status-closed {
|
||||
background-color: rgba(25, 135, 84, 0.1);
|
||||
color: var(--bs-success);
|
||||
}
|
||||
|
||||
.circuit-breaker-status.status-open {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
color: var(--bs-danger);
|
||||
}
|
||||
|
||||
.circuit-breaker-status.status-half-open {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Spinner Override for Tool Execution */
|
||||
.tool-execution-feedback .spinner-border-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-width: 0.15em;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.tool-preview-container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-preview-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tool-preview-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-stats-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tool-preview-container,
|
||||
.tool-execution-feedback {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.tool-preview-item {
|
||||
background-color: rgba(255, 255, 255, 0.03) !important;
|
||||
}
|
||||
|
||||
.tool-history-container,
|
||||
.tool-stats-container {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.parameter-item {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
309
apps/client/src/widgets/llm_chat/tool_execution_ui.ts
Normal file
309
apps/client/src/widgets/llm_chat/tool_execution_ui.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Tool Execution UI Components
|
||||
*
|
||||
* This module provides enhanced UI components for displaying tool execution status,
|
||||
* progress, and user-friendly error messages during LLM tool calls.
|
||||
*/
|
||||
|
||||
import { t } from "../../services/i18n.js";
|
||||
|
||||
/**
|
||||
* Tool execution status types
|
||||
*/
|
||||
export type ToolExecutionStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Tool execution display data
|
||||
*/
|
||||
export interface ToolExecutionDisplay {
|
||||
toolName: string;
|
||||
displayName: string;
|
||||
status: ToolExecutionStatus;
|
||||
description?: string;
|
||||
progress?: {
|
||||
current: number;
|
||||
total: number;
|
||||
message?: string;
|
||||
};
|
||||
result?: string;
|
||||
error?: string;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of tool names to user-friendly display names
|
||||
*/
|
||||
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
'search_notes': 'Searching Notes',
|
||||
'get_note_content': 'Reading Note',
|
||||
'create_note': 'Creating Note',
|
||||
'update_note': 'Updating Note',
|
||||
'execute_code': 'Running Code',
|
||||
'web_search': 'Searching Web',
|
||||
'get_note_attributes': 'Reading Note Properties',
|
||||
'set_note_attribute': 'Setting Note Property',
|
||||
'navigate_notes': 'Navigating Notes',
|
||||
'query_decomposition': 'Analyzing Query',
|
||||
'contextual_thinking': 'Processing Context'
|
||||
};
|
||||
|
||||
/**
|
||||
* Map of tool names to descriptions
|
||||
*/
|
||||
const TOOL_DESCRIPTIONS: Record<string, string> = {
|
||||
'search_notes': 'Finding relevant notes in your knowledge base',
|
||||
'get_note_content': 'Reading the content of a specific note',
|
||||
'create_note': 'Creating a new note with the provided content',
|
||||
'update_note': 'Updating an existing note',
|
||||
'execute_code': 'Running code in a safe environment',
|
||||
'web_search': 'Searching the web for current information',
|
||||
'get_note_attributes': 'Reading note metadata and properties',
|
||||
'set_note_attribute': 'Updating note metadata',
|
||||
'navigate_notes': 'Exploring the note hierarchy',
|
||||
'query_decomposition': 'Breaking down complex queries',
|
||||
'contextual_thinking': 'Analyzing context for better understanding'
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a tool execution indicator element
|
||||
*/
|
||||
export function createToolExecutionIndicator(toolName: string): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'tool-execution-indicator mb-2 p-2 border rounded bg-light';
|
||||
container.dataset.toolName = toolName;
|
||||
|
||||
const displayName = TOOL_DISPLAY_NAMES[toolName] || toolName;
|
||||
const description = TOOL_DESCRIPTIONS[toolName] || '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="tool-status-icon me-2">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="tool-name fw-bold small">${displayName}</div>
|
||||
${description ? `<div class="tool-description text-muted small">${description}</div>` : ''}
|
||||
<div class="tool-progress" style="display: none;">
|
||||
<div class="progress mt-1" style="height: 4px;">
|
||||
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="progress-message text-muted small mt-1"></div>
|
||||
</div>
|
||||
<div class="tool-result text-success small mt-1" style="display: none;"></div>
|
||||
<div class="tool-error text-danger small mt-1" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="tool-duration text-muted small ms-2" style="display: none;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tool execution status
|
||||
*/
|
||||
export function updateToolExecutionStatus(
|
||||
container: HTMLElement,
|
||||
status: ToolExecutionStatus,
|
||||
data?: {
|
||||
progress?: { current: number; total: number; message?: string };
|
||||
result?: string;
|
||||
error?: string;
|
||||
duration?: number;
|
||||
}
|
||||
): void {
|
||||
const statusIcon = container.querySelector('.tool-status-icon');
|
||||
const progressDiv = container.querySelector('.tool-progress') as HTMLElement;
|
||||
const progressBar = container.querySelector('.progress-bar') as HTMLElement;
|
||||
const progressMessage = container.querySelector('.progress-message') as HTMLElement;
|
||||
const resultDiv = container.querySelector('.tool-result') as HTMLElement;
|
||||
const errorDiv = container.querySelector('.tool-error') as HTMLElement;
|
||||
const durationDiv = container.querySelector('.tool-duration') as HTMLElement;
|
||||
|
||||
if (!statusIcon) return;
|
||||
|
||||
// Update status icon
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
statusIcon.innerHTML = `
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status">
|
||||
<span class="visually-hidden">Pending...</span>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'running':
|
||||
statusIcon.innerHTML = `
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Running...</span>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'success':
|
||||
statusIcon.innerHTML = '<i class="bx bx-check-circle text-success fs-5"></i>';
|
||||
container.classList.add('border-success', 'bg-success-subtle');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
statusIcon.innerHTML = '<i class="bx bx-error-circle text-danger fs-5"></i>';
|
||||
container.classList.add('border-danger', 'bg-danger-subtle');
|
||||
break;
|
||||
|
||||
case 'cancelled':
|
||||
statusIcon.innerHTML = '<i class="bx bx-x-circle text-warning fs-5"></i>';
|
||||
container.classList.add('border-warning', 'bg-warning-subtle');
|
||||
break;
|
||||
}
|
||||
|
||||
// Update progress if provided
|
||||
if (data?.progress && progressDiv && progressBar && progressMessage) {
|
||||
progressDiv.style.display = 'block';
|
||||
const percentage = (data.progress.current / data.progress.total) * 100;
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
if (data.progress.message) {
|
||||
progressMessage.textContent = data.progress.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Update result if provided
|
||||
if (data?.result && resultDiv) {
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.textContent = data.result;
|
||||
}
|
||||
|
||||
// Update error if provided
|
||||
if (data?.error && errorDiv) {
|
||||
errorDiv.style.display = 'block';
|
||||
errorDiv.textContent = formatErrorMessage(data.error);
|
||||
}
|
||||
|
||||
// Update duration if provided
|
||||
if (data?.duration && durationDiv) {
|
||||
durationDiv.style.display = 'block';
|
||||
durationDiv.textContent = formatDuration(data.duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error messages to be user-friendly
|
||||
*/
|
||||
function formatErrorMessage(error: string): string {
|
||||
// Remove technical details and provide user-friendly messages
|
||||
const errorMappings: Record<string, string> = {
|
||||
'ECONNREFUSED': 'Connection refused. Please check if the service is running.',
|
||||
'ETIMEDOUT': 'Request timed out. Please try again.',
|
||||
'ENOTFOUND': 'Service not found. Please check your configuration.',
|
||||
'401': 'Authentication failed. Please check your API credentials.',
|
||||
'403': 'Access denied. Please check your permissions.',
|
||||
'404': 'Resource not found.',
|
||||
'429': 'Rate limit exceeded. Please wait a moment and try again.',
|
||||
'500': 'Server error. Please try again later.',
|
||||
'503': 'Service temporarily unavailable. Please try again later.'
|
||||
};
|
||||
|
||||
for (const [key, message] of Object.entries(errorMappings)) {
|
||||
if (error.includes(key)) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error formatting
|
||||
if (error.length > 100) {
|
||||
return error.substring(0, 100) + '...';
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in a human-readable way
|
||||
*/
|
||||
function formatDuration(milliseconds: number): string {
|
||||
if (milliseconds < 1000) {
|
||||
return `${milliseconds}ms`;
|
||||
} else if (milliseconds < 60000) {
|
||||
return `${(milliseconds / 1000).toFixed(1)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool execution summary
|
||||
*/
|
||||
export function createToolExecutionSummary(executions: ToolExecutionDisplay[]): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'tool-execution-summary mt-2 p-2 border rounded bg-light small';
|
||||
|
||||
const successful = executions.filter(e => e.status === 'success').length;
|
||||
const failed = executions.filter(e => e.status === 'error').length;
|
||||
const total = executions.length;
|
||||
|
||||
const totalDuration = executions.reduce((sum, e) => {
|
||||
if (e.startTime && e.endTime) {
|
||||
return sum + (e.endTime - e.startTime);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<i class="bx bx-check-shield me-1"></i>
|
||||
<span class="fw-bold">Tools Executed:</span>
|
||||
<span class="badge bg-success ms-1">${successful} successful</span>
|
||||
${failed > 0 ? `<span class="badge bg-danger ms-1">${failed} failed</span>` : ''}
|
||||
<span class="badge bg-secondary ms-1">${total} total</span>
|
||||
</div>
|
||||
${totalDuration > 0 ? `
|
||||
<div class="text-muted">
|
||||
<i class="bx bx-time me-1"></i>
|
||||
${formatDuration(totalDuration)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a loading indicator with custom message
|
||||
*/
|
||||
export function createLoadingIndicator(message: string = 'Processing...'): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'loading-indicator-enhanced d-flex align-items-center p-2';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="spinner-grow spinner-grow-sm text-primary me-2" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<span class="loading-message">${message}</span>
|
||||
`;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update loading indicator message
|
||||
*/
|
||||
export function updateLoadingMessage(container: HTMLElement, message: string): void {
|
||||
const messageElement = container.querySelector('.loading-message');
|
||||
if (messageElement) {
|
||||
messageElement.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createToolExecutionIndicator,
|
||||
updateToolExecutionStatus,
|
||||
createToolExecutionSummary,
|
||||
createLoadingIndicator,
|
||||
updateLoadingMessage
|
||||
};
|
||||
599
apps/client/src/widgets/llm_chat/tool_feedback_ui.ts
Normal file
599
apps/client/src/widgets/llm_chat/tool_feedback_ui.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* Tool Feedback UI Component
|
||||
*
|
||||
* Provides real-time feedback UI during tool execution including
|
||||
* progress tracking, step visualization, and execution history.
|
||||
*/
|
||||
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { VirtualScrollManager, createVirtualScroll } from './virtual_scroll.js';
|
||||
|
||||
// UI Constants
|
||||
const UI_CONSTANTS = {
|
||||
HISTORY_MOVE_DELAY: 5000,
|
||||
STEP_COLLAPSE_DELAY: 1000,
|
||||
FADE_OUT_DURATION: 300,
|
||||
MAX_HISTORY_UI_SIZE: 50,
|
||||
MAX_VISIBLE_STEPS: 3,
|
||||
MAX_STRING_DISPLAY_LENGTH: 100,
|
||||
MAX_STEP_CONTAINER_HEIGHT: 150,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Tool execution status
|
||||
*/
|
||||
export type ToolExecutionStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled' | 'timeout';
|
||||
|
||||
/**
|
||||
* Tool execution progress data
|
||||
*/
|
||||
export interface ToolProgressData {
|
||||
executionId: string;
|
||||
current: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
message?: string;
|
||||
estimatedTimeRemaining?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool execution step data
|
||||
*/
|
||||
export interface ToolStepData {
|
||||
executionId: string;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
type: 'info' | 'warning' | 'error' | 'progress';
|
||||
data?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool execution tracker
|
||||
*/
|
||||
interface ExecutionTracker {
|
||||
id: string;
|
||||
toolName: string;
|
||||
element: HTMLElement;
|
||||
startTime: number;
|
||||
status: ToolExecutionStatus;
|
||||
steps: ToolStepData[];
|
||||
animationFrameId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Feedback UI Manager
|
||||
*/
|
||||
export class ToolFeedbackUI {
|
||||
private container: HTMLElement;
|
||||
private executions: Map<string, ExecutionTracker> = new Map();
|
||||
private historyContainer?: HTMLElement;
|
||||
private statsContainer?: HTMLElement;
|
||||
private virtualScroll?: VirtualScrollManager;
|
||||
private historyItems: any[] = [];
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking a tool execution
|
||||
*/
|
||||
public startExecution(
|
||||
executionId: string,
|
||||
toolName: string,
|
||||
displayName?: string
|
||||
): void {
|
||||
// Create execution element
|
||||
const element = this.createExecutionElement(executionId, toolName, displayName);
|
||||
this.container.appendChild(element);
|
||||
|
||||
// Create tracker
|
||||
const tracker: ExecutionTracker = {
|
||||
id: executionId,
|
||||
toolName,
|
||||
element,
|
||||
startTime: Date.now(),
|
||||
status: 'running',
|
||||
steps: []
|
||||
};
|
||||
|
||||
// Start elapsed time update with requestAnimationFrame
|
||||
this.startElapsedTimeAnimation(tracker);
|
||||
|
||||
this.executions.set(executionId, tracker);
|
||||
|
||||
// Auto-scroll to new execution
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update execution progress
|
||||
*/
|
||||
public updateProgress(data: ToolProgressData): void {
|
||||
const tracker = this.executions.get(data.executionId);
|
||||
if (!tracker) return;
|
||||
|
||||
const progressBar = tracker.element.querySelector('.progress-bar') as HTMLElement;
|
||||
const progressText = tracker.element.querySelector('.progress-text') as HTMLElement;
|
||||
const progressContainer = tracker.element.querySelector('.tool-progress') as HTMLElement;
|
||||
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${data.percentage}%`;
|
||||
progressBar.setAttribute('aria-valuenow', String(data.percentage));
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
let text = `${data.current}/${data.total}`;
|
||||
if (data.message) {
|
||||
text += ` - ${data.message}`;
|
||||
}
|
||||
if (data.estimatedTimeRemaining) {
|
||||
text += ` (${this.formatDuration(data.estimatedTimeRemaining)} remaining)`;
|
||||
}
|
||||
progressText.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add execution step
|
||||
*/
|
||||
public addStep(data: ToolStepData): void {
|
||||
const tracker = this.executions.get(data.executionId);
|
||||
if (!tracker) return;
|
||||
|
||||
tracker.steps.push(data);
|
||||
|
||||
const stepsContainer = tracker.element.querySelector('.tool-steps') as HTMLElement;
|
||||
if (stepsContainer) {
|
||||
const stepElement = this.createStepElement(data);
|
||||
stepsContainer.appendChild(stepElement);
|
||||
|
||||
// Show steps container if hidden
|
||||
stepsContainer.style.display = 'block';
|
||||
|
||||
// Auto-scroll steps
|
||||
stepsContainer.scrollTop = stepsContainer.scrollHeight;
|
||||
}
|
||||
|
||||
// Update status indicator for warnings/errors
|
||||
if (data.type === 'warning' || data.type === 'error') {
|
||||
this.updateStatusIndicator(tracker, data.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete execution
|
||||
*/
|
||||
public completeExecution(
|
||||
executionId: string,
|
||||
status: 'success' | 'error' | 'cancelled' | 'timeout',
|
||||
result?: any,
|
||||
error?: string
|
||||
): void {
|
||||
const tracker = this.executions.get(executionId);
|
||||
if (!tracker) return;
|
||||
|
||||
tracker.status = status;
|
||||
|
||||
// Stop elapsed time update
|
||||
if (tracker.animationFrameId) {
|
||||
cancelAnimationFrame(tracker.animationFrameId);
|
||||
tracker.animationFrameId = undefined;
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.updateStatusIndicator(tracker, status);
|
||||
|
||||
const duration = Date.now() - tracker.startTime;
|
||||
const durationElement = tracker.element.querySelector('.tool-duration') as HTMLElement;
|
||||
if (durationElement) {
|
||||
durationElement.textContent = this.formatDuration(duration);
|
||||
}
|
||||
|
||||
// Show result or error
|
||||
if (status === 'success' && result) {
|
||||
const resultElement = tracker.element.querySelector('.tool-result') as HTMLElement;
|
||||
if (resultElement) {
|
||||
resultElement.style.display = 'block';
|
||||
resultElement.textContent = this.formatResult(result);
|
||||
}
|
||||
} else if ((status === 'error' || status === 'timeout') && error) {
|
||||
const errorElement = tracker.element.querySelector('.tool-error') as HTMLElement;
|
||||
if (errorElement) {
|
||||
errorElement.style.display = 'block';
|
||||
errorElement.textContent = error;
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse steps after completion
|
||||
setTimeout(() => {
|
||||
this.collapseStepsIfNeeded(tracker);
|
||||
}, UI_CONSTANTS.STEP_COLLAPSE_DELAY);
|
||||
|
||||
// Move to history after a delay
|
||||
setTimeout(() => {
|
||||
this.moveToHistory(tracker);
|
||||
}, UI_CONSTANTS.HISTORY_MOVE_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel execution
|
||||
*/
|
||||
public cancelExecution(executionId: string): void {
|
||||
this.completeExecution(executionId, 'cancelled', undefined, 'Cancelled by user');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create execution element
|
||||
*/
|
||||
private createExecutionElement(
|
||||
executionId: string,
|
||||
toolName: string,
|
||||
displayName?: string
|
||||
): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'tool-execution-feedback mb-2 p-2 border rounded bg-light';
|
||||
element.dataset.executionId = executionId;
|
||||
|
||||
element.innerHTML = `
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="tool-status-icon me-2">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Running...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="tool-name fw-bold small">
|
||||
${displayName || toolName}
|
||||
</div>
|
||||
<div class="tool-actions">
|
||||
<button class="btn btn-sm btn-link p-0 cancel-btn" title="Cancel">
|
||||
<i class="bx bx-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-progress mt-1" style="display: none;">
|
||||
<div class="progress" style="height: 4px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style="width: 0%"
|
||||
aria-valuenow="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-text text-muted small mt-1"></div>
|
||||
</div>
|
||||
<div class="tool-steps mt-2 small" style="display: none; max-height: ${UI_CONSTANTS.MAX_STEP_CONTAINER_HEIGHT}px; overflow-y: auto;">
|
||||
</div>
|
||||
<div class="tool-result text-success small mt-2" style="display: none;"></div>
|
||||
<div class="tool-error text-danger small mt-2" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="tool-duration text-muted small ms-2">
|
||||
<span class="elapsed-time">0s</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add cancel button listener
|
||||
const cancelBtn = element.querySelector('.cancel-btn') as HTMLButtonElement;
|
||||
cancelBtn?.addEventListener('click', () => {
|
||||
this.cancelExecution(executionId);
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create step element
|
||||
*/
|
||||
private createStepElement(step: ToolStepData): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = `tool-step tool-step-${step.type} text-${this.getStepColor(step.type)} mb-1`;
|
||||
|
||||
const timestamp = new Date(step.timestamp).toLocaleTimeString();
|
||||
|
||||
element.innerHTML = `
|
||||
<i class="bx ${this.getStepIcon(step.type)} me-1"></i>
|
||||
<span class="step-time text-muted">[${timestamp}]</span>
|
||||
<span class="step-message ms-1">${step.message}</span>
|
||||
`;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status indicator
|
||||
*/
|
||||
private updateStatusIndicator(tracker: ExecutionTracker, status: string): void {
|
||||
const statusIcon = tracker.element.querySelector('.tool-status-icon');
|
||||
if (!statusIcon) return;
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
'success': '<i class="bx bx-check-circle text-success fs-5"></i>',
|
||||
'error': '<i class="bx bx-error-circle text-danger fs-5"></i>',
|
||||
'warning': '<i class="bx bx-error text-warning fs-5"></i>',
|
||||
'cancelled': '<i class="bx bx-x-circle text-warning fs-5"></i>',
|
||||
'timeout': '<i class="bx bx-time-five text-danger fs-5"></i>'
|
||||
};
|
||||
|
||||
if (icons[status]) {
|
||||
statusIcon.innerHTML = icons[status];
|
||||
}
|
||||
|
||||
// Update container style
|
||||
const borderColors: Record<string, string> = {
|
||||
'success': 'border-success',
|
||||
'error': 'border-danger',
|
||||
'warning': 'border-warning',
|
||||
'cancelled': 'border-warning',
|
||||
'timeout': 'border-danger'
|
||||
};
|
||||
|
||||
if (borderColors[status]) {
|
||||
tracker.element.classList.add(borderColors[status]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start elapsed time animation with requestAnimationFrame
|
||||
*/
|
||||
private startElapsedTimeAnimation(tracker: ExecutionTracker): void {
|
||||
const updateTime = () => {
|
||||
if (this.executions.has(tracker.id)) {
|
||||
const elapsed = Date.now() - tracker.startTime;
|
||||
const elapsedElement = tracker.element.querySelector('.elapsed-time') as HTMLElement;
|
||||
if (elapsedElement) {
|
||||
elapsedElement.textContent = this.formatDuration(elapsed);
|
||||
}
|
||||
tracker.animationFrameId = requestAnimationFrame(updateTime);
|
||||
}
|
||||
};
|
||||
tracker.animationFrameId = requestAnimationFrame(updateTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move execution to history
|
||||
*/
|
||||
private moveToHistory(tracker: ExecutionTracker): void {
|
||||
// Remove from active executions
|
||||
this.executions.delete(tracker.id);
|
||||
|
||||
// Fade out and remove
|
||||
tracker.element.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
tracker.element.remove();
|
||||
}, UI_CONSTANTS.FADE_OUT_DURATION);
|
||||
|
||||
// Add to history
|
||||
this.addToHistory(tracker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tracker to history
|
||||
*/
|
||||
private addToHistory(tracker: ExecutionTracker): void {
|
||||
// Add to history items array
|
||||
this.historyItems.unshift(tracker);
|
||||
|
||||
// Limit history size
|
||||
if (this.historyItems.length > UI_CONSTANTS.MAX_HISTORY_UI_SIZE) {
|
||||
this.historyItems = this.historyItems.slice(0, UI_CONSTANTS.MAX_HISTORY_UI_SIZE);
|
||||
}
|
||||
|
||||
// Update display
|
||||
if (this.virtualScroll) {
|
||||
this.virtualScroll.updateTotalItems(this.historyItems.length);
|
||||
this.virtualScroll.refresh();
|
||||
} else if (this.historyContainer) {
|
||||
const historyItem = this.createHistoryItem(tracker);
|
||||
this.historyContainer.prepend(historyItem);
|
||||
|
||||
// Limit DOM elements
|
||||
const elements = this.historyContainer.querySelectorAll('.history-item');
|
||||
if (elements.length > UI_CONSTANTS.MAX_HISTORY_UI_SIZE) {
|
||||
elements[elements.length - 1].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create history item
|
||||
*/
|
||||
private createHistoryItem(tracker: ExecutionTracker): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'history-item small text-muted mb-1';
|
||||
|
||||
const duration = Date.now() - tracker.startTime;
|
||||
const statusIcon = this.getStatusIcon(tracker.status);
|
||||
const time = new Date(tracker.startTime).toLocaleTimeString();
|
||||
|
||||
element.innerHTML = `
|
||||
${statusIcon}
|
||||
<span class="ms-1">${tracker.toolName}</span>
|
||||
<span class="ms-1">(${this.formatDuration(duration)})</span>
|
||||
<span class="ms-1 text-muted">${time}</span>
|
||||
`;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get step color
|
||||
*/
|
||||
private getStepColor(type: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
'info': 'muted',
|
||||
'warning': 'warning',
|
||||
'error': 'danger',
|
||||
'progress': 'primary'
|
||||
};
|
||||
return colors[type] || 'muted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get step icon
|
||||
*/
|
||||
private getStepIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
'info': 'bx-info-circle',
|
||||
'warning': 'bx-error',
|
||||
'error': 'bx-error-circle',
|
||||
'progress': 'bx-loader-alt'
|
||||
};
|
||||
return icons[type] || 'bx-circle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon
|
||||
*/
|
||||
private getStatusIcon(status: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
'success': '<i class="bx bx-check-circle text-success"></i>',
|
||||
'error': '<i class="bx bx-error-circle text-danger"></i>',
|
||||
'cancelled': '<i class="bx bx-x-circle text-warning"></i>',
|
||||
'timeout': '<i class="bx bx-time-five text-danger"></i>',
|
||||
'running': '<i class="bx bx-loader-alt text-primary"></i>',
|
||||
'pending': '<i class="bx bx-time text-muted"></i>'
|
||||
};
|
||||
return icons[status] || '<i class="bx bx-circle text-muted"></i>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse steps if there are too many
|
||||
*/
|
||||
private collapseStepsIfNeeded(tracker: ExecutionTracker): void {
|
||||
const stepsContainer = tracker.element.querySelector('.tool-steps') as HTMLElement;
|
||||
if (stepsContainer && tracker.steps.length > UI_CONSTANTS.MAX_VISIBLE_STEPS) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'mt-2';
|
||||
details.innerHTML = `
|
||||
<summary class="text-muted small cursor-pointer">
|
||||
Show ${tracker.steps.length} execution steps
|
||||
</summary>
|
||||
`;
|
||||
details.appendChild(stepsContainer.cloneNode(true));
|
||||
stepsContainer.replaceWith(details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format result for display
|
||||
*/
|
||||
private formatResult(result: any): string {
|
||||
if (typeof result === 'string') {
|
||||
return this.truncateString(result);
|
||||
}
|
||||
const json = JSON.stringify(result);
|
||||
return this.truncateString(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate string for display
|
||||
*/
|
||||
private truncateString(str: string, maxLength: number = UI_CONSTANTS.MAX_STRING_DISPLAY_LENGTH): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
return `${str.substring(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration
|
||||
*/
|
||||
private formatDuration(milliseconds: number): string {
|
||||
if (milliseconds < 1000) {
|
||||
return `${Math.round(milliseconds)}ms`;
|
||||
} else if (milliseconds < 60000) {
|
||||
return `${(milliseconds / 1000).toFixed(1)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set history container with virtual scrolling
|
||||
*/
|
||||
public setHistoryContainer(container: HTMLElement, useVirtualScroll: boolean = false): void {
|
||||
this.historyContainer = container;
|
||||
|
||||
if (useVirtualScroll && this.historyItems.length > 20) {
|
||||
this.initializeVirtualScroll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize virtual scrolling for history
|
||||
*/
|
||||
private initializeVirtualScroll(): void {
|
||||
if (!this.historyContainer) return;
|
||||
|
||||
this.virtualScroll = createVirtualScroll({
|
||||
container: this.historyContainer,
|
||||
itemHeight: 30, // Approximate height of history items
|
||||
totalItems: this.historyItems.length,
|
||||
overscan: 3,
|
||||
onRenderItem: (index) => {
|
||||
return this.renderHistoryItemAtIndex(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render history item at specific index
|
||||
*/
|
||||
private renderHistoryItemAtIndex(index: number): HTMLElement {
|
||||
const item = this.historyItems[index];
|
||||
if (!item) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'history-item-empty';
|
||||
return empty;
|
||||
}
|
||||
|
||||
return this.createHistoryItem(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set statistics container
|
||||
*/
|
||||
public setStatsContainer(container: HTMLElement): void {
|
||||
this.statsContainer = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all executions
|
||||
*/
|
||||
public clear(): void {
|
||||
this.executions.forEach(tracker => {
|
||||
if (tracker.animationFrameId) {
|
||||
cancelAnimationFrame(tracker.animationFrameId);
|
||||
}
|
||||
});
|
||||
this.executions.clear();
|
||||
this.container.innerHTML = '';
|
||||
this.historyItems = [];
|
||||
|
||||
if (this.virtualScroll) {
|
||||
this.virtualScroll.destroy();
|
||||
this.virtualScroll = undefined;
|
||||
}
|
||||
|
||||
if (this.historyContainer) {
|
||||
this.historyContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool feedback UI instance
|
||||
*/
|
||||
export function createToolFeedbackUI(container: HTMLElement): ToolFeedbackUI {
|
||||
return new ToolFeedbackUI(container);
|
||||
}
|
||||
367
apps/client/src/widgets/llm_chat/tool_preview_ui.ts
Normal file
367
apps/client/src/widgets/llm_chat/tool_preview_ui.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Tool Preview UI Component
|
||||
*
|
||||
* Provides UI for previewing tool executions before they run,
|
||||
* allowing users to approve, reject, or modify tool parameters.
|
||||
*/
|
||||
|
||||
import { t } from "../../services/i18n.js";
|
||||
|
||||
/**
|
||||
* Tool preview data from server
|
||||
*/
|
||||
export interface ToolPreviewData {
|
||||
id: string;
|
||||
toolName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
formattedParameters: string[];
|
||||
estimatedDuration: number;
|
||||
riskLevel: 'low' | 'medium' | 'high';
|
||||
requiresConfirmation: boolean;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution plan from server
|
||||
*/
|
||||
export interface ExecutionPlanData {
|
||||
id: string;
|
||||
tools: ToolPreviewData[];
|
||||
totalEstimatedDuration: number;
|
||||
requiresConfirmation: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User approval data
|
||||
*/
|
||||
export interface UserApproval {
|
||||
planId: string;
|
||||
approved: boolean;
|
||||
rejectedTools?: string[];
|
||||
modifiedParameters?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Preview UI Manager
|
||||
*/
|
||||
export class ToolPreviewUI {
|
||||
private container: HTMLElement;
|
||||
private currentPlan?: ExecutionPlanData;
|
||||
private onApprovalCallback?: (approval: UserApproval) => void;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show tool execution preview
|
||||
*/
|
||||
public async showPreview(
|
||||
plan: ExecutionPlanData,
|
||||
onApproval: (approval: UserApproval) => void
|
||||
): Promise<void> {
|
||||
this.currentPlan = plan;
|
||||
this.onApprovalCallback = onApproval;
|
||||
|
||||
const previewElement = this.createPreviewElement(plan);
|
||||
this.container.appendChild(previewElement);
|
||||
|
||||
// Auto-scroll to preview
|
||||
previewElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create preview element
|
||||
*/
|
||||
private createPreviewElement(plan: ExecutionPlanData): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'tool-preview-container mb-3 border rounded p-3 bg-light';
|
||||
element.dataset.planId = plan.id;
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'tool-preview-header mb-3';
|
||||
header.innerHTML = `
|
||||
<h6 class="mb-2">
|
||||
<i class="bx bx-shield-quarter me-2"></i>
|
||||
${t('Tool Execution Preview')}
|
||||
</h6>
|
||||
<p class="text-muted small mb-2">
|
||||
${plan.tools.length} ${plan.tools.length === 1 ? 'tool' : 'tools'} will be executed
|
||||
${plan.requiresConfirmation ? ' (confirmation required)' : ''}
|
||||
</p>
|
||||
<div class="d-flex align-items-center gap-3 small text-muted">
|
||||
<span>
|
||||
<i class="bx bx-time-five me-1"></i>
|
||||
Estimated time: ${this.formatDuration(plan.totalEstimatedDuration)}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
element.appendChild(header);
|
||||
|
||||
// Tool list
|
||||
const toolList = document.createElement('div');
|
||||
toolList.className = 'tool-preview-list mb-3';
|
||||
|
||||
plan.tools.forEach((tool, index) => {
|
||||
const toolElement = this.createToolPreviewItem(tool, index);
|
||||
toolList.appendChild(toolElement);
|
||||
});
|
||||
|
||||
element.appendChild(toolList);
|
||||
|
||||
// Actions
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'tool-preview-actions d-flex gap-2';
|
||||
|
||||
if (plan.requiresConfirmation) {
|
||||
actions.innerHTML = `
|
||||
<button class="btn btn-success btn-sm approve-all-btn">
|
||||
<i class="bx bx-check me-1"></i>
|
||||
Approve All
|
||||
</button>
|
||||
<button class="btn btn-warning btn-sm modify-btn">
|
||||
<i class="bx bx-edit me-1"></i>
|
||||
Modify
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm reject-all-btn">
|
||||
<i class="bx bx-x me-1"></i>
|
||||
Reject All
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add event listeners
|
||||
const approveBtn = actions.querySelector('.approve-all-btn') as HTMLButtonElement;
|
||||
const modifyBtn = actions.querySelector('.modify-btn') as HTMLButtonElement;
|
||||
const rejectBtn = actions.querySelector('.reject-all-btn') as HTMLButtonElement;
|
||||
|
||||
approveBtn?.addEventListener('click', () => this.handleApproveAll());
|
||||
modifyBtn?.addEventListener('click', () => this.handleModify());
|
||||
rejectBtn?.addEventListener('click', () => this.handleRejectAll());
|
||||
} else {
|
||||
// Auto-approve after showing preview
|
||||
setTimeout(() => {
|
||||
this.handleApproveAll();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
element.appendChild(actions);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tool preview item
|
||||
*/
|
||||
private createToolPreviewItem(tool: ToolPreviewData, index: number): HTMLElement {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'tool-preview-item mb-2 p-2 border rounded bg-white';
|
||||
item.dataset.toolName = tool.toolName;
|
||||
|
||||
const riskBadge = this.getRiskBadge(tool.riskLevel);
|
||||
const riskIcon = this.getRiskIcon(tool.riskLevel);
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="tool-preview-checkbox me-2 pt-1">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
id="tool-${index}"
|
||||
checked
|
||||
${tool.requiresConfirmation ? '' : 'disabled'}>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<label class="tool-name fw-bold small mb-0" for="tool-${index}">
|
||||
${tool.displayName}
|
||||
</label>
|
||||
${riskBadge}
|
||||
${riskIcon}
|
||||
</div>
|
||||
<div class="tool-description text-muted small mb-2">
|
||||
${tool.description}
|
||||
</div>
|
||||
<div class="tool-parameters small">
|
||||
<details>
|
||||
<summary class="text-primary cursor-pointer">
|
||||
Parameters (${Object.keys(tool.parameters).length})
|
||||
</summary>
|
||||
<div class="mt-1 p-2 bg-light rounded">
|
||||
${this.formatParameters(tool.formattedParameters)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
${tool.warnings && tool.warnings.length > 0 ? `
|
||||
<div class="tool-warnings mt-2">
|
||||
${tool.warnings.map(w => `
|
||||
<div class="alert alert-warning py-1 px-2 mb-1 small">
|
||||
<i class="bx bx-error-circle me-1"></i>
|
||||
${w}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="tool-duration text-muted small ms-2">
|
||||
<i class="bx bx-time me-1"></i>
|
||||
~${this.formatDuration(tool.estimatedDuration)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk level badge
|
||||
*/
|
||||
private getRiskBadge(riskLevel: 'low' | 'medium' | 'high'): string {
|
||||
const badges = {
|
||||
low: '<span class="badge bg-success ms-2">Low Risk</span>',
|
||||
medium: '<span class="badge bg-warning ms-2">Medium Risk</span>',
|
||||
high: '<span class="badge bg-danger ms-2">High Risk</span>'
|
||||
};
|
||||
return badges[riskLevel] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk level icon
|
||||
*/
|
||||
private getRiskIcon(riskLevel: 'low' | 'medium' | 'high'): string {
|
||||
const icons = {
|
||||
low: '<i class="bx bx-shield-check text-success ms-2"></i>',
|
||||
medium: '<i class="bx bx-shield text-warning ms-2"></i>',
|
||||
high: '<i class="bx bx-shield-x text-danger ms-2"></i>'
|
||||
};
|
||||
return icons[riskLevel] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format parameters for display
|
||||
*/
|
||||
private formatParameters(parameters: string[]): string {
|
||||
return parameters.map(param => {
|
||||
const [key, ...valueParts] = param.split(':');
|
||||
const value = valueParts.join(':').trim();
|
||||
return `
|
||||
<div class="parameter-item mb-1">
|
||||
<span class="parameter-key text-muted">${key}:</span>
|
||||
<span class="parameter-value ms-1">${this.escapeHtml(value)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle approve all
|
||||
*/
|
||||
private handleApproveAll(): void {
|
||||
if (!this.currentPlan || !this.onApprovalCallback) return;
|
||||
|
||||
const approval: UserApproval = {
|
||||
planId: this.currentPlan.id,
|
||||
approved: true
|
||||
};
|
||||
|
||||
this.onApprovalCallback(approval);
|
||||
this.hidePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle modify
|
||||
*/
|
||||
private handleModify(): void {
|
||||
if (!this.currentPlan) return;
|
||||
|
||||
// Get selected tools
|
||||
const checkboxes = this.container.querySelectorAll('.tool-preview-item input[type="checkbox"]');
|
||||
const rejectedTools: string[] = [];
|
||||
|
||||
checkboxes.forEach((checkbox: Element) => {
|
||||
const input = checkbox as HTMLInputElement;
|
||||
const toolItem = input.closest('.tool-preview-item') as HTMLElement;
|
||||
const toolName = toolItem?.dataset.toolName;
|
||||
|
||||
if (toolName && !input.checked) {
|
||||
rejectedTools.push(toolName);
|
||||
}
|
||||
});
|
||||
|
||||
const approval: UserApproval = {
|
||||
planId: this.currentPlan.id,
|
||||
approved: true,
|
||||
rejectedTools: rejectedTools.length > 0 ? rejectedTools : undefined
|
||||
};
|
||||
|
||||
if (this.onApprovalCallback) {
|
||||
this.onApprovalCallback(approval);
|
||||
}
|
||||
|
||||
this.hidePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reject all
|
||||
*/
|
||||
private handleRejectAll(): void {
|
||||
if (!this.currentPlan || !this.onApprovalCallback) return;
|
||||
|
||||
const approval: UserApproval = {
|
||||
planId: this.currentPlan.id,
|
||||
approved: false
|
||||
};
|
||||
|
||||
this.onApprovalCallback(approval);
|
||||
this.hidePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide preview
|
||||
*/
|
||||
private hidePreview(): void {
|
||||
const preview = this.container.querySelector('.tool-preview-container');
|
||||
if (preview) {
|
||||
// Add fade out animation
|
||||
preview.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
preview.remove();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
this.currentPlan = undefined;
|
||||
this.onApprovalCallback = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration
|
||||
*/
|
||||
private formatDuration(milliseconds: number): string {
|
||||
if (milliseconds < 1000) {
|
||||
return `${milliseconds}ms`;
|
||||
} else if (milliseconds < 60000) {
|
||||
return `${(milliseconds / 1000).toFixed(1)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML
|
||||
*/
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool preview UI instance
|
||||
*/
|
||||
export function createToolPreviewUI(container: HTMLElement): ToolPreviewUI {
|
||||
return new ToolPreviewUI(container);
|
||||
}
|
||||
419
apps/client/src/widgets/llm_chat/tool_websocket.ts
Normal file
419
apps/client/src/widgets/llm_chat/tool_websocket.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Tool WebSocket Manager
|
||||
*
|
||||
* Provides real-time WebSocket communication for tool execution updates.
|
||||
* Implements automatic reconnection, heartbeat, and message queuing.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* WebSocket message types
|
||||
*/
|
||||
export enum WSMessageType {
|
||||
// Tool execution events
|
||||
TOOL_START = 'tool:start',
|
||||
TOOL_PROGRESS = 'tool:progress',
|
||||
TOOL_STEP = 'tool:step',
|
||||
TOOL_COMPLETE = 'tool:complete',
|
||||
TOOL_ERROR = 'tool:error',
|
||||
TOOL_CANCELLED = 'tool:cancelled',
|
||||
|
||||
// Connection events
|
||||
HEARTBEAT = 'heartbeat',
|
||||
PING = 'ping',
|
||||
PONG = 'pong',
|
||||
|
||||
// Control events
|
||||
SUBSCRIBE = 'subscribe',
|
||||
UNSUBSCRIBE = 'unsubscribe',
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket message structure
|
||||
*/
|
||||
export interface WSMessage {
|
||||
id: string;
|
||||
type: WSMessageType;
|
||||
timestamp: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket configuration
|
||||
*/
|
||||
export interface WSConfig {
|
||||
url: string;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
heartbeatInterval?: number;
|
||||
messageTimeout?: number;
|
||||
autoReconnect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection state
|
||||
*/
|
||||
export enum ConnectionState {
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
RECONNECTING = 'reconnecting',
|
||||
DISCONNECTED = 'disconnected',
|
||||
FAILED = 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool WebSocket Manager
|
||||
*/
|
||||
export class ToolWebSocketManager extends EventEmitter {
|
||||
private ws?: WebSocket;
|
||||
private config: Required<WSConfig>;
|
||||
private state: ConnectionState = ConnectionState.DISCONNECTED;
|
||||
private reconnectAttempts: number = 0;
|
||||
private reconnectTimer?: number;
|
||||
private heartbeatTimer?: number;
|
||||
private messageQueue: WSMessage[] = [];
|
||||
private subscriptions: Set<string> = new Set();
|
||||
private lastPingTime?: number;
|
||||
private lastPongTime?: number;
|
||||
|
||||
// Performance constants
|
||||
private static readonly DEFAULT_RECONNECT_INTERVAL = 3000;
|
||||
private static readonly DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;
|
||||
private static readonly DEFAULT_HEARTBEAT_INTERVAL = 30000;
|
||||
private static readonly DEFAULT_MESSAGE_TIMEOUT = 5000;
|
||||
private static readonly MAX_QUEUE_SIZE = 100;
|
||||
|
||||
constructor(config: WSConfig) {
|
||||
super();
|
||||
|
||||
this.config = {
|
||||
url: config.url,
|
||||
reconnectInterval: config.reconnectInterval ?? ToolWebSocketManager.DEFAULT_RECONNECT_INTERVAL,
|
||||
maxReconnectAttempts: config.maxReconnectAttempts ?? ToolWebSocketManager.DEFAULT_MAX_RECONNECT_ATTEMPTS,
|
||||
heartbeatInterval: config.heartbeatInterval ?? ToolWebSocketManager.DEFAULT_HEARTBEAT_INTERVAL,
|
||||
messageTimeout: config.messageTimeout ?? ToolWebSocketManager.DEFAULT_MESSAGE_TIMEOUT,
|
||||
autoReconnect: config.autoReconnect ?? true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
public connect(): void {
|
||||
if (this.state === ConnectionState.CONNECTED || this.state === ConnectionState.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = ConnectionState.CONNECTING;
|
||||
this.emit('connecting');
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.config.url);
|
||||
this.setupEventHandlers();
|
||||
} catch (error) {
|
||||
this.handleConnectionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket event handlers
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.ws) return;
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.state = ConnectionState.CONNECTED;
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit('connected');
|
||||
|
||||
// Start heartbeat
|
||||
this.startHeartbeat();
|
||||
|
||||
// Re-subscribe to previous subscriptions
|
||||
this.resubscribe();
|
||||
|
||||
// Flush message queue
|
||||
this.flushMessageQueue();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: WSMessage = JSON.parse(event.data);
|
||||
this.handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.emit('error', error);
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
this.state = ConnectionState.DISCONNECTED;
|
||||
this.stopHeartbeat();
|
||||
this.emit('disconnected', event.code, event.reason);
|
||||
|
||||
if (this.config.autoReconnect && !event.wasClean) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message
|
||||
*/
|
||||
private handleMessage(message: WSMessage): void {
|
||||
// Handle control messages
|
||||
switch (message.type) {
|
||||
case WSMessageType.PONG:
|
||||
this.lastPongTime = Date.now();
|
||||
return;
|
||||
|
||||
case WSMessageType.HEARTBEAT:
|
||||
this.send({
|
||||
id: message.id,
|
||||
type: WSMessageType.PONG,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit message for subscribers
|
||||
this.emit('message', message);
|
||||
this.emit(message.type, message.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message
|
||||
*/
|
||||
public send(message: WSMessage): void {
|
||||
if (this.state === ConnectionState.CONNECTED && this.ws?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Failed to send WebSocket message:', error);
|
||||
this.queueMessage(message);
|
||||
}
|
||||
} else {
|
||||
this.queueMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a message for later sending
|
||||
*/
|
||||
private queueMessage(message: WSMessage): void {
|
||||
if (this.messageQueue.length >= ToolWebSocketManager.MAX_QUEUE_SIZE) {
|
||||
this.messageQueue.shift(); // Remove oldest message
|
||||
}
|
||||
this.messageQueue.push(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush message queue
|
||||
*/
|
||||
private flushMessageQueue(): void {
|
||||
while (this.messageQueue.length > 0 && this.state === ConnectionState.CONNECTED) {
|
||||
const message = this.messageQueue.shift();
|
||||
if (message) {
|
||||
this.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to tool execution updates
|
||||
*/
|
||||
public subscribe(executionId: string): void {
|
||||
this.subscriptions.add(executionId);
|
||||
|
||||
if (this.state === ConnectionState.CONNECTED) {
|
||||
this.send({
|
||||
id: this.generateMessageId(),
|
||||
type: WSMessageType.SUBSCRIBE,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { executionId }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from tool execution updates
|
||||
*/
|
||||
public unsubscribe(executionId: string): void {
|
||||
this.subscriptions.delete(executionId);
|
||||
|
||||
if (this.state === ConnectionState.CONNECTED) {
|
||||
this.send({
|
||||
id: this.generateMessageId(),
|
||||
type: WSMessageType.UNSUBSCRIBE,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { executionId }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-subscribe to all previous subscriptions
|
||||
*/
|
||||
private resubscribe(): void {
|
||||
this.subscriptions.forEach(executionId => {
|
||||
this.send({
|
||||
id: this.generateMessageId(),
|
||||
type: WSMessageType.SUBSCRIBE,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { executionId }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat mechanism
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
|
||||
this.heartbeatTimer = window.setInterval(() => {
|
||||
if (this.state === ConnectionState.CONNECTED) {
|
||||
// Check if last pong was received
|
||||
if (this.lastPingTime && this.lastPongTime) {
|
||||
const timeSinceLastPong = Date.now() - this.lastPongTime;
|
||||
if (timeSinceLastPong > this.config.heartbeatInterval * 2) {
|
||||
// Connection seems dead, reconnect
|
||||
this.reconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send ping
|
||||
this.lastPingTime = Date.now();
|
||||
this.send({
|
||||
id: this.generateMessageId(),
|
||||
type: WSMessageType.PING,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: null
|
||||
});
|
||||
}
|
||||
}, this.config.heartbeatInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat mechanism
|
||||
*/
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection attempt
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
||||
this.state = ConnectionState.FAILED;
|
||||
this.emit('failed', 'Max reconnection attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = ConnectionState.RECONNECTING;
|
||||
this.reconnectAttempts++;
|
||||
|
||||
const delay = Math.min(
|
||||
this.config.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
|
||||
30000 // Max 30 seconds
|
||||
);
|
||||
|
||||
this.emit('reconnecting', this.reconnectAttempts, delay);
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to server
|
||||
*/
|
||||
public reconnect(): void {
|
||||
this.disconnect(false);
|
||||
this.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle connection error
|
||||
*/
|
||||
private handleConnectionError(error: any): void {
|
||||
console.error('WebSocket connection error:', error);
|
||||
this.state = ConnectionState.DISCONNECTED;
|
||||
this.emit('error', error);
|
||||
|
||||
if (this.config.autoReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from server
|
||||
*/
|
||||
public disconnect(clearSubscriptions: boolean = true): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
|
||||
this.stopHeartbeat();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Client disconnect');
|
||||
this.ws = undefined;
|
||||
}
|
||||
|
||||
if (clearSubscriptions) {
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
|
||||
this.messageQueue = [];
|
||||
this.state = ConnectionState.DISCONNECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection state
|
||||
*/
|
||||
public getState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
return this.state === ConnectionState.CONNECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique message ID
|
||||
*/
|
||||
private generateMessageId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the WebSocket manager
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.disconnect(true);
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create WebSocket manager instance
|
||||
*/
|
||||
export function createToolWebSocket(config: WSConfig): ToolWebSocketManager {
|
||||
return new ToolWebSocketManager(config);
|
||||
}
|
||||
312
apps/client/src/widgets/llm_chat/virtual_scroll.ts
Normal file
312
apps/client/src/widgets/llm_chat/virtual_scroll.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Virtual Scrolling Component
|
||||
*
|
||||
* Provides efficient rendering of large lists by only rendering visible items.
|
||||
* Optimized for the tool execution history display.
|
||||
*/
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
container: HTMLElement;
|
||||
itemHeight: number;
|
||||
totalItems: number;
|
||||
renderBuffer?: number;
|
||||
overscan?: number;
|
||||
onRenderItem: (index: number) => HTMLElement;
|
||||
onScrollEnd?: () => void;
|
||||
}
|
||||
|
||||
export interface VirtualScrollItem {
|
||||
index: number;
|
||||
element: HTMLElement;
|
||||
top: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual Scroll Manager
|
||||
*/
|
||||
export class VirtualScrollManager {
|
||||
private container: HTMLElement;
|
||||
private viewport: HTMLElement;
|
||||
private content: HTMLElement;
|
||||
private itemHeight: number;
|
||||
private totalItems: number;
|
||||
private renderBuffer: number;
|
||||
private overscan: number;
|
||||
private onRenderItem: (index: number) => HTMLElement;
|
||||
private onScrollEnd?: () => void;
|
||||
|
||||
private visibleItems: Map<number, VirtualScrollItem> = new Map();
|
||||
private scrollRAF?: number;
|
||||
private lastScrollTop: number = 0;
|
||||
private scrollEndTimeout?: number;
|
||||
|
||||
// Performance optimization constants
|
||||
private static readonly DEFAULT_RENDER_BUFFER = 3;
|
||||
private static readonly DEFAULT_OVERSCAN = 2;
|
||||
private static readonly SCROLL_END_DELAY = 150;
|
||||
private static readonly RECYCLE_POOL_SIZE = 50;
|
||||
|
||||
// Element recycling pool
|
||||
private recyclePool: HTMLElement[] = [];
|
||||
|
||||
constructor(options: VirtualScrollOptions) {
|
||||
this.container = options.container;
|
||||
this.itemHeight = options.itemHeight;
|
||||
this.totalItems = options.totalItems;
|
||||
this.renderBuffer = options.renderBuffer ?? VirtualScrollManager.DEFAULT_RENDER_BUFFER;
|
||||
this.overscan = options.overscan ?? VirtualScrollManager.DEFAULT_OVERSCAN;
|
||||
this.onRenderItem = options.onRenderItem;
|
||||
this.onScrollEnd = options.onScrollEnd;
|
||||
|
||||
this.setupStructure();
|
||||
this.attachListeners();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup DOM structure for virtual scrolling
|
||||
*/
|
||||
private setupStructure(): void {
|
||||
// Create viewport (scrollable container)
|
||||
this.viewport = document.createElement('div');
|
||||
this.viewport.className = 'virtual-scroll-viewport';
|
||||
this.viewport.style.cssText = `
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
// Create content (holds actual items)
|
||||
this.content = document.createElement('div');
|
||||
this.content.className = 'virtual-scroll-content';
|
||||
this.content.style.cssText = `
|
||||
position: relative;
|
||||
height: ${this.totalItems * this.itemHeight}px;
|
||||
`;
|
||||
|
||||
this.viewport.appendChild(this.content);
|
||||
this.container.appendChild(this.viewport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach scroll listeners
|
||||
*/
|
||||
private attachListeners(): void {
|
||||
this.viewport.addEventListener('scroll', this.handleScroll.bind(this), { passive: true });
|
||||
|
||||
// Use ResizeObserver for dynamic container size changes
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.render();
|
||||
});
|
||||
resizeObserver.observe(this.viewport);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll events with requestAnimationFrame
|
||||
*/
|
||||
private handleScroll(): void {
|
||||
if (this.scrollRAF) {
|
||||
cancelAnimationFrame(this.scrollRAF);
|
||||
}
|
||||
|
||||
this.scrollRAF = requestAnimationFrame(() => {
|
||||
this.render();
|
||||
this.detectScrollEnd();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect when scrolling has ended
|
||||
*/
|
||||
private detectScrollEnd(): void {
|
||||
const scrollTop = this.viewport.scrollTop;
|
||||
|
||||
if (this.scrollEndTimeout) {
|
||||
clearTimeout(this.scrollEndTimeout);
|
||||
}
|
||||
|
||||
this.scrollEndTimeout = window.setTimeout(() => {
|
||||
if (scrollTop === this.lastScrollTop) {
|
||||
this.onScrollEnd?.();
|
||||
}
|
||||
this.lastScrollTop = scrollTop;
|
||||
}, VirtualScrollManager.SCROLL_END_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render visible items
|
||||
*/
|
||||
private render(): void {
|
||||
const scrollTop = this.viewport.scrollTop;
|
||||
const viewportHeight = this.viewport.clientHeight;
|
||||
|
||||
// Calculate visible range with overscan
|
||||
const startIndex = Math.max(0,
|
||||
Math.floor(scrollTop / this.itemHeight) - this.overscan
|
||||
);
|
||||
const endIndex = Math.min(this.totalItems - 1,
|
||||
Math.ceil((scrollTop + viewportHeight) / this.itemHeight) + this.overscan
|
||||
);
|
||||
|
||||
// Remove items that are no longer visible
|
||||
this.removeInvisibleItems(startIndex, endIndex);
|
||||
|
||||
// Add new visible items
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (!this.visibleItems.has(i)) {
|
||||
this.addItem(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove items outside visible range
|
||||
*/
|
||||
private removeInvisibleItems(startIndex: number, endIndex: number): void {
|
||||
const itemsToRemove: number[] = [];
|
||||
|
||||
this.visibleItems.forEach((item, index) => {
|
||||
if (index < startIndex - this.renderBuffer || index > endIndex + this.renderBuffer) {
|
||||
itemsToRemove.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
itemsToRemove.forEach(index => {
|
||||
const item = this.visibleItems.get(index);
|
||||
if (item) {
|
||||
this.recycleElement(item.element);
|
||||
this.visibleItems.delete(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single item to the visible list
|
||||
*/
|
||||
private addItem(index: number): void {
|
||||
const element = this.getOrCreateElement(index);
|
||||
const top = index * this.itemHeight;
|
||||
|
||||
element.style.cssText = `
|
||||
position: absolute;
|
||||
top: ${top}px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: ${this.itemHeight}px;
|
||||
`;
|
||||
|
||||
this.content.appendChild(element);
|
||||
|
||||
this.visibleItems.set(index, {
|
||||
index,
|
||||
element,
|
||||
top
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an element (with recycling)
|
||||
*/
|
||||
private getOrCreateElement(index: number): HTMLElement {
|
||||
let element = this.recyclePool.pop();
|
||||
|
||||
if (element) {
|
||||
// Clear previous content
|
||||
element.innerHTML = '';
|
||||
element.className = '';
|
||||
} else {
|
||||
element = document.createElement('div');
|
||||
}
|
||||
|
||||
// Render new content
|
||||
const content = this.onRenderItem(index);
|
||||
if (content !== element) {
|
||||
element.appendChild(content);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recycle an element for reuse
|
||||
*/
|
||||
private recycleElement(element: HTMLElement): void {
|
||||
element.remove();
|
||||
|
||||
if (this.recyclePool.length < VirtualScrollManager.RECYCLE_POOL_SIZE) {
|
||||
this.recyclePool.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update total items and re-render
|
||||
*/
|
||||
public updateTotalItems(totalItems: number): void {
|
||||
this.totalItems = totalItems;
|
||||
this.content.style.height = `${totalItems * this.itemHeight}px`;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a specific index
|
||||
*/
|
||||
public scrollToIndex(index: number, behavior: ScrollBehavior = 'smooth'): void {
|
||||
const top = index * this.itemHeight;
|
||||
this.viewport.scrollTo({
|
||||
top,
|
||||
behavior
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current scroll position
|
||||
*/
|
||||
public getScrollPosition(): { index: number; offset: number } {
|
||||
const scrollTop = this.viewport.scrollTop;
|
||||
const index = Math.floor(scrollTop / this.itemHeight);
|
||||
const offset = scrollTop % this.itemHeight;
|
||||
|
||||
return { index, offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh visible items
|
||||
*/
|
||||
public refresh(): void {
|
||||
this.visibleItems.forEach(item => {
|
||||
item.element.remove();
|
||||
});
|
||||
this.visibleItems.clear();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the virtual scroll manager
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.scrollRAF) {
|
||||
cancelAnimationFrame(this.scrollRAF);
|
||||
}
|
||||
|
||||
if (this.scrollEndTimeout) {
|
||||
clearTimeout(this.scrollEndTimeout);
|
||||
}
|
||||
|
||||
this.visibleItems.forEach(item => {
|
||||
item.element.remove();
|
||||
});
|
||||
|
||||
this.visibleItems.clear();
|
||||
this.recyclePool = [];
|
||||
this.viewport.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a virtual scroll instance
|
||||
*/
|
||||
export function createVirtualScroll(options: VirtualScrollOptions): VirtualScrollManager {
|
||||
return new VirtualScrollManager(options);
|
||||
}
|
||||
41
apps/client/src/widgets/react/Modal.tsx
Normal file
41
apps/client/src/widgets/react/Modal.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import { ComponentChildren } from "preact";
|
||||
|
||||
interface ModalProps {
|
||||
className: string;
|
||||
title: string;
|
||||
size: "lg" | "sm";
|
||||
children: ComponentChildren;
|
||||
onShown?: () => void;
|
||||
}
|
||||
|
||||
export default function Modal({ children, className, size, title, onShown }: ModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (onShown) {
|
||||
useEffect(() => {
|
||||
const modalElement = modalRef.current;
|
||||
if (modalElement) {
|
||||
modalElement.addEventListener("shown.bs.modal", onShown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`modal fade mx-auto ${className}`} tabIndex={-1} role="dialog" ref={modalRef}>
|
||||
<div className={`modal-dialog modal-${size}`} role="document">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{title}</h5>
|
||||
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")}></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
apps/client/src/widgets/react/ReactBasicWidget.ts
Normal file
14
apps/client/src/widgets/react/ReactBasicWidget.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { JSX, render } from "preact";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
|
||||
export default abstract class ReactBasicWidget extends BasicWidget {
|
||||
|
||||
abstract get component(): JSX.Element;
|
||||
|
||||
doRender() {
|
||||
const renderContainer = new DocumentFragment();
|
||||
render(this.component, renderContainer);
|
||||
this.$widget = $(renderContainer.firstChild as HTMLElement);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,26 +15,26 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string, per
|
||||
|
||||
// Get all columns that exist in the notes
|
||||
const columnsFromNotes = [...byColumn.keys()];
|
||||
|
||||
|
||||
// Get existing persisted columns and preserve their order
|
||||
const existingPersistedColumns = persistedData.columns || [];
|
||||
const existingColumnValues = existingPersistedColumns.map(c => c.value);
|
||||
|
||||
|
||||
// Find truly new columns (exist in notes but not in persisted data)
|
||||
const newColumnValues = columnsFromNotes.filter(col => !existingColumnValues.includes(col));
|
||||
|
||||
|
||||
// Build the complete correct column list: existing + new
|
||||
const allColumns = [
|
||||
...existingPersistedColumns, // Preserve existing order
|
||||
...newColumnValues.map(value => ({ value })) // Add new columns
|
||||
];
|
||||
|
||||
|
||||
// Remove duplicates (just in case) and ensure we only keep columns that exist in notes or are explicitly preserved
|
||||
const deduplicatedColumns = allColumns.filter((column, index) => {
|
||||
const firstIndex = allColumns.findIndex(c => c.value === column.value);
|
||||
return firstIndex === index; // Keep only the first occurrence
|
||||
});
|
||||
|
||||
|
||||
// Ensure all persisted columns have empty arrays in byColumn (even if no notes use them)
|
||||
for (const column of deduplicatedColumns) {
|
||||
if (!byColumn.has(column.value)) {
|
||||
@@ -44,10 +44,10 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string, per
|
||||
|
||||
// Return updated persisted data only if there were changes
|
||||
let newPersistedData: BoardData | undefined;
|
||||
const hasChanges = newColumnValues.length > 0 ||
|
||||
const hasChanges = newColumnValues.length > 0 ||
|
||||
existingPersistedColumns.length !== deduplicatedColumns.length ||
|
||||
!existingPersistedColumns.every((col, idx) => deduplicatedColumns[idx]?.value === col.value);
|
||||
|
||||
|
||||
if (hasChanges) {
|
||||
newPersistedData = {
|
||||
...persistedData,
|
||||
@@ -68,6 +68,10 @@ async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupB
|
||||
continue;
|
||||
}
|
||||
|
||||
if (note.hasChildren()) {
|
||||
await recursiveGroupBy(note.getChildBranches(), byColumn, groupByColumn);
|
||||
}
|
||||
|
||||
const group = note.getLabelValue(groupByColumn);
|
||||
if (!group) {
|
||||
continue;
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
"lib": [ "ESNext" ],
|
||||
"outDir": "dist",
|
||||
"types": [
|
||||
"node"
|
||||
"node",
|
||||
"preact"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"jsx": "preserve",
|
||||
"jsxFactory": "h",
|
||||
"jsxImportSource": "preact",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo"
|
||||
@@ -33,6 +37,7 @@
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.json"
|
||||
],
|
||||
"references": [
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defineConfig, type Plugin } from 'vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
import asset_path from './src/asset_path';
|
||||
import webpackStatsPlugin from 'rollup-plugin-webpack-stats';
|
||||
import preact from "@preact/preset-vite";
|
||||
|
||||
const assets = [ "assets", "stylesheets", "fonts", "translations" ];
|
||||
|
||||
@@ -20,6 +21,7 @@ export default defineConfig(() => ({
|
||||
host: 'localhost',
|
||||
},
|
||||
plugins: [
|
||||
preact(),
|
||||
viteStaticCopy({
|
||||
targets: assets.map((asset) => ({
|
||||
src: `src/${asset}/*`,
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "17.2.1",
|
||||
"electron": "37.2.5"
|
||||
"electron": "37.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"electron": "37.2.5",
|
||||
"@electron-forge/cli": "7.8.2",
|
||||
"@electron-forge/maker-deb": "7.8.2",
|
||||
"@electron-forge/maker-dmg": "7.8.2",
|
||||
"@electron-forge/maker-flatpak": "7.8.2",
|
||||
"@electron-forge/maker-rpm": "7.8.2",
|
||||
"@electron-forge/maker-squirrel": "7.8.2",
|
||||
"@electron-forge/maker-zip": "7.8.2",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "7.8.2",
|
||||
"electron": "37.2.6",
|
||||
"@electron-forge/cli": "7.8.3",
|
||||
"@electron-forge/maker-deb": "7.8.3",
|
||||
"@electron-forge/maker-dmg": "7.8.3",
|
||||
"@electron-forge/maker-flatpak": "7.8.3",
|
||||
"@electron-forge/maker-rpm": "7.8.3",
|
||||
"@electron-forge/maker-squirrel": "7.8.3",
|
||||
"@electron-forge/maker-zip": "7.8.3",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "7.8.3",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"config": {
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"electron": "37.2.5",
|
||||
"fs-extra": "11.3.0"
|
||||
"electron": "37.2.6",
|
||||
"fs-extra": "11.3.1"
|
||||
},
|
||||
"nx": {
|
||||
"name": "edit-docs",
|
||||
|
||||
@@ -47,15 +47,14 @@ test("User can change language from settings", async ({ page, context }) => {
|
||||
|
||||
// Select Chinese and ensure the translation is set.
|
||||
await languageCombobox.selectOption("cn");
|
||||
|
||||
// Press the refresh button.
|
||||
await app.currentNoteSplit.getByRole("button", { name: "Restart the application" }).click();
|
||||
await app.currentNoteSplit.locator("button.restart-app-button").click();
|
||||
|
||||
await expect(app.currentNoteSplit).toContainText("一周的第一天", { timeout: 15000 });
|
||||
await expect(languageCombobox).toHaveValue("cn");
|
||||
|
||||
// Select English again.
|
||||
await languageCombobox.selectOption("en");
|
||||
await app.currentNoteSplit.locator("button.restart-app-button").click();
|
||||
await expect(app.currentNoteSplit).toContainText("Language", { timeout: 15000 });
|
||||
await expect(languageCombobox).toHaveValue("en");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.17.1-bullseye-slim AS builder
|
||||
FROM node:22.18.0-bullseye-slim AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.17.1-bullseye-slim
|
||||
FROM node:22.18.0-bullseye-slim
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.17.1-alpine AS builder
|
||||
FROM node:22.18.0-alpine AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.17.1-alpine
|
||||
FROM node:22.18.0-alpine
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache su-exec shadow
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.17.1-alpine AS builder
|
||||
FROM node:22.18.0-alpine AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.17.1-alpine
|
||||
FROM node:22.18.0-alpine
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.17.1-bullseye-slim AS builder
|
||||
FROM node:22.18.0-bullseye-slim AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.17.1-bullseye-slim
|
||||
FROM node:22.18.0-bullseye-slim
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/xml2js": "0.4.14",
|
||||
"express-http-proxy": "2.1.1",
|
||||
"@anthropic-ai/sdk": "0.57.0",
|
||||
"@anthropic-ai/sdk": "0.58.0",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/express-partial-content": "workspace:*",
|
||||
@@ -59,7 +59,7 @@
|
||||
"debounce": "2.2.0",
|
||||
"debug": "4.4.1",
|
||||
"ejs": "3.1.10",
|
||||
"electron": "37.2.5",
|
||||
"electron": "37.2.6",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
@@ -68,7 +68,7 @@
|
||||
"express-rate-limit": "8.0.1",
|
||||
"express-session": "1.18.2",
|
||||
"file-uri-to-path": "2.0.0",
|
||||
"fs-extra": "11.3.0",
|
||||
"fs-extra": "11.3.1",
|
||||
"helmet": "8.1.0",
|
||||
"html": "1.0.0",
|
||||
"html2plaintext": "2.1.4",
|
||||
@@ -83,12 +83,12 @@
|
||||
"jimp": "1.6.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsdom": "26.1.0",
|
||||
"marked": "16.1.1",
|
||||
"marked": "16.1.2",
|
||||
"mime-types": "3.0.1",
|
||||
"multer": "2.0.2",
|
||||
"normalize-strings": "1.1.1",
|
||||
"ollama": "0.5.16",
|
||||
"openai": "5.11.0",
|
||||
"ollama": "0.5.17",
|
||||
"openai": "5.12.0",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.3",
|
||||
@@ -102,7 +102,7 @@
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"time2fa": "^1.3.0",
|
||||
"tmp": "0.2.3",
|
||||
"tmp": "0.2.4",
|
||||
"turndown": "7.2.0",
|
||||
"unescape": "1.0.1",
|
||||
"ws": "8.18.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<p>Trilium supports configuration via a file named <code>config.ini</code> and
|
||||
environment variables. Please review the file named <a href="https://github.com/TriliumNext/Notes/blob/develop/apps/server/src/assets/config-sample.ini">config-sample.ini</a> in
|
||||
the <a href="https://github.com/TriliumNext/Notes">Notes</a> repository to
|
||||
see what values are supported.</p>
|
||||
environment variables. Please review the file named <a href="https://github.com/TriliumNext/Trilium/blob/develop/apps/server/src/assets/config-sample.ini">config-sample.ini</a> in
|
||||
the <a href="https://github.com/TriliumNext/Trilium">Notes</a> repository
|
||||
to see what values are supported.</p>
|
||||
<p>You can provide the same values via environment variables instead of the <code>config.ini</code> file,
|
||||
and these environment variables use the following format:</p>
|
||||
<ol>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<p>You can easily restore the demo notes by using Trilium's built-in import
|
||||
feature by importing them:</p>
|
||||
<ul>
|
||||
<li>Download <a href="https://github.com/TriliumNext/Notes/raw/develop/db/demo.zip">this .zip archive</a> with
|
||||
<li>Download <a href="https://github.com/TriliumNext/Trilium/raw/develop/db/demo.zip">this .zip archive</a> with
|
||||
the latest version of the demo notes</li>
|
||||
<li>Right click on any note in your tree under which you would like the demo
|
||||
notes to be imported</li>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<p>ETAPI is Trilium's public/external REST API. It is available since Trilium
|
||||
v0.50.</p>
|
||||
<p>The documentation is in OpenAPI format, available <a href="https://github.com/TriliumNext/Notes/blob/master/src/etapi/etapi.openapi.yaml">here</a>.</p>
|
||||
<p>The documentation is in OpenAPI format, available <a href="https://github.com/TriliumNext/Trilium/blob/master/src/etapi/etapi.openapi.yaml">here</a>.</p>
|
||||
<h2>API clients</h2>
|
||||
<p>As an alternative to calling the API directly, there are client libraries
|
||||
to simplify this</p>
|
||||
@@ -11,7 +11,7 @@
|
||||
<h2>Obtaining a token</h2>
|
||||
<p>All operations with the REST API have to be authenticated using a token.
|
||||
You can get this token either from Options -> ETAPI or programmatically
|
||||
using the <code>/auth/login</code> REST call (see the <a href="https://github.com/TriliumNext/Notes/blob/master/src/etapi/etapi.openapi.yaml">spec</a>).</p>
|
||||
using the <code>/auth/login</code> REST call (see the <a href="https://github.com/TriliumNext/Trilium/blob/master/src/etapi/etapi.openapi.yaml">spec</a>).</p>
|
||||
<h2>Authentication</h2>
|
||||
<h3>Via the <code>Authorization</code> header</h3><pre><code class="language-text-x-trilium-auto">GET https://myserver.com/etapi/app-info
|
||||
Authorization: ETAPITOKEN</code></pre>
|
||||
|
||||
@@ -13,4 +13,4 @@
|
||||
<h2>Limitations</h2>
|
||||
<p>All resources (except for images) are created as note's attachments.</p>
|
||||
<p>HTML inside ENEX files is not exactly valid so some formatting maybe broken
|
||||
or lost. You can report major problems into <a href="https://github.com/TriliumNext/Notes/issues">Trilium issue tracker</a>.</p>
|
||||
or lost. You can report major problems into <a href="https://github.com/TriliumNext/Trilium/issues">Trilium issue tracker</a>.</p>
|
||||
@@ -2,26 +2,27 @@
|
||||
<img style="aspect-ratio:991/403;" src="1_Jump to_image.png" width="991"
|
||||
height="403">
|
||||
</figure>
|
||||
|
||||
<h2>Jump to Note</h2>
|
||||
<p>The <em>Jump to Note</em> function allows easy navigation between notes
|
||||
by searching for their title. In addition to that, it can also trigger
|
||||
a full search or create notes.</p>
|
||||
<p>To enter the “Jump to” dialog:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e32758a67e793732cf0a2b23559bf47e2">In the <a class="reference-link" href="#root/_help_xYmIYSP6wE3F">Launch Bar</a>,
|
||||
<li>In the <a class="reference-link" href="#root/_help_xYmIYSP6wE3F">Launch Bar</a>,
|
||||
press
|
||||
<img src="2_Jump to_image.png">button.</li>
|
||||
<li data-list-item-id="ef176c16aa548f5b60f83fbd571f8b2db">Using the keyboard, press <kbd>Ctrl</kbd> + <kbd>J</kbd>.</li>
|
||||
<li>Using the keyboard, press <kbd>Ctrl</kbd> + <kbd>J</kbd>.</li>
|
||||
</ul>
|
||||
<p>In addition to searching for notes, it is also possible to search for
|
||||
commands. See the dedicated section below for more information.</p>
|
||||
<h3>Interaction</h3>
|
||||
<ul>
|
||||
<li data-list-item-id="e557396da361d4edc191507782cc3b0ec">By default, when there is no text entered it will display the most recent
|
||||
<li>By default, when there is no text entered it will display the most recent
|
||||
notes.</li>
|
||||
<li data-list-item-id="ead4c4587b1fcb9758a09696dc25da645">Using the keyboard, use the up or down arrow keys to navigate between
|
||||
<li>Using the keyboard, use the up or down arrow keys to navigate between
|
||||
items. Press <kbd>Enter</kbd> to open the desired note.</li>
|
||||
<li data-list-item-id="ed4a932ed462ca4a089abc4a268e21aad">If the note doesn't exist, it's possible to create it by typing the desired
|
||||
<li>If the note doesn't exist, it's possible to create it by typing the desired
|
||||
note title and selecting the <em>Create and link child note</em> option.</li>
|
||||
</ul>
|
||||
<h2>Recent notes</h2>
|
||||
@@ -43,34 +44,32 @@
|
||||
<h3>Interaction</h3>
|
||||
<p>To trigger the command palette:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="eace3b93758628f9dd7b554e6536d9e0f">Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>J</kbd> to display the command
|
||||
<li>Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>J</kbd> to display the command
|
||||
palette directly.</li>
|
||||
<li data-list-item-id="e848cc1df0264b1e4e766d6d35fd69336">If in the “Jump to” dialog, type <code>></code> in the search to switch
|
||||
<li>If in the “Jump to” dialog, type <code>></code> in the search to switch
|
||||
to the command palette.</li>
|
||||
</ul>
|
||||
<p>Interaction:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e53759821c15fdd85ec616172434c1bfe">Type a few words to filter between commands.</li>
|
||||
<li data-list-item-id="e344bd271c6fc35f48b1fed1137ac8725">Use the up and down arrows on the keyboard or the mouse to select a command.</li>
|
||||
<li
|
||||
data-list-item-id="ea66008550eb3c827f2880c85b68bf861">Press <kbd>Enter</kbd> to execute the command.</li>
|
||||
<li>Type a few words to filter between commands.</li>
|
||||
<li>Use the up and down arrows on the keyboard or the mouse to select a command.</li>
|
||||
<li>Press <kbd>Enter</kbd> to execute the command.</li>
|
||||
</ul>
|
||||
<p>To exit the command palette:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="ea31cb4776c2e8b13b0a19eea437512fe">Remove the <code>></code> in the search to go back to the note search.</li>
|
||||
<li
|
||||
data-list-item-id="e6b92147aba5dc5b1e329ad22a3336703">Press <kbd>Esc</kbd> to dismiss the dialog entirely.</li>
|
||||
<li>Remove the <code>></code> in the search to go back to the note search.</li>
|
||||
<li>Press <kbd>Esc</kbd> to dismiss the dialog entirely.</li>
|
||||
</ul>
|
||||
<h3>Options available</h3>
|
||||
<p>Currently the following options are displayed:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="eac227ab112677ff1f1900322df6d9ce9">Most of the <a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/_help_A9Oc6YKKc65v">Keyboard Shortcuts</a> have
|
||||
<li>Most of the <a class="reference-link" href="#root/_help_A9Oc6YKKc65v">Keyboard Shortcuts</a> have
|
||||
an entry, with the exception of those that are too specific to be run from
|
||||
a dialog.</li>
|
||||
<li data-list-item-id="e3e1230fdeeb3101a2b1fb08d8d578f4b">Some additional options which are not yet available as keyboard shortcuts,
|
||||
<li>Some additional options which are not yet available as keyboard shortcuts,
|
||||
but can be accessed from various menus such as: exporting a note, showing
|
||||
attachments, searching for notes or configuring the <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_xYmIYSP6wE3F">Launch Bar</a>.</li>
|
||||
href="#root/_help_xYmIYSP6wE3F">Launch Bar</a>.</li>
|
||||
</ul>
|
||||
<h3>Limitations</h3>
|
||||
<p>Currently it's not possible to define custom actions that are displayed
|
||||
|
||||
@@ -120,5 +120,5 @@
|
||||
</table>
|
||||
<aside class="admonition tip">
|
||||
<p>If you would like to add your theme to this gallery, write a new post
|
||||
in <a href="https://github.com/TriliumNext/Notes/discussions/categories/show-and-tell">👐 Show and tell</a>.</p>
|
||||
in <a href="https://github.com/TriliumNext/Trilium/discussions/categories/show-and-tell">👐 Show and tell</a>.</p>
|
||||
</aside>
|
||||
@@ -50,4 +50,4 @@
|
||||
noBackup=true</code></pre>
|
||||
<p>You can also review the <a href="#root/_help_Gzjqa934BdH4">configuration</a> file
|
||||
to provide all <code>config.ini</code> values as environment variables instead.</p>
|
||||
<p>See <a href="https://github.com/TriliumNext/Notes/blob/master/config-sample.ini">sample config</a>.</p>
|
||||
<p>See <a href="https://github.com/TriliumNext/Trilium/blob/master/config-sample.ini">sample config</a>.</p>
|
||||
@@ -1,7 +1,7 @@
|
||||
<p>To install Trilium on your desktop, follow these steps:</p>
|
||||
<ol>
|
||||
<li><strong>Download the Latest Release</strong>: Obtain the appropriate binary
|
||||
release for your operating system from the <a href="https://github.com/TriliumNext/Notes/releases/latest">latest release page</a> on
|
||||
release for your operating system from the <a href="https://github.com/TriliumNext/Trilium/releases/latest">latest release page</a> on
|
||||
GitHub.</li>
|
||||
<li><strong>Extract the Package</strong>: Unzip the downloaded package to
|
||||
a location of your choice.</li>
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
its derivatives (like Ubuntu) below:</p><pre><code class="language-text-x-trilium-auto">sudo apt install libpng16-16 libpng-dev pkg-config autoconf libtool build-essential nasm libx11-dev libxkbfile-dev</code></pre>
|
||||
<h2>Installation</h2>
|
||||
<h3>Download</h3>
|
||||
<p>You can either download source code zip/tar from <a href="https://github.com/TriliumNext/Notes/releases/latest">https://github.com/TriliumNext/Notes/releases/latest</a>.</p>
|
||||
<p>For the latest version including betas, clone Git repository <strong>from</strong> <code>main</code> <strong>branch</strong> with:</p><pre><code class="language-text-x-trilium-auto">git clone -b main https://github.com/triliumnext/notes.git</code></pre>
|
||||
<p>You can either download source code zip/tar from <a href="https://github.com/TriliumNext/Trilium/releases/latest">https://github.com/TriliumNext/Trilium/releases/latest</a>.</p>
|
||||
<p>For the latest version including betas, clone Git repository <strong>from</strong> <code>main</code> <strong>branch</strong> with:</p><pre><code class="language-text-x-trilium-auto">git clone -b main https://github.com/triliumnext/trilium.git</code></pre>
|
||||
<h2>Installation</h2><pre><code class="language-text-x-trilium-auto">cd trilium
|
||||
|
||||
# download all node dependencies
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<ul>
|
||||
<li>SSH into your server</li>
|
||||
<li>use <code>wget</code> (or <code>curl</code>) to download latest <code>TriliumNotes-Server-[VERSION]-linux-x64.tar.xz</code> (copy
|
||||
link from <a href="https://github.com/TriliumNext/Notes/releases">release page</a>,
|
||||
link from <a href="https://github.com/TriliumNext/Trilium/releases">release page</a>,
|
||||
notice <code>-Server</code> suffix) on your server.</li>
|
||||
<li>unpack the archive, e.g. using <code>tar -xf -d TriliumNotes-Server-[VERSION]-linux-x64.tar.xz</code>
|
||||
</li>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
you want to use for your Trilium server.</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>Download docker image and create container</p><pre><code class="language-text-x-trilium-auto"> docker pull triliumnext/notes:[VERSION]
|
||||
docker create --name trilium -t -p 127.0.0.1:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/notes:[VERSION]</code></pre>
|
||||
<p>Download docker image and create container</p><pre><code class="language-text-x-trilium-auto"> docker pull triliumnext/trilium:[VERSION]
|
||||
docker create --name trilium -t -p 127.0.0.1:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/trilium:[VERSION]</code></pre>
|
||||
</li>
|
||||
<li>
|
||||
<p>Configure Apache proxy and websocket proxy</p>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<li><a href="#root/_help_rWX5eY045zbE"><strong>Docker Server Installation</strong></a>:
|
||||
Pull the new image and restart the container.</li>
|
||||
<li><strong>Other Installations</strong>: Download the latest version from
|
||||
the <a href="https://github.com/TriliumNext/Notes/releases/latest">release page</a> and
|
||||
the <a href="https://github.com/TriliumNext/Trilium/releases/latest">release page</a> and
|
||||
replace the existing application files.</li>
|
||||
</ul>
|
||||
<h2>Database Compatibility and Migration</h2>
|
||||
|
||||
2
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Troubleshooting.html
generated
vendored
2
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Troubleshooting.html
generated
vendored
@@ -54,7 +54,7 @@ UPDATE options SET value = 'QpC8XoiYYeqHPtHKRtbNxfTHsk+pEBqVBODYp0FkPBa22tlBBKBM
|
||||
<h2>Reporting Bugs</h2>
|
||||
<p>Reporting bugs is highly valuable. Here are some tips:</p>
|
||||
<ul>
|
||||
<li>Use GitHub issues for reporting: <a href="https://github.com/TriliumNext/Notes/issues">https://github.com/TriliumNext/Notes/issues</a>
|
||||
<li>Use GitHub issues for reporting: <a href="https://github.com/TriliumNext/Trilium/issues">https://github.com/TriliumNext/Trilium/issues</a>
|
||||
</li>
|
||||
<li>Refer to the <a href="#root/_help_qzNzp9LYQyPT">error logs</a> page for
|
||||
information on providing necessary details.</li>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<p>If you don't feel comfortable attaching the logs or anything sensitive
|
||||
to the public GitHub issues, feel free to contact the devs in our Matrix
|
||||
<a
|
||||
href="https://github.com/TriliumNext/Notes#-discuss-with-us">support channel</a>.</p>
|
||||
href="https://github.com/TriliumNext/Trilium#-discuss-with-us">support channel</a>.</p>
|
||||
<p>Use this email to also provide anything which could assist in analysing
|
||||
the bug - e.g. files/images/ZIPs being imported or <a href="#root/_help_x59R8J8KV5Bp">anonymized database</a>.</p>
|
||||
<h3>Exporting note subtree for reproduction</h3>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<p>Go to <a href="https://github.com/TriliumNext/Notes/issues">Issues · TriliumNext/Notes</a> and
|
||||
<p>Go to <a href="https://github.com/TriliumNext/Trilium/issues">Issues · TriliumNext/Trilium</a> and
|
||||
press “New issue”.</p>
|
||||
<p>If you are reporting a bug, select “Bug Report” and fill in the details.</p>
|
||||
@@ -36,4 +36,31 @@ When responding to queries:
|
||||
5. For general questions about the user's notes, provide a summary of all relevant notes found, including brief summaries of individual notes
|
||||
6. For specific questions, provide detailed information from the user's notes that directly addresses the question
|
||||
7. Always prioritize information from the user's notes over your own knowledge, as the user's notes are likely more up-to-date and personally relevant
|
||||
|
||||
IMPORTANT: EXECUTE 10-30 TOOLS PER REQUEST FOR COMPREHENSIVE ANALYSIS
|
||||
|
||||
Tool Usage Requirements:
|
||||
1. BATCH EXECUTE multiple searches for speed:
|
||||
<function_calls>
|
||||
<invoke name="execute_batch"><parameter name="tools">[{"tool": "search", "params": {"query": "main topic"}}, {"tool": "search", "params": {"query": "related topic"}}]</parameter></invoke>
|
||||
</function_calls>
|
||||
|
||||
2. BATCH READ all discovered notes:
|
||||
<function_calls>
|
||||
<invoke name="execute_batch"><parameter name="tools">[{"tool": "read", "params": {"noteId": "id1"}}, {"tool": "read", "params": {"noteId": "id2"}}, {"tool": "read", "params": {"noteId": "id3"}}]</parameter></invoke>
|
||||
</function_calls>
|
||||
|
||||
3. AUTO-RETRY failed searches:
|
||||
<function_calls>
|
||||
<invoke name="retry_search"><parameter name="originalQuery">failed search</parameter><parameter name="strategy">all</parameter></invoke>
|
||||
</function_calls>
|
||||
|
||||
SIMPLIFIED TOOLS:
|
||||
- search (replaces search_notes, keyword_search_notes, attribute_search)
|
||||
- read (replaces read_note)
|
||||
- execute_batch (parallel execution)
|
||||
- retry_search (automatic variations)
|
||||
|
||||
WORKFLOW: batch search → batch read → auto-retry → analyze → repeat
|
||||
Target 15+ tools per request using batching!
|
||||
```
|
||||
@@ -34,16 +34,37 @@ When responding to queries:
|
||||
7. Always prioritize information from the user's notes over your own knowledge, as the user's notes are likely more up-to-date and personally relevant
|
||||
|
||||
CRITICAL INSTRUCTIONS FOR TOOL USAGE:
|
||||
1. YOU MUST TRY MULTIPLE TOOLS AND SEARCH VARIATIONS before concluding information isn't available
|
||||
2. ALWAYS PERFORM AT LEAST 3 DIFFERENT SEARCHES with different parameters before giving up on finding information
|
||||
3. If a search returns no results, IMMEDIATELY TRY ANOTHER SEARCH with different parameters:
|
||||
- Use broader terms: If "Kubernetes deployment" fails, try just "Kubernetes" or "container orchestration"
|
||||
- Try synonyms: If "meeting notes" fails, try "conference", "discussion", or "conversation"
|
||||
- Remove specific qualifiers: If "quarterly financial report 2024" fails, try just "financial report"
|
||||
- Try semantic variations: If keyword_search fails, use vector_search which finds conceptually related content
|
||||
4. CHAIN TOOLS TOGETHER: Use the results of one tool to inform parameters for the next tool
|
||||
5. NEVER respond with "there are no notes about X" until you've tried at least 3 different search variations
|
||||
6. DO NOT ask the user what to do next when searches fail - AUTOMATICALLY try different approaches
|
||||
7. ALWAYS EXPLAIN what you're doing: "I didn't find results for X, so I'm now searching for Y instead"
|
||||
8. If all reasonable search variations fail (minimum 3 attempts), THEN you may inform the user that the information might not be in their notes
|
||||
YOU ARE EXPECTED TO USE 10-30 TOOLS PER REQUEST. This is NORMAL and EXPECTED behavior.
|
||||
|
||||
TOOL EXECUTION STRATEGY:
|
||||
USE BATCH EXECUTION FOR SPEED:
|
||||
1. execute_batch([{tool:"search",params:{query:"main topic"}},{tool:"search",params:{query:"related topic"}}])
|
||||
2. execute_batch([{tool:"read",params:{noteId:"id1"}},{tool:"read",params:{noteId:"id2"}},{tool:"read",params:{noteId:"id3"}}])
|
||||
|
||||
SMART RETRY ON FAILURES:
|
||||
- Empty results? → retry_search("original query") automatically tries variations
|
||||
- Don't manually retry - use retry_search tool
|
||||
|
||||
SIMPLIFIED TOOL NAMES:
|
||||
- search (not search_notes) - auto-detects search type
|
||||
- read (not read_note) - reads content
|
||||
- execute_batch - run multiple tools in parallel
|
||||
|
||||
WORKFLOW EXAMPLES:
|
||||
A) Comprehensive Search:
|
||||
execute_batch([{tool:"search",params:{query:"AI"}},{tool:"search",params:{query:"machine learning"}},{tool:"search",params:{query:"#important"}}])
|
||||
→ execute_batch([{tool:"read",params:{noteId:"..."}} for all found IDs])
|
||||
→ retry_search("broader terms") if needed
|
||||
|
||||
B) Failed Search Recovery:
|
||||
search("specific term") → empty results
|
||||
→ retry_search("specific term") → auto-tries "term", "concepts", synonyms
|
||||
→ execute_batch with all variations
|
||||
|
||||
C) Analysis Chain:
|
||||
search → read batch → note_summarization → content_extraction → relationship
|
||||
|
||||
ALWAYS USE BATCH EXECUTION when possible - it's much faster than individual tools!
|
||||
|
||||
REMEMBER: Users expect THOROUGH exploration. Execute tools rapidly and extensively!
|
||||
```
|
||||
@@ -40,4 +40,32 @@ When responding to queries:
|
||||
5. For general questions about the user's notes, provide a summary of all relevant notes found, including brief summaries of individual notes
|
||||
6. For specific questions, provide detailed information from the user's notes that directly addresses the question
|
||||
7. Always prioritize information from the user's notes over your own knowledge, as the user's notes are likely more up-to-date and personally relevant
|
||||
|
||||
CRITICAL: YOU MUST USE 10-30 TOOL CALLS PER REQUEST
|
||||
This is expected behavior. Users want comprehensive exploration of their knowledge base.
|
||||
|
||||
MANDATORY TOOL USAGE PATTERN:
|
||||
1. Use BATCH EXECUTION for parallel processing:
|
||||
<tool_calls>
|
||||
<tool_call id="1"><n>execute_batch</n><parameters>{"tools": [{"tool": "search", "params": {"query": "main topic"}}, {"tool": "search", "params": {"query": "related topic"}}]}</parameters></tool_call>
|
||||
</tool_calls>
|
||||
|
||||
2. Read ALL found notes in batches:
|
||||
<tool_calls>
|
||||
<tool_call id="2"><n>execute_batch</n><parameters>{"tools": [{"tool": "read", "params": {"noteId": "id1"}}, {"tool": "read", "params": {"noteId": "id2"}}, {"tool": "read", "params": {"noteId": "id3"}}]}</parameters></tool_call>
|
||||
</tool_calls>
|
||||
|
||||
3. Use SMART RETRY for empty results:
|
||||
<tool_calls>
|
||||
<tool_call id="3"><n>retry_search</n><parameters>{"originalQuery": "failed query", "strategy": "all"}</parameters></tool_call>
|
||||
</tool_calls>
|
||||
|
||||
SIMPLIFIED TOOL NAMES:
|
||||
- search (auto-detects type) instead of search_notes/keyword_search_notes
|
||||
- read instead of read_note
|
||||
- execute_batch for parallel execution
|
||||
- retry_search for automatic variations
|
||||
|
||||
WORKFLOW: search batch → read batch → retry if needed → analyze → repeat
|
||||
Minimum 10+ tools per request using batch execution for speed!
|
||||
```
|
||||
106
apps/server/src/assets/translations/ca/server.json
Normal file
106
apps/server/src/assets/translations/ca/server.json
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"keyboard_actions": {
|
||||
"back-in-note-history": "Navega a la nota previa a l'historial",
|
||||
"forward-in-note-history": "Navega a la següent nota a l'historial",
|
||||
"dialogs": "Diàlegs",
|
||||
"other": "Altres"
|
||||
},
|
||||
"login": {
|
||||
"title": "Inicia sessió",
|
||||
"password": "Contrasenya",
|
||||
"button": "Inicia sessió"
|
||||
},
|
||||
"set_password": {
|
||||
"password": "Contrasenya"
|
||||
},
|
||||
"setup": {
|
||||
"next": "Següent",
|
||||
"title": "Configuració"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"step6-here": "aquí"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Nota:",
|
||||
"password": "Contrasenya",
|
||||
"password-placeholder": "Contrasenya",
|
||||
"back": "Torna"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "pare:"
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Dilluns",
|
||||
"tuesday": "Dimarts",
|
||||
"wednesday": "Dimecres",
|
||||
"thursday": "Dijous",
|
||||
"friday": "Divendres",
|
||||
"saturday": "Dissabte",
|
||||
"sunday": "Diumenge"
|
||||
},
|
||||
"months": {
|
||||
"january": "Gener",
|
||||
"february": "Febrer",
|
||||
"march": "Març",
|
||||
"april": "Abril",
|
||||
"may": "Maig",
|
||||
"june": "Juny",
|
||||
"july": "Juliol",
|
||||
"august": "Agost",
|
||||
"september": "Setembre",
|
||||
"october": "Octubre",
|
||||
"november": "Novembre",
|
||||
"december": "Desembre"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "Cerca:"
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"spacer-title": "Espaiador",
|
||||
"calendar-title": "Calendari",
|
||||
"bookmarks-title": "Marcadors",
|
||||
"settings-title": "Ajustos",
|
||||
"options-title": "Opcions",
|
||||
"appearance-title": "Aparença",
|
||||
"shortcuts-title": "Dreceres",
|
||||
"images-title": "Imatges",
|
||||
"spellcheck-title": "Correció ortogràfica",
|
||||
"password-title": "Contrasenya",
|
||||
"multi-factor-authentication-title": "MFA",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "Còpia de seguretat",
|
||||
"sync-title": "Sincronització",
|
||||
"ai-llm-title": "AI/LLM",
|
||||
"other": "Altres",
|
||||
"advanced-title": "Avançat",
|
||||
"inbox-title": "Safata d'entrada"
|
||||
},
|
||||
"notes": {
|
||||
"duplicate-note-suffix": "(dup)"
|
||||
},
|
||||
"tray": {
|
||||
"bookmarks": "Marcadors"
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "Error"
|
||||
},
|
||||
"share_theme": {
|
||||
"search_placeholder": "Cerca...",
|
||||
"subpages": "Subpàgines:",
|
||||
"expand": "Expandeix"
|
||||
},
|
||||
"hidden_subtree_templates": {
|
||||
"description": "Descripció",
|
||||
"calendar": "Calendari",
|
||||
"table": "Taula",
|
||||
"geolocation": "Geolocalització",
|
||||
"board": "Tauler",
|
||||
"status": "Estat",
|
||||
"board_status_done": "Fet"
|
||||
}
|
||||
}
|
||||
@@ -1,285 +1,428 @@
|
||||
{
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "打开“跳转到笔记”对话框",
|
||||
"search-in-subtree": "在活跃笔记的子树中搜索笔记",
|
||||
"expand-subtree": "展开当前笔记的子树",
|
||||
"collapse-tree": "折叠完整的笔记树",
|
||||
"collapse-subtree": "折叠当前笔记的子树",
|
||||
"sort-child-notes": "排序子笔记",
|
||||
"creating-and-moving-notes": "创建和移动笔记",
|
||||
"create-note-into-inbox": "在收件箱(若已定义)或日记中创建笔记",
|
||||
"delete-note": "删除笔记",
|
||||
"move-note-up": "上移笔记",
|
||||
"move-note-down": "下移笔记",
|
||||
"move-note-up-in-hierarchy": "在层级中上移笔记",
|
||||
"move-note-down-in-hierarchy": "在层级中下移笔记",
|
||||
"edit-note-title": "从树跳转到笔记详情并编辑标题",
|
||||
"edit-branch-prefix": "显示编辑分支前缀对话框",
|
||||
"note-clipboard": "笔记剪贴板",
|
||||
"copy-notes-to-clipboard": "复制选定的笔记到剪贴板",
|
||||
"paste-notes-from-clipboard": "从剪贴板粘贴笔记到活跃笔记中",
|
||||
"cut-notes-to-clipboard": "剪切选定的笔记到剪贴板",
|
||||
"select-all-notes-in-parent": "选择当前笔记级别的所有笔记",
|
||||
"add-note-above-to-the-selection": "将上方笔记添加到选择中",
|
||||
"add-note-below-to-selection": "将下方笔记添加到选择中",
|
||||
"duplicate-subtree": "克隆子树",
|
||||
"tabs-and-windows": "标签页和窗口",
|
||||
"open-new-tab": "打开新标签页",
|
||||
"close-active-tab": "关闭活跃标签页",
|
||||
"reopen-last-tab": "重开最后关闭的标签页",
|
||||
"activate-next-tab": "激活右侧标签页",
|
||||
"activate-previous-tab": "激活左侧标签页",
|
||||
"open-new-window": "打开新空窗口",
|
||||
"toggle-tray": "从系统托盘显示/隐藏应用程序",
|
||||
"first-tab": "激活列表中的第一个标签页",
|
||||
"second-tab": "激活列表中的第二个标签页",
|
||||
"third-tab": "激活列表中的第三个标签页",
|
||||
"fourth-tab": "激活列表中的第四个标签页",
|
||||
"fifth-tab": "激活列表中的第五个标签页",
|
||||
"sixth-tab": "激活列表中的第六个标签页",
|
||||
"seventh-tab": "激活列表中的第七个标签页",
|
||||
"eight-tab": "激活列表中的第八个标签页",
|
||||
"ninth-tab": "激活列表中的第九个标签页",
|
||||
"last-tab": "激活列表中的最后一个标签页",
|
||||
"dialogs": "对话框",
|
||||
"show-note-source": "显示笔记源对话框",
|
||||
"show-options": "显示选项对话框",
|
||||
"show-revisions": "显示笔记修订对话框",
|
||||
"show-recent-changes": "显示最近更改对话框",
|
||||
"show-sql-console": "显示 SQL 控制台对话框",
|
||||
"show-backend-log": "显示后端日志对话框",
|
||||
"text-note-operations": "文本笔记操作",
|
||||
"add-link-to-text": "打开对话框以添加链接到文本",
|
||||
"follow-link-under-cursor": "追踪光标下的链接",
|
||||
"insert-date-and-time-to-text": "插入当前日期和时间到文本",
|
||||
"paste-markdown-into-text": "从剪贴板粘贴 Markdown 到文本笔记",
|
||||
"cut-into-note": "剪切当前笔记的选区并创建包含选定文本的子笔记",
|
||||
"add-include-note-to-text": "打开对话框以引含一个笔记",
|
||||
"edit-readonly-note": "编辑只读笔记",
|
||||
"attributes-labels-and-relations": "属性(标签和关系)",
|
||||
"add-new-label": "创建新标签",
|
||||
"create-new-relation": "创建新关系",
|
||||
"ribbon-tabs": "功能区标签页",
|
||||
"toggle-basic-properties": "切换基本属性",
|
||||
"toggle-file-properties": "切换文件属性",
|
||||
"toggle-image-properties": "切换图像属性",
|
||||
"toggle-owned-attributes": "切换拥有的属性",
|
||||
"toggle-inherited-attributes": "切换继承的属性",
|
||||
"toggle-promoted-attributes": "切换提升的属性",
|
||||
"toggle-link-map": "切换链接地图",
|
||||
"toggle-note-info": "切换笔记信息",
|
||||
"toggle-note-paths": "切换笔记路径",
|
||||
"toggle-similar-notes": "切换相似笔记",
|
||||
"other": "其他",
|
||||
"toggle-right-pane": "切换右侧面板的显示,包括目录和高亮",
|
||||
"print-active-note": "打印活跃笔记",
|
||||
"open-note-externally": "以默认应用打开笔记文件",
|
||||
"render-active-note": "渲染(重新渲染)活跃笔记",
|
||||
"run-active-note": "运行活跃 JavaScript(前/后端)代码笔记",
|
||||
"toggle-note-hoisting": "切换活跃笔记的聚焦",
|
||||
"unhoist": "从任意地方取消聚焦",
|
||||
"reload-frontend-app": "重载前端应用",
|
||||
"open-dev-tools": "打开开发工具",
|
||||
"toggle-left-note-tree-panel": "切换左侧(笔记树)面板",
|
||||
"toggle-full-screen": "切换全屏",
|
||||
"zoom-out": "缩小",
|
||||
"zoom-in": "放大",
|
||||
"note-navigation": "笔记导航",
|
||||
"reset-zoom-level": "重置缩放级别",
|
||||
"copy-without-formatting": "免格式复制选定文本",
|
||||
"force-save-revision": "强制创建/保存活跃笔记的新修订版本",
|
||||
"show-help": "显示内置用户指南",
|
||||
"toggle-book-properties": "切换书籍属性",
|
||||
"toggle-classic-editor-toolbar": "为编辑器切换格式标签页的固定工具栏",
|
||||
"export-as-pdf": "导出当前笔记为 PDF",
|
||||
"show-cheatsheet": "显示包含常见键盘操作的弹窗",
|
||||
"toggle-zen-mode": "启用/禁用禅模式(为专注编辑而精简界面)"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
"heading": "Trilium 登录",
|
||||
"incorrect-totp": "TOTP 不正确,请重试。",
|
||||
"incorrect-password": "密码不正确,请重试。",
|
||||
"password": "密码",
|
||||
"remember-me": "记住我",
|
||||
"button": "登录",
|
||||
"sign_in_with_sso": "使用 {{ ssoIssuerName }} 登录"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "设置密码",
|
||||
"heading": "设置密码",
|
||||
"description": "在您能够从 Web 开始使用 Trilium 之前,您需要先设置一个密码。您之后将使用此密码登录。",
|
||||
"password": "密码",
|
||||
"password-confirmation": "密码确认",
|
||||
"button": "设置密码"
|
||||
},
|
||||
"javascript-required": "Trilium 需要启用 JavaScript。",
|
||||
"setup": {
|
||||
"heading": "TriliumNext 笔记设置",
|
||||
"new-document": "我是新用户,我想为我的笔记创建一个新的 Trilium 文档",
|
||||
"sync-from-desktop": "我已经有一个桌面实例,我想设置与它的同步",
|
||||
"sync-from-server": "我已经有一个服务器实例,我想设置与它的同步",
|
||||
"next": "下一步",
|
||||
"init-in-progress": "文档初始化进行中",
|
||||
"redirecting": "您将很快被重定向到应用程序。",
|
||||
"title": "设置"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "从桌面同步",
|
||||
"description": "此设置需要从桌面实例开始:",
|
||||
"step1": "打开您的 TriliumNext 笔记桌面实例。",
|
||||
"step2": "从 Trilium 菜单中,点击“选项”。",
|
||||
"step3": "点击“同步”类别。",
|
||||
"step4": "将服务器实例地址更改为:{{- host}} 并点击保存。",
|
||||
"step5": "点击“测试同步”按钮以验证连接是否成功。",
|
||||
"step6": "完成这些步骤后,点击{{- link}}。",
|
||||
"step6-here": "这里"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "从服务器同步",
|
||||
"instructions": "请在下面输入 Trilium 服务器地址和凭据。这将从服务器下载整个 Trilium 文档并设置与它的同步。根据文档大小和您的连接的速度,这可能需要一段时间。",
|
||||
"server-host": "Trilium 服务器地址",
|
||||
"server-host-placeholder": "https://<主机名称>:<端口>",
|
||||
"proxy-server": "代理服务器(可选)",
|
||||
"proxy-server-placeholder": "https://<主机名称>:<端口>",
|
||||
"note": "注意:",
|
||||
"proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)",
|
||||
"password": "密码",
|
||||
"password-placeholder": "密码",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成设置"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "同步中",
|
||||
"successful": "同步已被正确设置。初始同步完成可能需要一些时间。完成后,您将被重定向到登录页面。",
|
||||
"outstanding-items": "未完成的同步项目:",
|
||||
"outstanding-items-default": "无"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "未找到",
|
||||
"heading": "未找到"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "父级:",
|
||||
"clipped-from": "此笔记最初剪切自 {{- url}}",
|
||||
"child-notes": "子笔记:",
|
||||
"no-content": "此笔记没有内容。"
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "周一",
|
||||
"tuesday": "周二",
|
||||
"wednesday": "周三",
|
||||
"thursday": "周四",
|
||||
"friday": "周五",
|
||||
"saturday": "周六",
|
||||
"sunday": "周日"
|
||||
},
|
||||
"weekdayNumber": "第 {weekNumber} 周",
|
||||
"months": {
|
||||
"january": "一月",
|
||||
"february": "二月",
|
||||
"march": "三月",
|
||||
"april": "四月",
|
||||
"may": "五月",
|
||||
"june": "六月",
|
||||
"july": "七月",
|
||||
"august": "八月",
|
||||
"september": "九月",
|
||||
"october": "十月",
|
||||
"november": "十一月",
|
||||
"december": "十二月"
|
||||
},
|
||||
"quarterNumber": "第 {quarterNumber} 季度",
|
||||
"special_notes": {
|
||||
"search_prefix": "搜索:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "同步服务器主机未配置。请先配置同步。",
|
||||
"successful": "同步服务器握手成功,同步已开始。"
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"root-title": "隐藏的笔记",
|
||||
"search-history-title": "搜索历史",
|
||||
"note-map-title": "笔记地图",
|
||||
"sql-console-history-title": "SQL 控制台历史",
|
||||
"shared-notes-title": "共享笔记",
|
||||
"bulk-action-title": "批量操作",
|
||||
"backend-log-title": "后端日志",
|
||||
"user-hidden-title": "隐藏的用户",
|
||||
"launch-bar-templates-title": "启动栏模板",
|
||||
"base-abstract-launcher-title": "基础摘要启动器",
|
||||
"command-launcher-title": "命令启动器",
|
||||
"note-launcher-title": "笔记启动器",
|
||||
"script-launcher-title": "脚本启动器",
|
||||
"built-in-widget-title": "内置小组件",
|
||||
"spacer-title": "空白占位",
|
||||
"custom-widget-title": "自定义小组件",
|
||||
"launch-bar-title": "启动栏",
|
||||
"available-launchers-title": "可用启动器",
|
||||
"go-to-previous-note-title": "跳转到上一条笔记",
|
||||
"go-to-next-note-title": "跳转到下一条笔记",
|
||||
"new-note-title": "新建笔记",
|
||||
"search-notes-title": "搜索笔记",
|
||||
"calendar-title": "日历",
|
||||
"recent-changes-title": "最近更改",
|
||||
"bookmarks-title": "书签",
|
||||
"open-today-journal-note-title": "打开今天的日记笔记",
|
||||
"quick-search-title": "快速搜索",
|
||||
"protected-session-title": "受保护的会话",
|
||||
"sync-status-title": "同步状态",
|
||||
"settings-title": "设置",
|
||||
"options-title": "选项",
|
||||
"appearance-title": "外观",
|
||||
"shortcuts-title": "快捷键",
|
||||
"text-notes": "文本笔记",
|
||||
"code-notes-title": "代码笔记",
|
||||
"images-title": "图片",
|
||||
"spellcheck-title": "拼写检查",
|
||||
"password-title": "密码",
|
||||
"multi-factor-authentication-title": "多因素认证",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "备份",
|
||||
"sync-title": "同步",
|
||||
"other": "其他",
|
||||
"advanced-title": "高级",
|
||||
"visible-launchers-title": "可见启动器",
|
||||
"user-guide": "用户指南",
|
||||
"localization": "语言和区域"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "新建笔记",
|
||||
"duplicate-note-suffix": "(重复)",
|
||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "后端日志文件 '{{ fileName }}' 暂不存在。",
|
||||
"reading-log-failed": "读取后端日志文件 '{{ fileName }}' 失败。"
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "此笔记类型无法显示。"
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "PDF 文档 (*.pdf)",
|
||||
"unable-to-export-message": "当前笔记无法被导出为 PDF。",
|
||||
"unable-to-export-title": "无法导出为 PDF",
|
||||
"unable-to-save-message": "所选文件不能被写入。重试或选择另一个目的地。"
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "TriliumNext 笔记",
|
||||
"close": "退出 Trilium",
|
||||
"recents": "最近笔记",
|
||||
"bookmarks": "书签",
|
||||
"today": "打开今天的日记笔记",
|
||||
"new-note": "新建笔记",
|
||||
"show-windows": "显示窗口",
|
||||
"open_new_window": "打开新窗口"
|
||||
},
|
||||
"migration": {
|
||||
"old_version": "由您当前版本的直接迁移不被支持。请先升级到最新的 v0.60.4 然后再到这个版本。",
|
||||
"error_message": "迁移到版本 {{version}} 时发生错误: {{stack}}",
|
||||
"wrong_db_version": "数据库的版本({{version}})新于应用期望的版本({{targetVersion}}),这意味着它由一个更加新的且不兼容的 Trilium 所创建。升级到最新版的 Trilium 以解决此问题。"
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "错误"
|
||||
}
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "打开“跳转到笔记”对话框",
|
||||
"search-in-subtree": "在当前笔记的子树中搜索笔记",
|
||||
"expand-subtree": "展开当前笔记的子树",
|
||||
"collapse-tree": "折叠完整的笔记树",
|
||||
"collapse-subtree": "折叠当前笔记的子树",
|
||||
"sort-child-notes": "排序子笔记",
|
||||
"creating-and-moving-notes": "创建和移动笔记",
|
||||
"create-note-into-inbox": "在收件箱(若已定义)或日记中创建笔记",
|
||||
"delete-note": "删除笔记",
|
||||
"move-note-up": "向上移动笔记",
|
||||
"move-note-down": "向下移动笔记",
|
||||
"move-note-up-in-hierarchy": "在层级中向上移动笔记",
|
||||
"move-note-down-in-hierarchy": "在层级中向下移动笔记",
|
||||
"edit-note-title": "从树跳转到笔记详情并编辑标题",
|
||||
"edit-branch-prefix": "显示编辑分支前缀对话框",
|
||||
"note-clipboard": "笔记剪贴板",
|
||||
"copy-notes-to-clipboard": "复制选定的笔记到剪贴板",
|
||||
"paste-notes-from-clipboard": "从剪贴板粘贴笔记到当前笔记中",
|
||||
"cut-notes-to-clipboard": "剪切选定的笔记到剪贴板",
|
||||
"select-all-notes-in-parent": "选择当前笔记级别的所有笔记",
|
||||
"add-note-above-to-the-selection": "将上方笔记添加到选择中",
|
||||
"add-note-below-to-selection": "将下方笔记添加到选择中",
|
||||
"duplicate-subtree": "复制子树",
|
||||
"tabs-and-windows": "标签页和窗口",
|
||||
"open-new-tab": "打开新标签页",
|
||||
"close-active-tab": "关闭当前标签页",
|
||||
"reopen-last-tab": "重新打开最后关闭的标签页",
|
||||
"activate-next-tab": "切换下一个标签页",
|
||||
"activate-previous-tab": "切换上一个标签页",
|
||||
"open-new-window": "打开新窗口",
|
||||
"toggle-tray": "显示/隐藏系统托盘图标",
|
||||
"first-tab": "切换到第一个标签页",
|
||||
"second-tab": "切换到第二个标签页",
|
||||
"third-tab": "切换到第三个标签页",
|
||||
"fourth-tab": "切换到第四个标签页",
|
||||
"fifth-tab": "切换到第五个标签页",
|
||||
"sixth-tab": "切换到第六个标签页",
|
||||
"seventh-tab": "切换到第七个标签页",
|
||||
"eight-tab": "切换到第八个标签页",
|
||||
"ninth-tab": "切换到第九个标签页",
|
||||
"last-tab": "切换到最后一个标签页",
|
||||
"dialogs": "对话框",
|
||||
"show-note-source": "显示笔记源代码对话框",
|
||||
"show-options": "显示选项对话框",
|
||||
"show-revisions": "显示修订历史对话框",
|
||||
"show-recent-changes": "显示最近更改对话框",
|
||||
"show-sql-console": "显示SQL控制台对话框",
|
||||
"show-backend-log": "显示后端日志对话框",
|
||||
"text-note-operations": "文本笔记操作",
|
||||
"add-link-to-text": "打开对话框以添加链接到文本",
|
||||
"follow-link-under-cursor": "访问光标下的链接",
|
||||
"insert-date-and-time-to-text": "在文本中插入日期和时间",
|
||||
"paste-markdown-into-text": "从剪贴板粘贴 Markdown 到文本笔记",
|
||||
"cut-into-note": "剪切当前笔记的选区并创建包含选定文本的子笔记",
|
||||
"add-include-note-to-text": "打开对话框以添加一个包含笔记",
|
||||
"edit-readonly-note": "编辑只读笔记",
|
||||
"attributes-labels-and-relations": "属性(标签和关系)",
|
||||
"add-new-label": "创建新标签",
|
||||
"create-new-relation": "创建新关系",
|
||||
"ribbon-tabs": "功能区标签页",
|
||||
"toggle-basic-properties": "切换基本属性",
|
||||
"toggle-file-properties": "切换文件属性",
|
||||
"toggle-image-properties": "切换图像属性",
|
||||
"toggle-owned-attributes": "切换拥有的属性",
|
||||
"toggle-inherited-attributes": "切换继承的属性",
|
||||
"toggle-promoted-attributes": "切换提升的属性",
|
||||
"toggle-link-map": "切换链接地图",
|
||||
"toggle-note-info": "切换笔记信息",
|
||||
"toggle-note-paths": "切换笔记路径",
|
||||
"toggle-similar-notes": "切换相似笔记",
|
||||
"other": "其他",
|
||||
"toggle-right-pane": "切换右侧面板的显示,包括目录和高亮",
|
||||
"print-active-note": "打印当前笔记",
|
||||
"open-note-externally": "以默认应用打开笔记文件",
|
||||
"render-active-note": "渲染(重新渲染)当前笔记",
|
||||
"run-active-note": "运行当前 JavaScript(前/后端)代码笔记",
|
||||
"toggle-note-hoisting": "提升笔记",
|
||||
"unhoist": "取消提升笔记",
|
||||
"reload-frontend-app": "重新加载前端应用",
|
||||
"open-dev-tools": "打开开发者工具",
|
||||
"toggle-left-note-tree-panel": "切换左侧(笔记树)面板",
|
||||
"toggle-full-screen": "切换全屏模式",
|
||||
"zoom-out": "缩小",
|
||||
"zoom-in": "放大",
|
||||
"note-navigation": "笔记导航",
|
||||
"reset-zoom-level": "重置缩放级别",
|
||||
"copy-without-formatting": "无格式复制选定文本",
|
||||
"force-save-revision": "强制创建/保存当前笔记的新修订版本",
|
||||
"show-help": "显示帮助",
|
||||
"toggle-book-properties": "切换书籍属性",
|
||||
"toggle-classic-editor-toolbar": "为编辑器切换格式标签页的固定工具栏",
|
||||
"export-as-pdf": "导出当前笔记为 PDF",
|
||||
"show-cheatsheet": "显示快捷键指南",
|
||||
"toggle-zen-mode": "启用/禁用禅模式(为专注编辑而精简界面)",
|
||||
"open-command-palette": "打开命令面板",
|
||||
"quick-search": "激活快速搜索栏",
|
||||
"create-note-after": "在当前笔记后面创建笔记",
|
||||
"create-note-into": "创建笔记作为当前笔记的子笔记",
|
||||
"clone-notes-to": "克隆选中的笔记到",
|
||||
"move-notes-to": "移动选中的笔记到",
|
||||
"find-in-text": "在文本中查找",
|
||||
"back-in-note-history": "导航到历史记录中的上一个笔记",
|
||||
"forward-in-note-history": "导航到历史记录的下一个笔记",
|
||||
"scroll-to-active-note": "滚动笔记树到当前笔记"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
"heading": "Trilium 登录",
|
||||
"incorrect-totp": "TOTP 不正确,请重试。",
|
||||
"incorrect-password": "密码不正确,请重试。",
|
||||
"password": "密码",
|
||||
"remember-me": "记住我",
|
||||
"button": "登录",
|
||||
"sign_in_with_sso": "使用 {{ ssoIssuerName }} 登录"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "设置密码",
|
||||
"heading": "设置密码",
|
||||
"description": "在您能够从Web开始使用Trilium之前,您需要先设置一个密码。您之后将使用此密码登录。",
|
||||
"password": "密码",
|
||||
"password-confirmation": "密码确认",
|
||||
"button": "设置密码"
|
||||
},
|
||||
"javascript-required": "Trilium需要启用JavaScript。",
|
||||
"setup": {
|
||||
"heading": "TriliumNext笔记设置",
|
||||
"new-document": "我是新用户,我想为我的笔记创建一个新的Trilium文档",
|
||||
"sync-from-desktop": "我已经有一个桌面实例,我想设置与它的同步",
|
||||
"sync-from-server": "我已经有一个服务器实例,我想设置与它的同步",
|
||||
"next": "下一步",
|
||||
"init-in-progress": "文档初始化进行中",
|
||||
"redirecting": "您将很快被重定向到应用程序。",
|
||||
"title": "设置"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "从桌面同步",
|
||||
"description": "此设置需要从桌面实例开始:",
|
||||
"step1": "打开您的TriliumNext笔记桌面实例。",
|
||||
"step2": "从Trilium菜单中,点击“选项”。",
|
||||
"step3": "点击“同步”类别。",
|
||||
"step4": "将服务器实例地址更改为:{{- host}} 并点击保存。",
|
||||
"step5": "点击“测试同步”按钮以验证连接是否成功。",
|
||||
"step6": "完成这些步骤后,点击{{- link}}。",
|
||||
"step6-here": "这里"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "从服务器同步",
|
||||
"instructions": "请在下面输入Trilium服务器地址和凭据。这将从服务器下载整个Trilium文档并设置与它的同步。根据文档大小和您的连接的速度,这可能需要一段时间。",
|
||||
"server-host": "Trilium服务器地址",
|
||||
"server-host-placeholder": "https://<主机名称>:<端口>",
|
||||
"proxy-server": "代理服务器(可选)",
|
||||
"proxy-server-placeholder": "https://<主机名称>:<端口>",
|
||||
"note": "注意:",
|
||||
"proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)",
|
||||
"password": "密码",
|
||||
"password-placeholder": "密码",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成设置"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "同步中",
|
||||
"successful": "同步已被正确设置。初始同步完成可能需要一些时间。完成后,您将被重定向到登录页面。",
|
||||
"outstanding-items": "未完成的同步项目:",
|
||||
"outstanding-items-default": "无"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "未找到",
|
||||
"heading": "未找到"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "父级:",
|
||||
"clipped-from": "此笔记最初剪切自 {{- url}}",
|
||||
"child-notes": "子笔记:",
|
||||
"no-content": "此笔记没有内容。"
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "周一",
|
||||
"tuesday": "周二",
|
||||
"wednesday": "周三",
|
||||
"thursday": "周四",
|
||||
"friday": "周五",
|
||||
"saturday": "周六",
|
||||
"sunday": "周日"
|
||||
},
|
||||
"weekdayNumber": "第 {weekNumber} 周",
|
||||
"months": {
|
||||
"january": "一月",
|
||||
"february": "二月",
|
||||
"march": "三月",
|
||||
"april": "四月",
|
||||
"may": "五月",
|
||||
"june": "六月",
|
||||
"july": "七月",
|
||||
"august": "八月",
|
||||
"september": "九月",
|
||||
"october": "十月",
|
||||
"november": "十一月",
|
||||
"december": "十二月"
|
||||
},
|
||||
"quarterNumber": "第 {quarterNumber} 季度",
|
||||
"special_notes": {
|
||||
"search_prefix": "搜索:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "同步服务器主机未配置。请先配置同步。",
|
||||
"successful": "同步服务器握手成功,同步已开始。"
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"root-title": "隐藏的笔记",
|
||||
"search-history-title": "搜索历史",
|
||||
"note-map-title": "笔记地图",
|
||||
"sql-console-history-title": "SQL 控制台历史",
|
||||
"shared-notes-title": "共享笔记",
|
||||
"bulk-action-title": "批量操作",
|
||||
"backend-log-title": "后端日志",
|
||||
"user-hidden-title": "隐藏的用户",
|
||||
"launch-bar-templates-title": "启动栏模板",
|
||||
"base-abstract-launcher-title": "基础摘要启动器",
|
||||
"command-launcher-title": "命令启动器",
|
||||
"note-launcher-title": "笔记启动器",
|
||||
"script-launcher-title": "脚本启动器",
|
||||
"built-in-widget-title": "内置小组件",
|
||||
"spacer-title": "空白占位",
|
||||
"custom-widget-title": "自定义小组件",
|
||||
"launch-bar-title": "启动栏",
|
||||
"available-launchers-title": "可用启动器",
|
||||
"go-to-previous-note-title": "跳转到上一条笔记",
|
||||
"go-to-next-note-title": "跳转到下一条笔记",
|
||||
"new-note-title": "新建笔记",
|
||||
"search-notes-title": "搜索笔记",
|
||||
"calendar-title": "日历",
|
||||
"recent-changes-title": "最近更改",
|
||||
"bookmarks-title": "书签",
|
||||
"open-today-journal-note-title": "打开今天的日记笔记",
|
||||
"quick-search-title": "快速搜索",
|
||||
"protected-session-title": "受保护的会话",
|
||||
"sync-status-title": "同步状态",
|
||||
"settings-title": "设置",
|
||||
"options-title": "选项",
|
||||
"appearance-title": "外观",
|
||||
"shortcuts-title": "快捷键",
|
||||
"text-notes": "文本笔记",
|
||||
"code-notes-title": "代码笔记",
|
||||
"images-title": "图片",
|
||||
"spellcheck-title": "拼写检查",
|
||||
"password-title": "密码",
|
||||
"multi-factor-authentication-title": "多因素认证",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "备份",
|
||||
"sync-title": "同步",
|
||||
"other": "其他",
|
||||
"advanced-title": "高级",
|
||||
"visible-launchers-title": "可见启动器",
|
||||
"user-guide": "用户指南",
|
||||
"localization": "语言和区域",
|
||||
"jump-to-note-title": "跳转至...",
|
||||
"llm-chat-title": "与笔记聊天",
|
||||
"ai-llm-title": "AI/LLM",
|
||||
"inbox-title": "收件箱"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "新建笔记",
|
||||
"duplicate-note-suffix": "(重复)",
|
||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "后端日志文件 '{{ fileName }}' 暂不存在。",
|
||||
"reading-log-failed": "读取后端日志文件 '{{ fileName }}' 失败。"
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "此笔记类型无法显示。"
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "PDF 文档 (*.pdf)",
|
||||
"unable-to-export-message": "当前笔记无法被导出为 PDF。",
|
||||
"unable-to-export-title": "无法导出为 PDF",
|
||||
"unable-to-save-message": "所选文件不能被写入。重试或选择另一个目的地。"
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "TriliumNext 笔记",
|
||||
"close": "退出 Trilium",
|
||||
"recents": "最近笔记",
|
||||
"bookmarks": "书签",
|
||||
"today": "打开今天的日记笔记",
|
||||
"new-note": "新建笔记",
|
||||
"show-windows": "显示窗口",
|
||||
"open_new_window": "打开新窗口"
|
||||
},
|
||||
"migration": {
|
||||
"old_version": "由您当前版本的直接迁移不被支持。请先升级到最新的 v0.60.4 然后再到这个版本。",
|
||||
"error_message": "迁移到版本 {{version}} 时发生错误: {{stack}}",
|
||||
"wrong_db_version": "数据库的版本({{version}})新于应用期望的版本({{targetVersion}}),这意味着它由一个更加新的且不兼容的 Trilium 所创建。升级到最新版的 Trilium 以解决此问题。"
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "错误"
|
||||
},
|
||||
"keyboard_action_names": {
|
||||
"back-in-note-history": "返回笔记历史",
|
||||
"forward-in-note-history": "前进笔记历史",
|
||||
"jump-to-note": "跳转到...",
|
||||
"command-palette": "命令面板",
|
||||
"scroll-to-active-note": "滚动到当前笔记",
|
||||
"quick-search": "快速搜索",
|
||||
"search-in-subtree": "在子树中搜索",
|
||||
"expand-subtree": "展开子树",
|
||||
"collapse-tree": "折叠整个树",
|
||||
"collapse-subtree": "折叠子树",
|
||||
"sort-child-notes": "排序子笔记",
|
||||
"create-note-after": "在后面创建笔记",
|
||||
"create-note-into": "创建笔记到",
|
||||
"create-note-into-inbox": "创建笔记到收件箱",
|
||||
"delete-notes": "删除笔记",
|
||||
"move-note-up": "上移笔记",
|
||||
"move-note-down": "下移笔记",
|
||||
"move-note-up-in-hierarchy": "在层级中上移笔记",
|
||||
"move-note-down-in-hierarchy": "在层级中下移笔记",
|
||||
"edit-note-title": "编辑笔记标题",
|
||||
"edit-branch-prefix": "编辑分支前缀",
|
||||
"clone-notes-to": "克隆笔记到",
|
||||
"move-notes-to": "移动笔记到",
|
||||
"copy-notes-to-clipboard": "复制笔记到剪贴板",
|
||||
"paste-notes-from-clipboard": "从剪贴板粘贴笔记",
|
||||
"cut-notes-to-clipboard": "剪切笔记到剪贴板",
|
||||
"select-all-notes-in-parent": "选择父节点中所有笔记",
|
||||
"zoom-in": "放大",
|
||||
"zoom-out": "缩小",
|
||||
"reset-zoom-level": "重置缩放级别",
|
||||
"copy-without-formatting": "无格式复制",
|
||||
"force-save-revision": "强制保存修订版本",
|
||||
"add-note-above-to-selection": "将上方笔记添加到选择",
|
||||
"add-note-below-to-selection": "将下方笔记添加到选择",
|
||||
"duplicate-subtree": "复制子树",
|
||||
"open-new-tab": "打开新标签页",
|
||||
"close-active-tab": "关闭当前标签页",
|
||||
"reopen-last-tab": "重新打开最后关闭的标签页",
|
||||
"activate-next-tab": "切换下一个标签页",
|
||||
"activate-previous-tab": "切换上一个标签页",
|
||||
"open-new-window": "打开新窗口",
|
||||
"toggle-system-tray-icon": "显示/隐藏系统托盘图标",
|
||||
"toggle-zen-mode": "启用/禁用禅模式",
|
||||
"switch-to-first-tab": "切换到第一个标签页",
|
||||
"switch-to-second-tab": "切换到第二个标签页",
|
||||
"switch-to-third-tab": "切换到第三个标签页",
|
||||
"switch-to-fourth-tab": "切换到第四个标签页",
|
||||
"switch-to-fifth-tab": "切换到第五个标签页",
|
||||
"switch-to-sixth-tab": "切换到第六个标签页",
|
||||
"switch-to-seventh-tab": "切换到第七个标签页",
|
||||
"switch-to-eighth-tab": "切换到第八个标签页",
|
||||
"switch-to-ninth-tab": "切换到第九个标签页",
|
||||
"switch-to-last-tab": "切换到最后一个标签页",
|
||||
"show-note-source": "显示笔记源代码",
|
||||
"show-options": "显示选项",
|
||||
"show-revisions": "显示修订历史",
|
||||
"show-recent-changes": "显示最近更改",
|
||||
"show-sql-console": "显示SQL控制台",
|
||||
"show-backend-log": "显示后端日志",
|
||||
"show-help": "显示帮助",
|
||||
"show-cheatsheet": "显示快捷键指南",
|
||||
"add-link-to-text": "为文本添加链接",
|
||||
"follow-link-under-cursor": "访问光标下的链接",
|
||||
"insert-date-and-time-to-text": "在文本中插入日期和时间",
|
||||
"paste-markdown-into-text": "粘贴Markdown到文本",
|
||||
"cut-into-note": "剪切到笔记",
|
||||
"add-include-note-to-text": "在文本中添加包含笔记",
|
||||
"edit-read-only-note": "编辑只读笔记",
|
||||
"add-new-label": "添加新标签",
|
||||
"add-new-relation": "添加新关系",
|
||||
"toggle-ribbon-tab-classic-editor": "切换功能区标签:经典编辑器",
|
||||
"toggle-ribbon-tab-basic-properties": "切换功能区标签:基本属性",
|
||||
"toggle-ribbon-tab-book-properties": "切换功能区标签:书籍属性",
|
||||
"toggle-ribbon-tab-file-properties": "切换功能区标签:文件属性",
|
||||
"toggle-ribbon-tab-image-properties": "切换功能区标签:图片属性",
|
||||
"toggle-ribbon-tab-owned-attributes": "切换功能区标签:自有属性",
|
||||
"toggle-ribbon-tab-inherited-attributes": "切换功能区标签:继承属性",
|
||||
"toggle-ribbon-tab-promoted-attributes": "切换功能区标签:提升属性",
|
||||
"toggle-ribbon-tab-note-map": "切换功能区标签:笔记地图",
|
||||
"toggle-ribbon-tab-note-info": "切换功能区标签:笔记信息",
|
||||
"toggle-ribbon-tab-note-paths": "切换功能区标签:笔记路径",
|
||||
"toggle-ribbon-tab-similar-notes": "切换功能区标签:相似笔记",
|
||||
"toggle-right-pane": "切换右侧面板",
|
||||
"print-active-note": "打印当前笔记",
|
||||
"export-active-note-as-pdf": "导出当前笔记为 PDF",
|
||||
"open-note-externally": "在外部打开笔记",
|
||||
"render-active-note": "渲染当前笔记",
|
||||
"run-active-note": "运行当前笔记",
|
||||
"toggle-note-hoisting": "提升笔记",
|
||||
"unhoist-note": "取消提升笔记",
|
||||
"reload-frontend-app": "重新加载前端应用",
|
||||
"open-developer-tools": "打开开发者工具",
|
||||
"find-in-text": "在文本中查找",
|
||||
"toggle-left-pane": "切换左侧面板",
|
||||
"toggle-full-screen": "切换全屏模式"
|
||||
},
|
||||
"share_theme": {
|
||||
"site-theme": "网站主题",
|
||||
"search_placeholder": "搜索...",
|
||||
"image_alt": "文章图片",
|
||||
"last-updated": "最后更新于 {{- date}}",
|
||||
"subpages": "子页面:",
|
||||
"on-this-page": "本页内容",
|
||||
"expand": "展开"
|
||||
},
|
||||
"hidden_subtree_templates": {
|
||||
"text-snippet": "文本片段",
|
||||
"description": "描述",
|
||||
"list-view": "列表视图",
|
||||
"grid-view": "网格视图",
|
||||
"calendar": "日历",
|
||||
"table": "表格",
|
||||
"geo-map": "地理地图",
|
||||
"start-date": "开始日期",
|
||||
"end-date": "结束日期",
|
||||
"start-time": "开始时间",
|
||||
"end-time": "结束时间",
|
||||
"geolocation": "地理位置",
|
||||
"built-in-templates": "内置模板",
|
||||
"board": "看板",
|
||||
"status": "状态",
|
||||
"board_note_first": "第一个笔记",
|
||||
"board_note_second": "第二个笔记",
|
||||
"board_note_third": "第三个笔记",
|
||||
"board_status_todo": "待办",
|
||||
"board_status_progress": "进行中",
|
||||
"board_status_done": "已完成"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,266 +1,277 @@
|
||||
{
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "Öffne das Dialogfeld \"Zu Notiz springen\"",
|
||||
"search-in-subtree": "Nach Notizen im Unterbaum der aktuellen Notiz suchen",
|
||||
"expand-subtree": "Unterbaum der aktuellen Notiz ausklappen",
|
||||
"collapse-tree": "Gesamten Notizbaum einklappen",
|
||||
"collapse-subtree": "Unterbaum der aktuellen Notiz einklappen",
|
||||
"sort-child-notes": "Untergeordnete Notizen sortieren",
|
||||
"creating-and-moving-notes": "Notizen erstellen und verschieben",
|
||||
"create-note-into-inbox": "Erstelle eine Notiz im Posteingang (falls definiert) oder in der Tagesnotiz",
|
||||
"delete-note": "Notiz löschen",
|
||||
"move-note-up": "Notiz nach oben verschieben",
|
||||
"move-note-down": "Notiz nach unten verschieben",
|
||||
"move-note-up-in-hierarchy": "Notiz in der Hierarchie nach oben verschieben",
|
||||
"move-note-down-in-hierarchy": "Notiz in der Hierarchie nach unten verschieben",
|
||||
"edit-note-title": "Vom Notiz-Baum zur Notiz-Detailansicht springen und den Titel bearbeiten",
|
||||
"edit-branch-prefix": "Dialog zum Bearbeiten des Zweigpräfixes anzeigen",
|
||||
"note-clipboard": "Notiz-Zwischenablage",
|
||||
"copy-notes-to-clipboard": "Ausgewählte Notizen in die Zwischenablage kopieren",
|
||||
"paste-notes-from-clipboard": "Notizen aus der Zwischenablage in die aktive Notiz einfügen",
|
||||
"cut-notes-to-clipboard": "Ausgewählte Notizen in die Zwischenablage ausschneiden",
|
||||
"select-all-notes-in-parent": "Alle Notizen der aktuellen Notizenebene auswählen",
|
||||
"add-note-above-to-the-selection": "Notiz oberhalb der Auswahl hinzufügen",
|
||||
"add-note-below-to-selection": "Notiz unterhalb der Auswahl hinzufügen",
|
||||
"duplicate-subtree": "Unterbaum duplizieren",
|
||||
"tabs-and-windows": "Tabs & Fenster",
|
||||
"open-new-tab": "Neuen Tab öffnen",
|
||||
"close-active-tab": "Aktiven Tab schließen",
|
||||
"reopen-last-tab": "Zuletzt geschlossenen Tab wieder öffnen",
|
||||
"activate-next-tab": "Rechten Tab aktivieren",
|
||||
"activate-previous-tab": "Linken Tab aktivieren",
|
||||
"open-new-window": "Neues leeres Fenster öffnen",
|
||||
"toggle-tray": "Anwendung im Systemtray anzeigen/verstecken",
|
||||
"first-tab": "Ersten Tab in der Liste aktivieren",
|
||||
"second-tab": " Zweiten Tab in der Liste aktivieren",
|
||||
"third-tab": "Dritten Tab in der Liste aktivieren",
|
||||
"fourth-tab": "Vierten Tab in der Liste aktivieren",
|
||||
"fifth-tab": "Fünften Tab in der Liste aktivieren",
|
||||
"sixth-tab": "Sechsten Tab in der Liste aktivieren",
|
||||
"seventh-tab": "Siebten Tab in der Liste aktivieren",
|
||||
"eight-tab": "Achten Tab in der Liste aktivieren",
|
||||
"ninth-tab": "Neunten Tab in der Liste aktivieren",
|
||||
"last-tab": "Letzten Tab in der Liste aktivieren",
|
||||
"dialogs": "Dialoge",
|
||||
"show-note-source": "Notizquellen-Dialog anzeigen",
|
||||
"show-options": "Optionen-Dialog anzeigen",
|
||||
"show-revisions": "Notizrevisionen-Dialog anzeigen",
|
||||
"show-recent-changes": "Letzte Änderungen-Dialog anzeigen",
|
||||
"show-sql-console": "SQL-Konsole-Dialog anzeigen",
|
||||
"show-backend-log": "Backend-Logs-Dialog anzeigen",
|
||||
"text-note-operations": "Textnotiz-Operationen",
|
||||
"add-link-to-text": "Dialogfeld zum Hinzufügen eines Links zum Text öffnen",
|
||||
"follow-link-under-cursor": "Folge dem Link, unter dem Mauszeiger",
|
||||
"insert-date-and-time-to-text": "Aktuelles Datum & Uhrzeit in den Text einfügen",
|
||||
"paste-markdown-into-text": "Markdown aus der Zwischenablage in die Textnotiz einfügen",
|
||||
"cut-into-note": "Auswahl aus der aktuellen Notiz ausschneiden und eine Unternotiz mit dem ausgewählten Text erstellen",
|
||||
"add-include-note-to-text": "Notiz-Einfügen-Dialog öffnen",
|
||||
"edit-readonly-note": "Schreibgeschützte Notiz bearbeiten",
|
||||
"attributes-labels-and-relations": "Attribute (Labels & Verknüpfungen)",
|
||||
"add-new-label": "Neues Label erstellen",
|
||||
"create-new-relation": "Neue Verknüpfungen",
|
||||
"ribbon-tabs": "Ribbon-Tabs",
|
||||
"toggle-basic-properties": "Grundattribute umschalten",
|
||||
"toggle-file-properties": "Dateiattribute umschalten",
|
||||
"toggle-image-properties": "Bildattribute umschalten",
|
||||
"toggle-owned-attributes": "Eigene Attribute umschalten",
|
||||
"toggle-inherited-attributes": "Vererbte Attribute umschalten",
|
||||
"toggle-promoted-attributes": "Beworbene Attribute umschalten",
|
||||
"toggle-link-map": "Link-Karte umschalten",
|
||||
"toggle-note-info": "Notizinformationen umschalten",
|
||||
"toggle-note-paths": "Notizpfade umschalten",
|
||||
"toggle-similar-notes": "Ähnliche Notizen umschalten",
|
||||
"other": "Sonstige",
|
||||
"toggle-right-pane": "Anzeige der rechten Leiste umschalten, das Inhaltsverzeichnis und Markierungen enthält",
|
||||
"print-active-note": "Aktive Notiz drucken",
|
||||
"open-note-externally": "Notiz als Datei mit Standardanwendung öffnen",
|
||||
"render-active-note": "Aktive Notiz rendern (erneut rendern)",
|
||||
"run-active-note": "Aktive JavaScript(Frontend/Backend)-Codenotiz ausführen",
|
||||
"toggle-note-hoisting": "Notiz-Fokus der aktiven Notiz umschalten",
|
||||
"unhoist": "Notiz-Fokus aufheben",
|
||||
"reload-frontend-app": "Frontend-App neuladen",
|
||||
"open-dev-tools": "Entwicklertools öffnen",
|
||||
"toggle-left-note-tree-panel": "Linke Notizbaum-Leiste umschalten",
|
||||
"toggle-full-screen": "Vollbildmodus umschalten",
|
||||
"zoom-out": "Herauszoomen",
|
||||
"zoom-in": "Hineinzoomen",
|
||||
"note-navigation": "Notiznavigation",
|
||||
"reset-zoom-level": "Zoomlevel zurücksetzen",
|
||||
"copy-without-formatting": "Ausgewählten Text ohne Formatierung kopieren",
|
||||
"force-save-revision": "Erstellen / Speichern einer neuen Notizrevision der aktiven Notiz erzwingen",
|
||||
"show-help": "Eingebaute Hilfe / Cheat-Sheet anzeigen",
|
||||
"toggle-book-properties": "Buch-Eigenschaften umschalten"
|
||||
},
|
||||
"login": {
|
||||
"title": "Anmeldung",
|
||||
"heading": "Trilium Anmeldung",
|
||||
"incorrect-password": "Das Passwort ist falsch. Bitte versuche es erneut.",
|
||||
"password": "Passwort",
|
||||
"remember-me": "Angemeldet bleiben",
|
||||
"button": "Anmelden"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "Passwort festlegen",
|
||||
"heading": "Passwort festlegen",
|
||||
"description": "Bevor du Trilium im Web verwenden kannst, musst du zuerst ein Passwort festlegen. Du wirst dieses Passwort dann zur Anmeldung verwenden.",
|
||||
"password": "Passwort",
|
||||
"password-confirmation": "Passwortbestätigung",
|
||||
"button": "Passwort festlegen"
|
||||
},
|
||||
"javascript-required": "Trilium erfordert, dass JavaScript aktiviert ist.",
|
||||
"setup": {
|
||||
"heading": "Trilium Notes Setup",
|
||||
"new-document": "Ich bin ein neuer Benutzer und möchte ein neues Trilium-Dokument für meine Notizen erstellen",
|
||||
"sync-from-desktop": "Ich habe bereits eine Desktop-Instanz und möchte die Synchronisierung damit einrichten",
|
||||
"sync-from-server": "Ich habe bereits eine Server-Instanz und möchte die Synchronisierung damit einrichten",
|
||||
"next": "Weiter",
|
||||
"init-in-progress": "Dokumenteninitialisierung läuft",
|
||||
"redirecting": "Du wirst in Kürze zur Anwendung weitergeleitet.",
|
||||
"title": "Setup"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Synchronisation vom Desktop",
|
||||
"description": "Dieses Setup muss von der Desktop-Instanz aus initiiert werden:",
|
||||
"step1": "Öffne deine Trilium Notes Desktop-Instanz.",
|
||||
"step2": "Klicke im Trilium-Menü auf Optionen.",
|
||||
"step3": "Klicke auf die Kategorie Synchronisation.",
|
||||
"step4": "Ändere die Server-Instanzadresse auf: {{- host}} und klicke auf Speichern.",
|
||||
"step5": "Klicke auf den Button \"Test-Synchronisation\", um zu überprüfen, ob die Verbindung erfolgreich ist.",
|
||||
"step6": "Sobald du diese Schritte abgeschlossen hast, klicke auf {{- link}}.",
|
||||
"step6-here": "hier"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Synchronisation vom Server",
|
||||
"instructions": "Bitte gib unten die Trilium-Server-Adresse und die Zugangsdaten ein. Dies wird das gesamte Trilium-Dokument vom Server herunterladen und die Synchronisation einrichten. Je nach Dokumentgröße und Verbindungsgeschwindigkeit kann dies eine Weile dauern.",
|
||||
"server-host": "Trilium Server-Adresse",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Proxy-Server (optional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Hinweis:",
|
||||
"proxy-instruction": "Wenn du die Proxy-Einstellung leer lässt, wird der System-Proxy verwendet (gilt nur für die Desktop-Anwendung)",
|
||||
"password": "Passwort",
|
||||
"password-placeholder": "Passwort",
|
||||
"back": "Zurück",
|
||||
"finish-setup": "Setup abschließen"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Synchronisation läuft",
|
||||
"successful": "Die Synchronisation wurde erfolgreich eingerichtet. Es wird eine Weile dauern, bis die erste Synchronisation abgeschlossen ist. Sobald dies erledigt ist, wirst du zur Anmeldeseite weitergeleitet.",
|
||||
"outstanding-items": "Ausstehende Synchronisationselemente:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Nicht gefunden",
|
||||
"heading": "Nicht gefunden"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "Übergeordnete Notiz:",
|
||||
"clipped-from": "Diese Notiz wurde ursprünglich von {{- url}} ausgeschnitten",
|
||||
"child-notes": "Untergeordnete Notizen:",
|
||||
"no-content": "Diese Notiz hat keinen Inhalt."
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Montag",
|
||||
"tuesday": "Dienstag",
|
||||
"wednesday": "Mittwoch",
|
||||
"thursday": "Donnerstag",
|
||||
"friday": "Freitag",
|
||||
"saturday": "Samstag",
|
||||
"sunday": "Sonntag"
|
||||
},
|
||||
"months": {
|
||||
"january": "Januar",
|
||||
"february": "Februar",
|
||||
"march": "März",
|
||||
"april": "April",
|
||||
"may": "Mai",
|
||||
"june": "Juni",
|
||||
"july": "Juli",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "Oktober",
|
||||
"november": "November",
|
||||
"december": "Dezember"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "Suche:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "Der Synchronisations-Server-Host ist nicht konfiguriert. Bitte konfiguriere zuerst die Synchronisation.",
|
||||
"successful": "Die Server-Verbindung wurde erfolgreich hergestellt, die Synchronisation wurde gestartet."
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"root-title": "Versteckte Notizen",
|
||||
"search-history-title": "Suchverlauf",
|
||||
"note-map-title": "Notiz Karte",
|
||||
"sql-console-history-title": "SQL Konsolen Verlauf",
|
||||
"shared-notes-title": "Geteilte Notizen",
|
||||
"bulk-action-title": "Massenverarbeitung",
|
||||
"backend-log-title": "Backend Log",
|
||||
"user-hidden-title": "Versteckt vom Nutzer",
|
||||
"launch-bar-templates-title": "Startleiste Vorlagen",
|
||||
"base-abstract-launcher-title": "Basis Abstrakte Startleiste",
|
||||
"command-launcher-title": "Befehlslauncher",
|
||||
"note-launcher-title": "Notiz Launcher",
|
||||
"script-launcher-title": "Script Launcher",
|
||||
"built-in-widget-title": "Eingebautes Widget",
|
||||
"spacer-title": "Freifeld",
|
||||
"custom-widget-title": "Custom Widget",
|
||||
"launch-bar-title": "Launchbar",
|
||||
"available-launchers-title": "Verfügbare Launchers",
|
||||
"go-to-previous-note-title": "Zur vorherigen Notiz gehen",
|
||||
"go-to-next-note-title": "Zur nächsten Notiz gehen",
|
||||
"new-note-title": "Neue Notiz",
|
||||
"search-notes-title": "Notizen durchsuchen",
|
||||
"calendar-title": "Kalender",
|
||||
"recent-changes-title": "neue Änderungen",
|
||||
"bookmarks-title": "Lesezeichen",
|
||||
"open-today-journal-note-title": "Heutigen Journaleintrag öffnen",
|
||||
"quick-search-title": "Schnellsuche",
|
||||
"protected-session-title": "Geschützte Sitzung",
|
||||
"sync-status-title": "Sync Status",
|
||||
"settings-title": "Einstellungen",
|
||||
"options-title": "Optionen",
|
||||
"appearance-title": "Erscheinungsbild",
|
||||
"shortcuts-title": "Tastaturkürzel",
|
||||
"text-notes": "Text Notizen",
|
||||
"code-notes-title": "Code Notizen",
|
||||
"images-title": "Bilder",
|
||||
"spellcheck-title": "Rechtschreibprüfung",
|
||||
"password-title": "Passwort",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "Sicherung",
|
||||
"sync-title": "Sync",
|
||||
"other": "Weitere",
|
||||
"advanced-title": "Erweitert",
|
||||
"visible-launchers-title": "Sichtbare Launcher",
|
||||
"user-guide": "Nutzerhandbuch"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Neue Notiz",
|
||||
"duplicate-note-suffix": "(dup)",
|
||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "Die Backend-Log-Datei '{{ fileName }}' existiert (noch) nicht.",
|
||||
"reading-log-failed": "Das Lesen der Backend-Log-Datei '{{ fileName }}' ist fehlgeschlagen."
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "Dieser Notiztyp kann nicht angezeigt werden."
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "PDF Dokument (*.pdf)",
|
||||
"unable-to-export-message": "Die aktuelle Notiz konnte nicht als PDF exportiert werden.",
|
||||
"unable-to-export-title": "Export als PDF fehlgeschlagen",
|
||||
"unable-to-save-message": "Die ausgewählte Datei konnte nicht beschrieben werden. Erneut versuchen oder ein anderes Ziel auswählen."
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "Trilium Notes",
|
||||
"close": "Trilium schließen",
|
||||
"recents": "Kürzliche Notizen",
|
||||
"bookmarks": "Lesezeichen",
|
||||
"today": "Heutigen Journal Eintrag öffnen",
|
||||
"new-note": "Neue Notiz",
|
||||
"show-windows": "Fenster anzeigen"
|
||||
}
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "Öffne das Dialogfeld \"Zu Notiz springen\"",
|
||||
"search-in-subtree": "Nach Notizen im Unterbaum der aktuellen Notiz suchen",
|
||||
"expand-subtree": "Unterbaum der aktuellen Notiz ausklappen",
|
||||
"collapse-tree": "Gesamten Notizbaum einklappen",
|
||||
"collapse-subtree": "Unterbaum der aktuellen Notiz einklappen",
|
||||
"sort-child-notes": "Untergeordnete Notizen sortieren",
|
||||
"creating-and-moving-notes": "Notizen erstellen und verschieben",
|
||||
"create-note-into-inbox": "Erstelle eine Notiz im Posteingang (falls definiert) oder in der Tagesnotiz",
|
||||
"delete-note": "Notiz löschen",
|
||||
"move-note-up": "Notiz nach oben verschieben",
|
||||
"move-note-down": "Notiz nach unten verschieben",
|
||||
"move-note-up-in-hierarchy": "Notiz in der Hierarchie nach oben verschieben",
|
||||
"move-note-down-in-hierarchy": "Notiz in der Hierarchie nach unten verschieben",
|
||||
"edit-note-title": "Vom Notiz-Baum zur Notiz-Detailansicht springen und den Titel bearbeiten",
|
||||
"edit-branch-prefix": "Dialog zum Bearbeiten des Zweigpräfixes anzeigen",
|
||||
"note-clipboard": "Notiz-Zwischenablage",
|
||||
"copy-notes-to-clipboard": "Ausgewählte Notizen in die Zwischenablage kopieren",
|
||||
"paste-notes-from-clipboard": "Notizen aus der Zwischenablage in die aktive Notiz einfügen",
|
||||
"cut-notes-to-clipboard": "Ausgewählte Notizen in die Zwischenablage ausschneiden",
|
||||
"select-all-notes-in-parent": "Alle Notizen der aktuellen Notizenebene auswählen",
|
||||
"add-note-above-to-the-selection": "Notiz oberhalb der Auswahl hinzufügen",
|
||||
"add-note-below-to-selection": "Notiz unterhalb der Auswahl hinzufügen",
|
||||
"duplicate-subtree": "Unterbaum duplizieren",
|
||||
"tabs-and-windows": "Tabs & Fenster",
|
||||
"open-new-tab": "Neuen Tab öffnen",
|
||||
"close-active-tab": "Aktiven Tab schließen",
|
||||
"reopen-last-tab": "Zuletzt geschlossenen Tab wieder öffnen",
|
||||
"activate-next-tab": "Rechten Tab aktivieren",
|
||||
"activate-previous-tab": "Linken Tab aktivieren",
|
||||
"open-new-window": "Neues leeres Fenster öffnen",
|
||||
"toggle-tray": "Anwendung im Systemtray anzeigen/verstecken",
|
||||
"first-tab": "Ersten Tab in der Liste aktivieren",
|
||||
"second-tab": " Zweiten Tab in der Liste aktivieren",
|
||||
"third-tab": "Dritten Tab in der Liste aktivieren",
|
||||
"fourth-tab": "Vierten Tab in der Liste aktivieren",
|
||||
"fifth-tab": "Fünften Tab in der Liste aktivieren",
|
||||
"sixth-tab": "Sechsten Tab in der Liste aktivieren",
|
||||
"seventh-tab": "Siebten Tab in der Liste aktivieren",
|
||||
"eight-tab": "Achten Tab in der Liste aktivieren",
|
||||
"ninth-tab": "Neunten Tab in der Liste aktivieren",
|
||||
"last-tab": "Letzten Tab in der Liste aktivieren",
|
||||
"dialogs": "Dialoge",
|
||||
"show-note-source": "Notizquellen-Dialog anzeigen",
|
||||
"show-options": "Optionen-Dialog anzeigen",
|
||||
"show-revisions": "Notizrevisionen-Dialog anzeigen",
|
||||
"show-recent-changes": "Letzte Änderungen-Dialog anzeigen",
|
||||
"show-sql-console": "SQL-Konsole-Dialog anzeigen",
|
||||
"show-backend-log": "Backend-Logs-Dialog anzeigen",
|
||||
"text-note-operations": "Textnotiz-Operationen",
|
||||
"add-link-to-text": "Dialogfeld zum Hinzufügen eines Links zum Text öffnen",
|
||||
"follow-link-under-cursor": "Folge dem Link, unter dem Mauszeiger",
|
||||
"insert-date-and-time-to-text": "Aktuelles Datum & Uhrzeit in den Text einfügen",
|
||||
"paste-markdown-into-text": "Markdown aus der Zwischenablage in die Textnotiz einfügen",
|
||||
"cut-into-note": "Auswahl aus der aktuellen Notiz ausschneiden und eine Unternotiz mit dem ausgewählten Text erstellen",
|
||||
"add-include-note-to-text": "Notiz-Einfügen-Dialog öffnen",
|
||||
"edit-readonly-note": "Schreibgeschützte Notiz bearbeiten",
|
||||
"attributes-labels-and-relations": "Attribute (Labels & Verknüpfungen)",
|
||||
"add-new-label": "Neues Label erstellen",
|
||||
"create-new-relation": "Neue Verknüpfungen",
|
||||
"ribbon-tabs": "Ribbon-Tabs",
|
||||
"toggle-basic-properties": "Grundattribute umschalten",
|
||||
"toggle-file-properties": "Dateiattribute umschalten",
|
||||
"toggle-image-properties": "Bildattribute umschalten",
|
||||
"toggle-owned-attributes": "Eigene Attribute umschalten",
|
||||
"toggle-inherited-attributes": "Vererbte Attribute umschalten",
|
||||
"toggle-promoted-attributes": "Beworbene Attribute umschalten",
|
||||
"toggle-link-map": "Link-Karte umschalten",
|
||||
"toggle-note-info": "Notizinformationen umschalten",
|
||||
"toggle-note-paths": "Notizpfade umschalten",
|
||||
"toggle-similar-notes": "Ähnliche Notizen umschalten",
|
||||
"other": "Sonstige",
|
||||
"toggle-right-pane": "Anzeige der rechten Leiste umschalten, das Inhaltsverzeichnis und Markierungen enthält",
|
||||
"print-active-note": "Aktive Notiz drucken",
|
||||
"open-note-externally": "Notiz als Datei mit Standardanwendung öffnen",
|
||||
"render-active-note": "Aktive Notiz rendern (erneut rendern)",
|
||||
"run-active-note": "Aktive JavaScript(Frontend/Backend)-Codenotiz ausführen",
|
||||
"toggle-note-hoisting": "Notiz-Fokus der aktiven Notiz umschalten",
|
||||
"unhoist": "Notiz-Fokus aufheben",
|
||||
"reload-frontend-app": "Frontend-App neuladen",
|
||||
"open-dev-tools": "Entwicklertools öffnen",
|
||||
"toggle-left-note-tree-panel": "Linke Notizbaum-Leiste umschalten",
|
||||
"toggle-full-screen": "Vollbildmodus umschalten",
|
||||
"zoom-out": "Herauszoomen",
|
||||
"zoom-in": "Hineinzoomen",
|
||||
"note-navigation": "Notiznavigation",
|
||||
"reset-zoom-level": "Zoomlevel zurücksetzen",
|
||||
"copy-without-formatting": "Ausgewählten Text ohne Formatierung kopieren",
|
||||
"force-save-revision": "Erstellen / Speichern einer neuen Notizrevision der aktiven Notiz erzwingen",
|
||||
"show-help": "Eingebaute Hilfe / Cheat-Sheet anzeigen",
|
||||
"toggle-book-properties": "Buch-Eigenschaften umschalten",
|
||||
"clone-notes-to": "Ausgewählte Notizen duplizieren",
|
||||
"open-command-palette": "Kommandopalette öffnen",
|
||||
"export-as-pdf": "Aktuelle Notiz als PDF exportieren"
|
||||
},
|
||||
"login": {
|
||||
"title": "Anmeldung",
|
||||
"heading": "Trilium Anmeldung",
|
||||
"incorrect-password": "Das Passwort ist falsch. Bitte versuche es erneut.",
|
||||
"password": "Passwort",
|
||||
"remember-me": "Angemeldet bleiben",
|
||||
"button": "Anmelden"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "Passwort festlegen",
|
||||
"heading": "Passwort festlegen",
|
||||
"description": "Bevor du Trilium im Web verwenden kannst, musst du zuerst ein Passwort festlegen. Du wirst dieses Passwort dann zur Anmeldung verwenden.",
|
||||
"password": "Passwort",
|
||||
"password-confirmation": "Passwortbestätigung",
|
||||
"button": "Passwort festlegen"
|
||||
},
|
||||
"javascript-required": "Trilium erfordert, dass JavaScript aktiviert ist.",
|
||||
"setup": {
|
||||
"heading": "Trilium Notes Setup",
|
||||
"new-document": "Ich bin ein neuer Benutzer und möchte ein neues Trilium-Dokument für meine Notizen erstellen",
|
||||
"sync-from-desktop": "Ich habe bereits eine Desktop-Instanz und möchte die Synchronisierung damit einrichten",
|
||||
"sync-from-server": "Ich habe bereits eine Server-Instanz und möchte die Synchronisierung damit einrichten",
|
||||
"next": "Weiter",
|
||||
"init-in-progress": "Dokumenteninitialisierung läuft",
|
||||
"redirecting": "Du wirst in Kürze zur Anwendung weitergeleitet.",
|
||||
"title": "Setup"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Synchronisation vom Desktop",
|
||||
"description": "Dieses Setup muss von der Desktop-Instanz aus initiiert werden:",
|
||||
"step1": "Öffne deine Trilium Notes Desktop-Instanz.",
|
||||
"step2": "Klicke im Trilium-Menü auf Optionen.",
|
||||
"step3": "Klicke auf die Kategorie Synchronisation.",
|
||||
"step4": "Ändere die Server-Instanzadresse auf: {{- host}} und klicke auf Speichern.",
|
||||
"step5": "Klicke auf den Button \"Test-Synchronisation\", um zu überprüfen, ob die Verbindung erfolgreich ist.",
|
||||
"step6": "Sobald du diese Schritte abgeschlossen hast, klicke auf {{- link}}.",
|
||||
"step6-here": "hier"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Synchronisation vom Server",
|
||||
"instructions": "Bitte gib unten die Trilium-Server-Adresse und die Zugangsdaten ein. Dies wird das gesamte Trilium-Dokument vom Server herunterladen und die Synchronisation einrichten. Je nach Dokumentgröße und Verbindungsgeschwindigkeit kann dies eine Weile dauern.",
|
||||
"server-host": "Trilium Server-Adresse",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Proxy-Server (optional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Hinweis:",
|
||||
"proxy-instruction": "Wenn du die Proxy-Einstellung leer lässt, wird der System-Proxy verwendet (gilt nur für die Desktop-Anwendung)",
|
||||
"password": "Passwort",
|
||||
"password-placeholder": "Passwort",
|
||||
"back": "Zurück",
|
||||
"finish-setup": "Setup abschließen"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Synchronisation läuft",
|
||||
"successful": "Die Synchronisation wurde erfolgreich eingerichtet. Es wird eine Weile dauern, bis die erste Synchronisation abgeschlossen ist. Sobald dies erledigt ist, wirst du zur Anmeldeseite weitergeleitet.",
|
||||
"outstanding-items": "Ausstehende Synchronisationselemente:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Nicht gefunden",
|
||||
"heading": "Nicht gefunden"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "Übergeordnete Notiz:",
|
||||
"clipped-from": "Diese Notiz wurde ursprünglich von {{- url}} ausgeschnitten",
|
||||
"child-notes": "Untergeordnete Notizen:",
|
||||
"no-content": "Diese Notiz hat keinen Inhalt."
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Montag",
|
||||
"tuesday": "Dienstag",
|
||||
"wednesday": "Mittwoch",
|
||||
"thursday": "Donnerstag",
|
||||
"friday": "Freitag",
|
||||
"saturday": "Samstag",
|
||||
"sunday": "Sonntag"
|
||||
},
|
||||
"months": {
|
||||
"january": "Januar",
|
||||
"february": "Februar",
|
||||
"march": "März",
|
||||
"april": "April",
|
||||
"may": "Mai",
|
||||
"june": "Juni",
|
||||
"july": "Juli",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "Oktober",
|
||||
"november": "November",
|
||||
"december": "Dezember"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "Suche:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "Der Synchronisations-Server-Host ist nicht konfiguriert. Bitte konfiguriere zuerst die Synchronisation.",
|
||||
"successful": "Die Server-Verbindung wurde erfolgreich hergestellt, die Synchronisation wurde gestartet."
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"root-title": "Versteckte Notizen",
|
||||
"search-history-title": "Suchverlauf",
|
||||
"note-map-title": "Notiz Karte",
|
||||
"sql-console-history-title": "SQL Konsolen Verlauf",
|
||||
"shared-notes-title": "Geteilte Notizen",
|
||||
"bulk-action-title": "Massenverarbeitung",
|
||||
"backend-log-title": "Backend Log",
|
||||
"user-hidden-title": "Versteckt vom Nutzer",
|
||||
"launch-bar-templates-title": "Startleiste Vorlagen",
|
||||
"base-abstract-launcher-title": "Basis Abstrakte Startleiste",
|
||||
"command-launcher-title": "Befehlslauncher",
|
||||
"note-launcher-title": "Notiz Launcher",
|
||||
"script-launcher-title": "Script Launcher",
|
||||
"built-in-widget-title": "Eingebautes Widget",
|
||||
"spacer-title": "Freifeld",
|
||||
"custom-widget-title": "Custom Widget",
|
||||
"launch-bar-title": "Launchbar",
|
||||
"available-launchers-title": "Verfügbare Launchers",
|
||||
"go-to-previous-note-title": "Zur vorherigen Notiz gehen",
|
||||
"go-to-next-note-title": "Zur nächsten Notiz gehen",
|
||||
"new-note-title": "Neue Notiz",
|
||||
"search-notes-title": "Notizen durchsuchen",
|
||||
"calendar-title": "Kalender",
|
||||
"recent-changes-title": "neue Änderungen",
|
||||
"bookmarks-title": "Lesezeichen",
|
||||
"open-today-journal-note-title": "Heutigen Journaleintrag öffnen",
|
||||
"quick-search-title": "Schnellsuche",
|
||||
"protected-session-title": "Geschützte Sitzung",
|
||||
"sync-status-title": "Sync Status",
|
||||
"settings-title": "Einstellungen",
|
||||
"options-title": "Optionen",
|
||||
"appearance-title": "Erscheinungsbild",
|
||||
"shortcuts-title": "Tastaturkürzel",
|
||||
"text-notes": "Text Notizen",
|
||||
"code-notes-title": "Code Notizen",
|
||||
"images-title": "Bilder",
|
||||
"spellcheck-title": "Rechtschreibprüfung",
|
||||
"password-title": "Passwort",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "Sicherung",
|
||||
"sync-title": "Sync",
|
||||
"other": "Weitere",
|
||||
"advanced-title": "Erweitert",
|
||||
"visible-launchers-title": "Sichtbare Launcher",
|
||||
"user-guide": "Nutzerhandbuch"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Neue Notiz",
|
||||
"duplicate-note-suffix": "(dup)",
|
||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "Die Backend-Log-Datei '{{ fileName }}' existiert (noch) nicht.",
|
||||
"reading-log-failed": "Das Lesen der Backend-Log-Datei '{{ fileName }}' ist fehlgeschlagen."
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "Dieser Notiztyp kann nicht angezeigt werden."
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "PDF Dokument (*.pdf)",
|
||||
"unable-to-export-message": "Die aktuelle Notiz konnte nicht als PDF exportiert werden.",
|
||||
"unable-to-export-title": "Export als PDF fehlgeschlagen",
|
||||
"unable-to-save-message": "Die ausgewählte Datei konnte nicht beschrieben werden. Erneut versuchen oder ein anderes Ziel auswählen."
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "Trilium Notes",
|
||||
"close": "Trilium schließen",
|
||||
"recents": "Kürzliche Notizen",
|
||||
"bookmarks": "Lesezeichen",
|
||||
"today": "Heutigen Journal Eintrag öffnen",
|
||||
"new-note": "Neue Notiz",
|
||||
"show-windows": "Fenster anzeigen"
|
||||
},
|
||||
"hidden_subtree_templates": {
|
||||
"table": "Tabelle",
|
||||
"board_status_done": "Erledigt"
|
||||
},
|
||||
"keyboard_action_names": {
|
||||
"copy-notes-to-clipboard": "Notizen in Zwischenablage kopieren",
|
||||
"paste-notes-from-clipboard": "Notizen aus Zwischenablage einfügen"
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/server/src/assets/translations/el/server.json
Normal file
1
apps/server/src/assets/translations/el/server.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -15,10 +15,10 @@
|
||||
"create-note-into": "Crear nota como subnota de la nota activa",
|
||||
"create-note-into-inbox": "Crear una nota en la bandeja de entrada (si está definida) o nota del día",
|
||||
"delete-note": "Eliminar nota",
|
||||
"move-note-up": "Mover nota hacia arriba",
|
||||
"move-note-down": "Mover nota hacia abajo",
|
||||
"move-note-up-in-hierarchy": "Mover nota hacia arriba en la jerarquía",
|
||||
"move-note-down-in-hierarchy": "Mover nota hacia abajo en la jerarquía",
|
||||
"move-note-up": "Subir nota",
|
||||
"move-note-down": "Bajar nota",
|
||||
"move-note-up-in-hierarchy": "Subir nota en la jerarquía",
|
||||
"move-note-down-in-hierarchy": "Bajar nota en la jerarquía",
|
||||
"edit-note-title": "Saltar del árbol al detalle de la nota y editar el título",
|
||||
"edit-branch-prefix": "Mostrar cuadro de diálogo Editar prefijo de rama",
|
||||
"cloneNotesTo": "Clonar notas seleccionadas",
|
||||
@@ -75,7 +75,7 @@
|
||||
"toggle-image-properties": "Alternar propiedades de imagen",
|
||||
"toggle-owned-attributes": "Alternar atributos de propiedad",
|
||||
"toggle-inherited-attributes": "Alternar atributos heredados",
|
||||
"toggle-promoted-attributes": "Alternar atributos promocionados",
|
||||
"toggle-promoted-attributes": "Alternar atributos destacados",
|
||||
"toggle-link-map": "Alternar mapa de enlaces",
|
||||
"toggle-note-info": "Alternar información de nota",
|
||||
"toggle-note-paths": "Alternar rutas de notas",
|
||||
@@ -373,7 +373,36 @@
|
||||
"zoom-in": "Acercar",
|
||||
"zoom-out": "Alejar",
|
||||
"toggle-full-screen": "Activar/desactivar pantalla completa",
|
||||
"toggle-left-pane": "Abrir/cerrar panel izquierdo"
|
||||
"toggle-left-pane": "Abrir/cerrar panel izquierdo",
|
||||
"toggle-right-pane": "Mostrar/ocultar panel derecho",
|
||||
"unhoist-note": "Desanclar nota",
|
||||
"toggle-note-hoisting": "Activar/desactivar anclaje de nota",
|
||||
"show-cheatsheet": "Mostrar hoja de referencia",
|
||||
"follow-link-under-cursor": "Seguir enlace bajo cursor",
|
||||
"reload-frontend-app": "Recargar aplicación del cliente",
|
||||
"run-active-note": "Ejecutar nota activa",
|
||||
"render-active-note": "Generar nota activa",
|
||||
"back-in-note-history": "Anterior en el historial de notas",
|
||||
"forward-in-note-history": "Posterior en el historial de notas",
|
||||
"cut-notes-to-clipboard": "Cortar notas al portapapeles",
|
||||
"select-all-notes-in-parent": "Seleccionar todas las notas en padre",
|
||||
"show-backend-log": "Mostrar registro del servidor",
|
||||
"paste-markdown-into-text": "Pegar Markdown en el texto",
|
||||
"cut-into-note": "Cortar en la nota",
|
||||
"add-include-note-to-text": "Agregar nota incluida al texto",
|
||||
"force-save-revision": "Forzar guardado de revisión",
|
||||
"toggle-ribbon-tab-classic-editor": "Mostrar pestaña de la cinta de opciones: Editor clásico",
|
||||
"toggle-ribbon-tab-basic-properties": "Mostrar pestaña de la cinta de opciones: Propiedades básicas",
|
||||
"toggle-ribbon-tab-book-properties": "Mostrar pestaña de la cinta de opciones: Propiedades de libro",
|
||||
"toggle-ribbon-tab-file-properties": "Mostrar pestaña de la cinta de opciones: Propiedades de archivo",
|
||||
"toggle-ribbon-tab-image-properties": "Mostrar pestaña de la cinta de opciones: Propiedades de imagen",
|
||||
"toggle-ribbon-tab-inherited-attributes": "Mostrar pestaña de la cinta de opciones: Atributos heredados",
|
||||
"toggle-ribbon-tab-note-map": "Mostrar pestaña de la cinta de opciones: Mapa de notas",
|
||||
"toggle-ribbon-tab-note-info": "Mostrar pestaña de la cinta de opciones: Información de nota",
|
||||
"toggle-ribbon-tab-note-paths": "Mostrar pestaña de la cinta de opciones: Rutas de nota",
|
||||
"toggle-ribbon-tab-similar-notes": "Mostrar pestaña de la cinta de opciones: Notas similares",
|
||||
"toggle-ribbon-tab-owned-attributes": "Mostrar pestaña de la cinta de opciones: Propiedades asignadas",
|
||||
"toggle-ribbon-tab-promoted-attributes": "Mostrar pestaña de la cinta de opciones: Atributos destacados"
|
||||
},
|
||||
"hidden_subtree_templates": {
|
||||
"board_note_first": "Primera nota",
|
||||
@@ -385,6 +414,17 @@
|
||||
"list-view": "Vista de lista",
|
||||
"grid-view": "Vista de cuadrícula",
|
||||
"status": "Estado",
|
||||
"table": "Tabla"
|
||||
"table": "Tabla",
|
||||
"text-snippet": "Fragmento de texto",
|
||||
"geo-map": "Mapa Geo",
|
||||
"start-date": "Fecha de inicio",
|
||||
"end-date": "Fecha de finalización",
|
||||
"start-time": "Hora de inicio",
|
||||
"end-time": "Hora de finalización",
|
||||
"geolocation": "Geolocalización",
|
||||
"built-in-templates": "Plantillas predefinidas",
|
||||
"board_status_todo": "Por hacer",
|
||||
"board_status_done": "Hecho",
|
||||
"board": "Tablero"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,278 +1,287 @@
|
||||
{
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"",
|
||||
"search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active",
|
||||
"expand-subtree": "Développer le sous-arbre de la note actuelle",
|
||||
"collapse-tree": "Réduire toute l'arborescence des notes",
|
||||
"collapse-subtree": "Réduire le sous-arbre de la note actuelle",
|
||||
"sort-child-notes": "Trier les notes enfants",
|
||||
"creating-and-moving-notes": "Créer et déplacer des notes",
|
||||
"create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière",
|
||||
"delete-note": "Supprimer la note",
|
||||
"move-note-up": "Déplacer la note vers le haut",
|
||||
"move-note-down": "Déplacer la note vers le bas",
|
||||
"move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie",
|
||||
"move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie",
|
||||
"edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre",
|
||||
"edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche",
|
||||
"note-clipboard": "Note presse-papiers",
|
||||
"copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers",
|
||||
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active",
|
||||
"cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers",
|
||||
"select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active",
|
||||
"add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection",
|
||||
"add-note-below-to-selection": "Ajouter la note en dessous de la sélection",
|
||||
"duplicate-subtree": "Dupliquer le sous-arbre",
|
||||
"tabs-and-windows": "Onglets et fenêtres",
|
||||
"open-new-tab": "Ouvrir un nouvel onglet",
|
||||
"close-active-tab": "Fermer l'onglet actif",
|
||||
"reopen-last-tab": "Rouvrir le dernier onglet fermé",
|
||||
"activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif",
|
||||
"activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif",
|
||||
"open-new-window": "Ouvrir une nouvelle fenêtre vide",
|
||||
"toggle-tray": "Afficher/masquer l'application dans la barre des tâches",
|
||||
"first-tab": "Basculer vers le premier onglet dans la liste",
|
||||
"second-tab": "Basculer vers le deuxième onglet dans la liste",
|
||||
"third-tab": "Basculer vers le troisième onglet dans la liste",
|
||||
"fourth-tab": "Basculer vers le quatrième onglet dans la liste",
|
||||
"fifth-tab": "Basculer vers le cinquième onglet dans la liste",
|
||||
"sixth-tab": "Basculer vers le sixième onglet dans la liste",
|
||||
"seventh-tab": "Basculer vers le septième onglet dans la liste",
|
||||
"eight-tab": "Basculer vers le huitième onglet dans la liste",
|
||||
"ninth-tab": "Basculer vers le neuvième onglet dans la liste",
|
||||
"last-tab": "Basculer vers le dernier onglet dans la liste",
|
||||
"dialogs": "Boîtes de dialogue",
|
||||
"show-note-source": "Affiche la boîte de dialogue Source de la note",
|
||||
"show-options": "Afficher les Options",
|
||||
"show-revisions": "Afficher la boîte de dialogue Versions de la note",
|
||||
"show-recent-changes": "Afficher la boîte de dialogue Modifications récentes",
|
||||
"show-sql-console": "Afficher la boîte de dialogue Console SQL",
|
||||
"show-backend-log": "Afficher la boîte de dialogue Journal du backend",
|
||||
"text-note-operations": "Opérations sur les notes textuelles",
|
||||
"add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte",
|
||||
"follow-link-under-cursor": "Suivre le lien sous le curseur",
|
||||
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
|
||||
"paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers",
|
||||
"cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné",
|
||||
"add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note",
|
||||
"edit-readonly-note": "Éditer une note en lecture seule",
|
||||
"attributes-labels-and-relations": "Attributs (labels et relations)",
|
||||
"add-new-label": "Créer un nouveau label",
|
||||
"create-new-relation": "Créer une nouvelle relation",
|
||||
"ribbon-tabs": "Onglets du ruban",
|
||||
"toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note",
|
||||
"toggle-file-properties": "Afficher/masquer les Propriétés du fichier",
|
||||
"toggle-image-properties": "Afficher/masquer les Propriétés de l'image",
|
||||
"toggle-owned-attributes": "Afficher/masquer les Attributs propres",
|
||||
"toggle-inherited-attributes": "Afficher/masquer les Attributs hérités",
|
||||
"toggle-promoted-attributes": "Afficher/masquer les Attributs promus",
|
||||
"toggle-link-map": "Afficher/masquer la Carte de la note",
|
||||
"toggle-note-info": "Afficher/masquer les Informations de la note",
|
||||
"toggle-note-paths": "Afficher/masquer les Emplacements de la note",
|
||||
"toggle-similar-notes": "Afficher/masquer les Notes similaires",
|
||||
"other": "Autre",
|
||||
"toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations",
|
||||
"print-active-note": "Imprimer la note active",
|
||||
"open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut",
|
||||
"render-active-note": "Rendre (ou re-rendre) la note active",
|
||||
"run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active",
|
||||
"toggle-note-hoisting": "Activer le focus sur la note active",
|
||||
"unhoist": "Désactiver tout focus",
|
||||
"reload-frontend-app": "Recharger l'application",
|
||||
"open-dev-tools": "Ouvrir les outils de développement",
|
||||
"toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)",
|
||||
"toggle-full-screen": "Basculer en plein écran",
|
||||
"zoom-out": "Dézoomer",
|
||||
"zoom-in": "Zoomer",
|
||||
"note-navigation": "Navigation dans les notes",
|
||||
"reset-zoom-level": "Réinitialiser le niveau de zoom",
|
||||
"copy-without-formatting": "Copier le texte sélectionné sans mise en forme",
|
||||
"force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active",
|
||||
"show-help": "Affiche le guide de l'utilisateur intégré",
|
||||
"toggle-book-properties": "Afficher/masquer les Propriétés du Livre",
|
||||
"toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
|
||||
"export-as-pdf": "Exporte la note actuelle en PDF",
|
||||
"show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes",
|
||||
"toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)"
|
||||
},
|
||||
"login": {
|
||||
"title": "Connexion",
|
||||
"heading": "Connexion à Trilium",
|
||||
"incorrect-password": "Le mot de passe est incorrect. Veuillez réessayer.",
|
||||
"password": "Mot de passe",
|
||||
"remember-me": "Se souvenir de moi",
|
||||
"button": "Connexion"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "Définir un mot de passe",
|
||||
"heading": "Définir un mot de passe",
|
||||
"description": "Avant de pouvoir commencer à utiliser Trilium depuis le web, vous devez d'abord définir un mot de passe. Vous utiliserez ensuite ce mot de passe pour vous connecter.",
|
||||
"password": "Mot de passe",
|
||||
"password-confirmation": "Confirmation du mot de passe",
|
||||
"button": "Définir le mot de passe"
|
||||
},
|
||||
"javascript-required": "Trilium nécessite que JavaScript soit activé.",
|
||||
"setup": {
|
||||
"heading": "Configuration de Trilium Notes",
|
||||
"new-document": "Je suis un nouvel utilisateur et je souhaite créer un nouveau document Trilium pour mes notes",
|
||||
"sync-from-desktop": "J'ai déjà l'application de bureau et je souhaite configurer la synchronisation avec celle-ci",
|
||||
"sync-from-server": "J'ai déjà un serveur et je souhaite configurer la synchronisation avec celui-ci",
|
||||
"next": "Suivant",
|
||||
"init-in-progress": "Initialisation du document en cours",
|
||||
"redirecting": "Vous serez bientôt redirigé vers l'application.",
|
||||
"title": "Configuration"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Synchroniser depuis une application de bureau",
|
||||
"description": "Cette procédure doit être réalisée depuis l'application de bureau installée sur votre ordinateur:",
|
||||
"step1": "Ouvrez l'application Trilium Notes.",
|
||||
"step2": "Dans le menu Trilium, cliquez sur Options.",
|
||||
"step3": "Cliquez sur la catégorie Synchroniser.",
|
||||
"step4": "Remplacez l'adresse de l'instance de serveur par : {{- host}} et cliquez sur Enregistrer.",
|
||||
"step5": "Cliquez sur le bouton 'Tester la synchronisation' pour vérifier que la connexion fonctionne.",
|
||||
"step6": "Une fois que vous avez terminé ces étapes, cliquez sur {{- link}}.",
|
||||
"step6-here": "ici"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Synchroniser depuis le serveur",
|
||||
"instructions": "Veuillez saisir l'adresse du serveur Trilium et les informations d'identification ci-dessous. Cela téléchargera l'intégralité du document Trilium à partir du serveur et configurera la synchronisation avec celui-ci. En fonction de la taille du document et de votre vitesse de connexion, cela peut prendre un plusieurs minutes.",
|
||||
"server-host": "Adresse du serveur Trilium",
|
||||
"server-host-placeholder": "https://<nom d'hôte>:<port>",
|
||||
"proxy-server": "Serveur proxy (facultatif)",
|
||||
"proxy-server-placeholder": "https://<nom d'hôte>:<port>",
|
||||
"note": "Note :",
|
||||
"proxy-instruction": "Si vous laissez le paramètre de proxy vide, le proxy du système sera utilisé (s'applique uniquement à l'application de bureau)",
|
||||
"password": "Mot de passe",
|
||||
"password-placeholder": "Mot de passe",
|
||||
"back": "Retour",
|
||||
"finish-setup": "Terminer"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Synchronisation en cours",
|
||||
"successful": "La synchronisation a été correctement configurée. La synchronisation initiale prendra un certain temps. Une fois terminée, vous serez redirigé vers la page de connexion.",
|
||||
"outstanding-items": "Éléments de synchronisation exceptionnels :",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Page non trouvée",
|
||||
"heading": "Page non trouvée"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "parent :",
|
||||
"clipped-from": "Cette note a été initialement extraite de {{- url}}",
|
||||
"child-notes": "Notes enfants :",
|
||||
"no-content": "Cette note n'a aucun contenu."
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Lundi",
|
||||
"tuesday": "Mardi",
|
||||
"wednesday": "Mercredi",
|
||||
"thursday": "Jeudi",
|
||||
"friday": "Vendredi",
|
||||
"saturday": "Samedi",
|
||||
"sunday": "Dimanche"
|
||||
},
|
||||
"months": {
|
||||
"january": "Janvier",
|
||||
"february": "Février",
|
||||
"march": "Mars",
|
||||
"april": "Avril",
|
||||
"may": "Mai",
|
||||
"june": "Juin",
|
||||
"july": "Juillet",
|
||||
"august": "Août",
|
||||
"september": "Septembre",
|
||||
"october": "Octobre",
|
||||
"november": "Novembre",
|
||||
"december": "Décembre"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "Recherche :"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "L'hôte du serveur de synchronisation n'est pas configuré. Veuillez d'abord configurer la synchronisation.",
|
||||
"successful": "L'établissement de liaison du serveur de synchronisation a été réussi, la synchronisation a été démarrée."
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"root-title": "Notes cachées",
|
||||
"search-history-title": "Historique de recherche",
|
||||
"note-map-title": "Carte de la Note",
|
||||
"sql-console-history-title": "Historique de la console SQL",
|
||||
"shared-notes-title": "Notes partagées",
|
||||
"bulk-action-title": "Action groupée",
|
||||
"backend-log-title": "Journal Backend",
|
||||
"user-hidden-title": "Utilisateur masqué",
|
||||
"launch-bar-templates-title": "Modèles de barre de raccourcis",
|
||||
"base-abstract-launcher-title": "Raccourci Base abstraite",
|
||||
"command-launcher-title": "Raccourci Commande",
|
||||
"note-launcher-title": "Raccourci Note",
|
||||
"script-launcher-title": "Raccourci Script",
|
||||
"built-in-widget-title": "Widget intégré",
|
||||
"spacer-title": "Séparateur",
|
||||
"custom-widget-title": "Widget personnalisé",
|
||||
"launch-bar-title": "Barre de lancement",
|
||||
"available-launchers-title": "Raccourcis disponibles",
|
||||
"go-to-previous-note-title": "Aller à la note précédente",
|
||||
"go-to-next-note-title": "Aller à la note suivante",
|
||||
"new-note-title": "Nouvelle note",
|
||||
"search-notes-title": "Rechercher des notes",
|
||||
"calendar-title": "Calendrier",
|
||||
"recent-changes-title": "Modifications récentes",
|
||||
"bookmarks-title": "Signets",
|
||||
"open-today-journal-note-title": "Ouvrir la note du journal du jour",
|
||||
"quick-search-title": "Recherche rapide",
|
||||
"protected-session-title": "Session protégée",
|
||||
"sync-status-title": "État de la synchronisation",
|
||||
"settings-title": "Réglages",
|
||||
"options-title": "Options",
|
||||
"appearance-title": "Apparence",
|
||||
"shortcuts-title": "Raccourcis",
|
||||
"text-notes": "Notes de texte",
|
||||
"code-notes-title": "Notes de code",
|
||||
"images-title": "Images",
|
||||
"spellcheck-title": "Correcteur orthographique",
|
||||
"password-title": "Mot de passe",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "Sauvegarde",
|
||||
"sync-title": "Synchronisation",
|
||||
"other": "Autre",
|
||||
"advanced-title": "Avancé",
|
||||
"visible-launchers-title": "Raccourcis visibles",
|
||||
"user-guide": "Guide de l'utilisateur"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Nouvelle note",
|
||||
"duplicate-note-suffix": "(dup)",
|
||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "Le fichier journal '{{ fileName }}' n'existe pas (encore).",
|
||||
"reading-log-failed": "La lecture du fichier journal d'administration '{{ fileName }}' a échoué."
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "Ce type de note ne peut pas être affiché."
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "Document PDF (*.pdf)",
|
||||
"unable-to-export-message": "La note actuelle n'a pas pu être exportée en format PDF.",
|
||||
"unable-to-export-title": "Impossible d'exporter au format PDF",
|
||||
"unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination."
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "Trilium Notes",
|
||||
"close": "Quitter Trilium",
|
||||
"recents": "Notes récentes",
|
||||
"bookmarks": "Signets",
|
||||
"today": "Ouvrir la note du journal du jour",
|
||||
"new-note": "Nouvelle note",
|
||||
"show-windows": "Afficher les fenêtres"
|
||||
},
|
||||
"migration": {
|
||||
"old_version": "La migration directe à partir de votre version actuelle n'est pas prise en charge. Veuillez d'abord mettre à jour vers la version v0.60.4, puis vers cette nouvelle version.",
|
||||
"error_message": "Erreur lors de la migration vers la version {{version}}: {{stack}}",
|
||||
"wrong_db_version": "La version de la base de données ({{version}}) est plus récente que ce que l'application supporte actuellement ({{targetVersion}}), ce qui signifie qu'elle a été créée par une version plus récente et incompatible de Trilium. Mettez à jour vers la dernière version de Trilium pour résoudre ce problème."
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "Erreur"
|
||||
}
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"",
|
||||
"search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active",
|
||||
"expand-subtree": "Développer le sous-arbre de la note actuelle",
|
||||
"collapse-tree": "Réduire toute l'arborescence des notes",
|
||||
"collapse-subtree": "Réduire le sous-arbre de la note actuelle",
|
||||
"sort-child-notes": "Trier les notes enfants",
|
||||
"creating-and-moving-notes": "Créer et déplacer des notes",
|
||||
"create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière",
|
||||
"delete-note": "Supprimer la note",
|
||||
"move-note-up": "Déplacer la note vers le haut",
|
||||
"move-note-down": "Déplacer la note vers le bas",
|
||||
"move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie",
|
||||
"move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie",
|
||||
"edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre",
|
||||
"edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche",
|
||||
"note-clipboard": "Note presse-papiers",
|
||||
"copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers",
|
||||
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active",
|
||||
"cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers",
|
||||
"select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active",
|
||||
"add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection",
|
||||
"add-note-below-to-selection": "Ajouter la note en dessous de la sélection",
|
||||
"duplicate-subtree": "Dupliquer le sous-arbre",
|
||||
"tabs-and-windows": "Onglets et fenêtres",
|
||||
"open-new-tab": "Ouvrir un nouvel onglet",
|
||||
"close-active-tab": "Fermer l'onglet actif",
|
||||
"reopen-last-tab": "Rouvrir le dernier onglet fermé",
|
||||
"activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif",
|
||||
"activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif",
|
||||
"open-new-window": "Ouvrir une nouvelle fenêtre vide",
|
||||
"toggle-tray": "Afficher/masquer l'application dans la barre des tâches",
|
||||
"first-tab": "Basculer vers le premier onglet dans la liste",
|
||||
"second-tab": "Basculer vers le deuxième onglet dans la liste",
|
||||
"third-tab": "Basculer vers le troisième onglet dans la liste",
|
||||
"fourth-tab": "Basculer vers le quatrième onglet dans la liste",
|
||||
"fifth-tab": "Basculer vers le cinquième onglet dans la liste",
|
||||
"sixth-tab": "Basculer vers le sixième onglet dans la liste",
|
||||
"seventh-tab": "Basculer vers le septième onglet dans la liste",
|
||||
"eight-tab": "Basculer vers le huitième onglet dans la liste",
|
||||
"ninth-tab": "Basculer vers le neuvième onglet dans la liste",
|
||||
"last-tab": "Basculer vers le dernier onglet dans la liste",
|
||||
"dialogs": "Boîtes de dialogue",
|
||||
"show-note-source": "Affiche la boîte de dialogue Source de la note",
|
||||
"show-options": "Afficher les Options",
|
||||
"show-revisions": "Afficher la boîte de dialogue Versions de la note",
|
||||
"show-recent-changes": "Afficher la boîte de dialogue Modifications récentes",
|
||||
"show-sql-console": "Afficher la boîte de dialogue Console SQL",
|
||||
"show-backend-log": "Afficher la boîte de dialogue Journal du backend",
|
||||
"text-note-operations": "Opérations sur les notes textuelles",
|
||||
"add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte",
|
||||
"follow-link-under-cursor": "Suivre le lien sous le curseur",
|
||||
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
|
||||
"paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers",
|
||||
"cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné",
|
||||
"add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note",
|
||||
"edit-readonly-note": "Éditer une note en lecture seule",
|
||||
"attributes-labels-and-relations": "Attributs (labels et relations)",
|
||||
"add-new-label": "Créer un nouveau label",
|
||||
"create-new-relation": "Créer une nouvelle relation",
|
||||
"ribbon-tabs": "Onglets du ruban",
|
||||
"toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note",
|
||||
"toggle-file-properties": "Afficher/masquer les Propriétés du fichier",
|
||||
"toggle-image-properties": "Afficher/masquer les Propriétés de l'image",
|
||||
"toggle-owned-attributes": "Afficher/masquer les Attributs propres",
|
||||
"toggle-inherited-attributes": "Afficher/masquer les Attributs hérités",
|
||||
"toggle-promoted-attributes": "Afficher/masquer les Attributs promus",
|
||||
"toggle-link-map": "Afficher/masquer la Carte de la note",
|
||||
"toggle-note-info": "Afficher/masquer les Informations de la note",
|
||||
"toggle-note-paths": "Afficher/masquer les Emplacements de la note",
|
||||
"toggle-similar-notes": "Afficher/masquer les Notes similaires",
|
||||
"other": "Autre",
|
||||
"toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations",
|
||||
"print-active-note": "Imprimer la note active",
|
||||
"open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut",
|
||||
"render-active-note": "Rendre (ou re-rendre) la note active",
|
||||
"run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active",
|
||||
"toggle-note-hoisting": "Activer le focus sur la note active",
|
||||
"unhoist": "Désactiver tout focus",
|
||||
"reload-frontend-app": "Recharger l'application",
|
||||
"open-dev-tools": "Ouvrir les outils de développement",
|
||||
"toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)",
|
||||
"toggle-full-screen": "Basculer en plein écran",
|
||||
"zoom-out": "Dézoomer",
|
||||
"zoom-in": "Zoomer",
|
||||
"note-navigation": "Navigation dans les notes",
|
||||
"reset-zoom-level": "Réinitialiser le niveau de zoom",
|
||||
"copy-without-formatting": "Copier le texte sélectionné sans mise en forme",
|
||||
"force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active",
|
||||
"show-help": "Affiche le guide de l'utilisateur intégré",
|
||||
"toggle-book-properties": "Afficher/masquer les Propriétés du Livre",
|
||||
"toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
|
||||
"export-as-pdf": "Exporte la note actuelle en PDF",
|
||||
"show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes",
|
||||
"toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)",
|
||||
"back-in-note-history": "Naviguer à la note précédente dans l'historique",
|
||||
"forward-in-note-history": "Naviguer a la note suivante dans l'historique",
|
||||
"open-command-palette": "Ouvrir la palette de commandes",
|
||||
"clone-notes-to": "Cloner les nœuds sélectionnés",
|
||||
"move-notes-to": "Déplacer les nœuds sélectionnés"
|
||||
},
|
||||
"login": {
|
||||
"title": "Connexion",
|
||||
"heading": "Connexion à Trilium",
|
||||
"incorrect-password": "Le mot de passe est incorrect. Veuillez réessayer.",
|
||||
"password": "Mot de passe",
|
||||
"remember-me": "Se souvenir de moi",
|
||||
"button": "Connexion"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "Définir un mot de passe",
|
||||
"heading": "Définir un mot de passe",
|
||||
"description": "Avant de pouvoir commencer à utiliser Trilium depuis le web, vous devez d'abord définir un mot de passe. Vous utiliserez ensuite ce mot de passe pour vous connecter.",
|
||||
"password": "Mot de passe",
|
||||
"password-confirmation": "Confirmation du mot de passe",
|
||||
"button": "Définir le mot de passe"
|
||||
},
|
||||
"javascript-required": "Trilium nécessite que JavaScript soit activé.",
|
||||
"setup": {
|
||||
"heading": "Configuration de Trilium Notes",
|
||||
"new-document": "Je suis un nouvel utilisateur et je souhaite créer un nouveau document Trilium pour mes notes",
|
||||
"sync-from-desktop": "J'ai déjà l'application de bureau et je souhaite configurer la synchronisation avec celle-ci",
|
||||
"sync-from-server": "J'ai déjà un serveur et je souhaite configurer la synchronisation avec celui-ci",
|
||||
"next": "Suivant",
|
||||
"init-in-progress": "Initialisation du document en cours",
|
||||
"redirecting": "Vous serez bientôt redirigé vers l'application.",
|
||||
"title": "Configuration"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Synchroniser depuis une application de bureau",
|
||||
"description": "Cette procédure doit être réalisée depuis l'application de bureau installée sur votre ordinateur:",
|
||||
"step1": "Ouvrez l'application Trilium Notes.",
|
||||
"step2": "Dans le menu Trilium, cliquez sur Options.",
|
||||
"step3": "Cliquez sur la catégorie Synchroniser.",
|
||||
"step4": "Remplacez l'adresse de l'instance de serveur par : {{- host}} et cliquez sur Enregistrer.",
|
||||
"step5": "Cliquez sur le bouton 'Tester la synchronisation' pour vérifier que la connexion fonctionne.",
|
||||
"step6": "Une fois que vous avez terminé ces étapes, cliquez sur {{- link}}.",
|
||||
"step6-here": "ici"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Synchroniser depuis le serveur",
|
||||
"instructions": "Veuillez saisir l'adresse du serveur Trilium et les informations d'identification ci-dessous. Cela téléchargera l'intégralité du document Trilium à partir du serveur et configurera la synchronisation avec celui-ci. En fonction de la taille du document et de votre vitesse de connexion, cela peut prendre un plusieurs minutes.",
|
||||
"server-host": "Adresse du serveur Trilium",
|
||||
"server-host-placeholder": "https://<nom d'hôte>:<port>",
|
||||
"proxy-server": "Serveur proxy (facultatif)",
|
||||
"proxy-server-placeholder": "https://<nom d'hôte>:<port>",
|
||||
"note": "Note :",
|
||||
"proxy-instruction": "Si vous laissez le paramètre de proxy vide, le proxy du système sera utilisé (s'applique uniquement à l'application de bureau)",
|
||||
"password": "Mot de passe",
|
||||
"password-placeholder": "Mot de passe",
|
||||
"back": "Retour",
|
||||
"finish-setup": "Terminer"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Synchronisation en cours",
|
||||
"successful": "La synchronisation a été correctement configurée. La synchronisation initiale prendra un certain temps. Une fois terminée, vous serez redirigé vers la page de connexion.",
|
||||
"outstanding-items": "Éléments de synchronisation exceptionnels :",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Page non trouvée",
|
||||
"heading": "Page non trouvée"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "parent :",
|
||||
"clipped-from": "Cette note a été initialement extraite de {{- url}}",
|
||||
"child-notes": "Notes enfants :",
|
||||
"no-content": "Cette note n'a aucun contenu."
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Lundi",
|
||||
"tuesday": "Mardi",
|
||||
"wednesday": "Mercredi",
|
||||
"thursday": "Jeudi",
|
||||
"friday": "Vendredi",
|
||||
"saturday": "Samedi",
|
||||
"sunday": "Dimanche"
|
||||
},
|
||||
"months": {
|
||||
"january": "Janvier",
|
||||
"february": "Février",
|
||||
"march": "Mars",
|
||||
"april": "Avril",
|
||||
"may": "Mai",
|
||||
"june": "Juin",
|
||||
"july": "Juillet",
|
||||
"august": "Août",
|
||||
"september": "Septembre",
|
||||
"october": "Octobre",
|
||||
"november": "Novembre",
|
||||
"december": "Décembre"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "Recherche :"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "L'hôte du serveur de synchronisation n'est pas configuré. Veuillez d'abord configurer la synchronisation.",
|
||||
"successful": "L'établissement de liaison du serveur de synchronisation a été réussi, la synchronisation a été démarrée."
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"root-title": "Notes cachées",
|
||||
"search-history-title": "Historique de recherche",
|
||||
"note-map-title": "Carte de la Note",
|
||||
"sql-console-history-title": "Historique de la console SQL",
|
||||
"shared-notes-title": "Notes partagées",
|
||||
"bulk-action-title": "Action groupée",
|
||||
"backend-log-title": "Journal Backend",
|
||||
"user-hidden-title": "Utilisateur masqué",
|
||||
"launch-bar-templates-title": "Modèles de barre de raccourcis",
|
||||
"base-abstract-launcher-title": "Raccourci Base abstraite",
|
||||
"command-launcher-title": "Raccourci Commande",
|
||||
"note-launcher-title": "Raccourci Note",
|
||||
"script-launcher-title": "Raccourci Script",
|
||||
"built-in-widget-title": "Widget intégré",
|
||||
"spacer-title": "Séparateur",
|
||||
"custom-widget-title": "Widget personnalisé",
|
||||
"launch-bar-title": "Barre de lancement",
|
||||
"available-launchers-title": "Raccourcis disponibles",
|
||||
"go-to-previous-note-title": "Aller à la note précédente",
|
||||
"go-to-next-note-title": "Aller à la note suivante",
|
||||
"new-note-title": "Nouvelle note",
|
||||
"search-notes-title": "Rechercher des notes",
|
||||
"calendar-title": "Calendrier",
|
||||
"recent-changes-title": "Modifications récentes",
|
||||
"bookmarks-title": "Signets",
|
||||
"open-today-journal-note-title": "Ouvrir la note du journal du jour",
|
||||
"quick-search-title": "Recherche rapide",
|
||||
"protected-session-title": "Session protégée",
|
||||
"sync-status-title": "État de la synchronisation",
|
||||
"settings-title": "Réglages",
|
||||
"options-title": "Options",
|
||||
"appearance-title": "Apparence",
|
||||
"shortcuts-title": "Raccourcis",
|
||||
"text-notes": "Notes de texte",
|
||||
"code-notes-title": "Notes de code",
|
||||
"images-title": "Images",
|
||||
"spellcheck-title": "Correcteur orthographique",
|
||||
"password-title": "Mot de passe",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "Sauvegarde",
|
||||
"sync-title": "Synchronisation",
|
||||
"other": "Autre",
|
||||
"advanced-title": "Avancé",
|
||||
"visible-launchers-title": "Raccourcis visibles",
|
||||
"user-guide": "Guide de l'utilisateur"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Nouvelle note",
|
||||
"duplicate-note-suffix": "(dup)",
|
||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "Le fichier journal '{{ fileName }}' n'existe pas (encore).",
|
||||
"reading-log-failed": "La lecture du fichier journal d'administration '{{ fileName }}' a échoué."
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "Ce type de note ne peut pas être affiché."
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "Document PDF (*.pdf)",
|
||||
"unable-to-export-message": "La note actuelle n'a pas pu être exportée en format PDF.",
|
||||
"unable-to-export-title": "Impossible d'exporter au format PDF",
|
||||
"unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination."
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "Trilium Notes",
|
||||
"close": "Quitter Trilium",
|
||||
"recents": "Notes récentes",
|
||||
"bookmarks": "Signets",
|
||||
"today": "Ouvrir la note du journal du jour",
|
||||
"new-note": "Nouvelle note",
|
||||
"show-windows": "Afficher les fenêtres"
|
||||
},
|
||||
"migration": {
|
||||
"old_version": "La migration directe à partir de votre version actuelle n'est pas prise en charge. Veuillez d'abord mettre à jour vers la version v0.60.4, puis vers cette nouvelle version.",
|
||||
"error_message": "Erreur lors de la migration vers la version {{version}}: {{stack}}",
|
||||
"wrong_db_version": "La version de la base de données ({{version}}) est plus récente que ce que l'application supporte actuellement ({{targetVersion}}), ce qui signifie qu'elle a été créée par une version plus récente et incompatible de Trilium. Mettez à jour vers la dernière version de Trilium pour résoudre ce problème."
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "Erreur"
|
||||
},
|
||||
"keyboard_action_names": {
|
||||
"command-palette": "Palette de commandes",
|
||||
"quick-search": "Recherche rapide"
|
||||
}
|
||||
}
|
||||
|
||||
157
apps/server/src/assets/translations/it/server.json
Normal file
157
apps/server/src/assets/translations/it/server.json
Normal file
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"keyboard_action_names": {
|
||||
"zoom-in": "Ingrandisci",
|
||||
"reset-zoom-level": "Ripristina il livello di ingrandimento",
|
||||
"zoom-out": "Riduci",
|
||||
"toggle-full-screen": "Attiva/disattiva lo Schermo Intero",
|
||||
"toggle-left-pane": "Attiva/disattiva il Pannello Sinistro",
|
||||
"toggle-zen-mode": "Attiva/disattiva la modalità zen",
|
||||
"toggle-right-pane": "Attiva/disattiva il Pannello Destro",
|
||||
"toggle-system-tray-icon": "Attiva/disattiva l'Icona nel Vassoio di Sistema",
|
||||
"toggle-note-hoisting": "Attiva/disattiva l'Ancoraggio della Nota",
|
||||
"unhoist-note": "Disancora la Nota",
|
||||
"reload-frontend-app": "Ricarica l'Applicazione Frontend",
|
||||
"open-developer-tools": "Apri gli Strumenti da Sviluppatore",
|
||||
"find-in-text": "Cerca nel Testo",
|
||||
"print-active-note": "Stampa la Nota Attiva",
|
||||
"export-active-note-as-pdf": "Esporta la nota attiva come PDF",
|
||||
"open-note-externally": "Apri Esternamente la Nota",
|
||||
"run-active-note": "Esegui la Nota Attiva",
|
||||
"render-active-note": "Presenta la Nota Attiva"
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"options-title": "Opzioni",
|
||||
"appearance-title": "Aspetto",
|
||||
"shortcuts-title": "Scorciatoie",
|
||||
"text-notes": "Note di Testo",
|
||||
"code-notes-title": "Note di Codice",
|
||||
"images-title": "Immagini",
|
||||
"spellcheck-title": "Controllo Ortografico",
|
||||
"password-title": "Password",
|
||||
"multi-factor-authentication-title": "MFA",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "Archiviazione",
|
||||
"sync-title": "Sincronizza",
|
||||
"ai-llm-title": "IA/LLM",
|
||||
"other": "Altro",
|
||||
"advanced-title": "Avanzato",
|
||||
"user-guide": "Guida Utente",
|
||||
"visible-launchers-title": "Lanciatori Visibili",
|
||||
"localization": "Lingua e Regione",
|
||||
"inbox-title": "Posta in arrivo"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Nuova nota",
|
||||
"duplicate-note-suffix": "(dup)",
|
||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "Il file di log del backend '{{ fileName }}' non esiste (ancora).",
|
||||
"reading-log-failed": "La lettura del file di log del backend '{{ fileName }}' è fallita."
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "Questo tipo di nota non può essere visualizzato."
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "Documento PDF (*.pdf)",
|
||||
"unable-to-export-message": "La nota corrente non può essere esportata come PDF.",
|
||||
"unable-to-export-title": "Impossibile esportare come PDF",
|
||||
"unable-to-save-message": "Il file selezionato non può essere salvato. Prova di nuovo o seleziona un'altra destinazione."
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "Trilium Notes",
|
||||
"close": "Esci da Trilium",
|
||||
"recents": "Note recenti",
|
||||
"bookmarks": "Segnalibri",
|
||||
"today": "Apri la nota di oggi",
|
||||
"new-note": "Nuova nota",
|
||||
"show-windows": "Mostra le finestre",
|
||||
"open_new_window": "Aprire una nuova finestra"
|
||||
},
|
||||
"migration": {
|
||||
"old_version": "La migrazione diretta dalla tua versione attuale non è supportata. Si prega di aggiornare prima all'ultima versione v0.60.4 e solo dopo a questa versione.",
|
||||
"error_message": "Errore durante la migrazione alla versione {{version}}: {{stack}}",
|
||||
"wrong_db_version": "La versione del database ({{version}}) è più recente di quanto l'applicazione si aspetti ({{targetVersion}}), il che significa che è stato creato da una versione più nuova e incompatibile di Trilium. Aggiorna Trilium all'ultima versione per risolvere questo problema."
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "Errore"
|
||||
},
|
||||
"share_theme": {
|
||||
"site-theme": "Tema del Sito",
|
||||
"search_placeholder": "Cerca..."
|
||||
},
|
||||
"keyboard_actions": {
|
||||
"back-in-note-history": "Navigare alla nota precedente della cronologia",
|
||||
"forward-in-note-history": "Navigare alla prossima nota della cronologia",
|
||||
"open-jump-to-note-dialog": "Apri la finestra di dialogo \"Salta alla nota\"",
|
||||
"open-command-palette": "Apri la palette dei comandi",
|
||||
"scroll-to-active-note": "Scorri l'albero fino alla nota attiva",
|
||||
"quick-search": "Attiva la barra di ricerca rapida",
|
||||
"search-in-subtree": "Cerca le note nel sotto albero della nota attiva",
|
||||
"expand-subtree": "Espande il sotto albero della nota corrente",
|
||||
"collapse-tree": "Contrae l'albero completo delle note",
|
||||
"collapse-subtree": "Contrae il sotto albero della nota corrente",
|
||||
"sort-child-notes": "Ordina le note figlio",
|
||||
"creating-and-moving-notes": "Crea e sposta le note",
|
||||
"create-note-after": "Crea una nota dopo quella attiva",
|
||||
"create-note-into": "Crea una nota come figlia di quella attiva",
|
||||
"create-note-into-inbox": "Creare una nota nella casella di posta (se definita) o nella nota del giorno",
|
||||
"delete-note": "Elimina una nota",
|
||||
"move-note-up": "Sposta su una nota",
|
||||
"move-note-down": "Sposta giù una nota",
|
||||
"move-note-up-in-hierarchy": "Sposta su la nota nella gerarchia",
|
||||
"move-note-down-in-hierarchy": "Sposta giù una nota nella gerarchia",
|
||||
"edit-note-title": "Salta dall'albero al dettaglio della nota e modifica il titolo",
|
||||
"edit-branch-prefix": "Mostra la finestra di dialogo \"Modifica il prefisso del ramo\"",
|
||||
"clone-notes-to": "Clona le note selezionate",
|
||||
"move-notes-to": "Sposta le note selezionate",
|
||||
"note-clipboard": "Appunti delle Note",
|
||||
"copy-notes-to-clipboard": "Copia le note selezionate negli appunti",
|
||||
"paste-notes-from-clipboard": "Incolla le note dagli appunti nella nota attiva",
|
||||
"cut-notes-to-clipboard": "Tagliare le note selezionate negli appunti",
|
||||
"select-all-notes-in-parent": "Seleziona tutte le note dal livello di nota corrente",
|
||||
"add-note-above-to-the-selection": "Aggiungi una nota sopra alla selezione",
|
||||
"add-note-below-to-selection": "Aggiungi una nota sotto alla selezione",
|
||||
"duplicate-subtree": "Duplica il sotto albero",
|
||||
"tabs-and-windows": "Schede e Finestre",
|
||||
"open-new-tab": "Apri una nuova scheda",
|
||||
"close-active-tab": "Chiudi la scheda attiva",
|
||||
"reopen-last-tab": "Riapri l'ultima scheda chiusa",
|
||||
"activate-next-tab": "Attiva la scheda sulla destra",
|
||||
"activate-previous-tab": "Attiva la scheda a sinistra",
|
||||
"open-new-window": "Apri una nuova finestra vuota",
|
||||
"toggle-tray": "Mostra/nascondi l'applicazione dal vassoio di sistema",
|
||||
"first-tab": "Attiva la prima scheda nell'elenco",
|
||||
"second-tab": "Attiva la seconda scheda nell'elenco",
|
||||
"third-tab": "Attiva la terza scheda nell'elenco",
|
||||
"fourth-tab": "Attiva la quarta scheda nella lista",
|
||||
"fifth-tab": "Attiva la quinta scheda nell'elenco",
|
||||
"sixth-tab": "Attiva la sesta scheda nell'elenco",
|
||||
"seventh-tab": "Attiva la settima scheda nella lista",
|
||||
"eight-tab": "Attiva l'ottava scheda nell'elenco",
|
||||
"ninth-tab": "Attiva la nona scheda nella lista",
|
||||
"last-tab": "Attiva l'ultima scheda nell'elenco",
|
||||
"dialogs": "Finestre di dialogo",
|
||||
"show-note-source": "Mostra la finestra di dialogo \"Sorgente della Nota\"",
|
||||
"show-options": "Apri la pagina \"Opzioni\"",
|
||||
"show-revisions": "Mostra la finestra di dialogo \"Revisione della Nota\"",
|
||||
"show-recent-changes": "Mostra la finestra di dialogo \"Modifiche Recenti\"",
|
||||
"show-sql-console": "Apri la pagina \"Console SQL\"",
|
||||
"show-backend-log": "Apri la pagina \"Log del Backend\"",
|
||||
"show-help": "Apri la Guida Utente integrata",
|
||||
"show-cheatsheet": "Mostra una finestra modale con le operazioni comuni da tastiera",
|
||||
"text-note-operations": "Operazioni sulle note di testo",
|
||||
"add-link-to-text": "Apri la finestra di dialogo per aggiungere il collegamento al testo",
|
||||
"follow-link-under-cursor": "Segui il collegamento all'interno del quale è il cursore",
|
||||
"insert-date-and-time-to-text": "Inserisci la data e l'ora corrente nel testo",
|
||||
"paste-markdown-into-text": "Incolla il Markdown dagli appunti nella nota di testo",
|
||||
"cut-into-note": "Taglia la selezione dalla nota corrente e crea una sotto nota col testo selezionato",
|
||||
"add-include-note-to-text": "Apre la finestra di dialogo per includere una nota",
|
||||
"edit-readonly-note": "Modifica una nota di sola lettura",
|
||||
"attributes-labels-and-relations": "Attributi (etichette e relazioni)",
|
||||
"add-new-label": "Crea una nuova etichetta",
|
||||
"create-new-relation": "Crea una nuova relazione",
|
||||
"ribbon-tabs": "Nastro delle schede",
|
||||
"toggle-basic-properties": "Mostra/nascondi le Proprietà di Base"
|
||||
}
|
||||
}
|
||||
1
apps/server/src/assets/translations/ja/server.json
Normal file
1
apps/server/src/assets/translations/ja/server.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -1,193 +1,428 @@
|
||||
{
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "Abir \"Pular para nota\" dialog",
|
||||
"search-in-subtree": "Procurar por notas na subárvore ativa",
|
||||
"expand-subtree": "Expandir subárvore da nota atual",
|
||||
"collapse-tree": "Colapsar a árvore completa de notas",
|
||||
"collapse-subtree": "Colapsar subárvore da nota atual",
|
||||
"sort-child-notes": "Ordenar notas filhas",
|
||||
"creating-and-moving-notes": "Criando e movendo notas",
|
||||
"create-note-into-inbox": "Crie uma nota na caixa de entrada (se definida) ou na nota do dia.",
|
||||
"delete-note": "Deletar nota",
|
||||
"move-note-up": "Mover nota para cima",
|
||||
"move-note-down": "Mover nota para baixo",
|
||||
"move-note-up-in-hierarchy": "Mover nota para cima em hierarquia",
|
||||
"move-note-down-in-hierarchy": "Mover nota para baixo em hierarquia",
|
||||
"edit-note-title": "Pule da árvore para os detalhes da nota e edite o título",
|
||||
"edit-branch-prefix": "Exibir o diálogo de edição do prefixo da branch",
|
||||
"note-clipboard": "Área de transferência de notas",
|
||||
"copy-notes-to-clipboard": "Copiar notas selecionadas para Área de transferência",
|
||||
"paste-notes-from-clipboard": "Colar notas da área de transferência na nota ativa",
|
||||
"cut-notes-to-clipboard": "Recortar as notas selecionadas para a área de transferência",
|
||||
"select-all-notes-in-parent": "Selecionar todas as notas do nível atual da nota",
|
||||
"add-note-above-to-the-selection": "Adicionar nota acima à seleção",
|
||||
"add-note-below-to-selection": "Adicionar nota abaixo à seleção",
|
||||
"duplicate-subtree": "Duplicar subárvores",
|
||||
"tabs-and-windows": "Abas & Janelas",
|
||||
"open-new-tab": "Abre nova aba",
|
||||
"close-active-tab": "Fecha aba ativa",
|
||||
"reopen-last-tab": "Reabre a última aba fechada",
|
||||
"activate-next-tab": "Ativa aba à direita",
|
||||
"activate-previous-tab": "Ativa aba à esquerda",
|
||||
"open-new-window": "Abre nova janela vazia",
|
||||
"toggle-tray": "Mostrar/ocultar o aplicativo da bandeja do sistema",
|
||||
"first-tab": "Ativa a primeira aba na lista",
|
||||
"second-tab": "Ativa a segunda aba na lista",
|
||||
"third-tab": "Ativa a terceira aba na lista",
|
||||
"fourth-tab": "Ativa a quarta aba na lista",
|
||||
"fifth-tab": "Ativa a quinta aba na lista",
|
||||
"sixth-tab": "Ativa a sexta aba na lista",
|
||||
"seventh-tab": "Ativa a sétima aba na lista",
|
||||
"eight-tab": "Ativa a oitava aba na lista",
|
||||
"ninth-tab": "Ativa a nona aba na lista",
|
||||
"last-tab": "Ativa a última aba na lista",
|
||||
"dialogs": "Dialogs",
|
||||
"show-note-source": "Exibe o log de origem da nota",
|
||||
"show-options": "Mostrar log de configurações",
|
||||
"show-revisions": "Exibe log de revisões de nota",
|
||||
"show-recent-changes": "Exibe o log de alterações recentes",
|
||||
"show-sql-console": "Exibe o log do console SQL",
|
||||
"show-backend-log": "Exibe o log do backend",
|
||||
"text-note-operations": "Operações de nota de texto",
|
||||
"add-link-to-text": "Abrir log e adcionar link ao texto",
|
||||
"follow-link-under-cursor": "Seguir o link sob o cursor",
|
||||
"insert-date-and-time-to-text": "Inserir data e hora atuais no texto",
|
||||
"paste-markdown-into-text": "Colar Markdown da área de transferência em nota de texto",
|
||||
"cut-into-note": "Corta a seleção da nota atual e cria uma subnota com o texto selecionado",
|
||||
"add-include-note-to-text": "Abre o log para incluir uma nota",
|
||||
"edit-readonly-note": "Editar uma nota somente leitura",
|
||||
"attributes-labels-and-relations": "Atributos (rótulos e relações)",
|
||||
"add-new-label": "Criar novo rótulo",
|
||||
"create-new-relation": "Criar nova relação",
|
||||
"ribbon-tabs": "Abas da faixa",
|
||||
"toggle-basic-properties": "Alterar Propriedades Básicas",
|
||||
"toggle-file-properties": "Alterar Propriedades do Arquivo",
|
||||
"toggle-image-properties": "Alterar Propriedades da Imagem",
|
||||
"toggle-owned-attributes": "Alterar Atributos Próprios",
|
||||
"toggle-inherited-attributes": "Alterar Atributos Herdados",
|
||||
"toggle-promoted-attributes": "Alternar Atributos Promovidos",
|
||||
"toggle-link-map": "Alternar Mapa de Links",
|
||||
"toggle-note-info": "Alternar Informações da Nota",
|
||||
"toggle-note-paths": "Alternar Caminhos da Nota",
|
||||
"toggle-similar-notes": "Alternar Notas Similares",
|
||||
"other": "Outros",
|
||||
"toggle-right-pane": "Alternar a exibição do painel direito, que inclui Sumário e Destaques",
|
||||
"print-active-note": "Imprimir nota ativa",
|
||||
"open-note-externally": "Abrir nota como arquivo no aplicativo padrão",
|
||||
"render-active-note": "Renderizar (re-renderizar) nota ativa",
|
||||
"run-active-note": "Executar código JavaScript (frontend/backend) da nota",
|
||||
"toggle-note-hoisting": "Alternar a elevação da nota ativa",
|
||||
"unhoist": "Desfazer elevação de tudo",
|
||||
"reload-frontend-app": "Recarregar Interface",
|
||||
"open-dev-tools": "Abrir ferramentas de desenvolvedor",
|
||||
"toggle-left-note-tree-panel": "Alternar painel esquerdo (árvore de notas)",
|
||||
"toggle-full-screen": "Alternar para tela cheia",
|
||||
"zoom-out": "Diminuir zoom",
|
||||
"zoom-in": "Aumentar zoom",
|
||||
"note-navigation": "Navegação de notas",
|
||||
"reset-zoom-level": "Redefinir nível de zoom",
|
||||
"copy-without-formatting": "Copiar texto selecionado sem formatação",
|
||||
"force-save-revision": "Forçar a criação/salvamento de uma nova revisão da nota ativa",
|
||||
"show-help": "Exibir Ajuda integrada / colinha",
|
||||
"toggle-book-properties": "Alternar propriedades do book",
|
||||
"toggle-classic-editor-toolbar": "Alternar a aba de Formatação no editor com barra de ferramentas fixa"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"heading": "Trilium login",
|
||||
"incorrect-password": "Senha incorreta. Tente novamente.",
|
||||
"password": "Senha",
|
||||
"remember-me": "Lembrar",
|
||||
"button": "Login"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "Definir senha",
|
||||
"heading": "Definir senha",
|
||||
"description": "Antes de começar a usar o Trilium web, você precisa definir uma senha. Você usará essa senha para fazer login.",
|
||||
"password": "Senha",
|
||||
"password-confirmation": "Confirmar Senha",
|
||||
"button": "Definir senha"
|
||||
},
|
||||
"javascript-required": "Trilium precisa que JavaScript esteja habilitado.",
|
||||
"setup": {
|
||||
"heading": "Trilium Notes setup",
|
||||
"new-document": "Sou um novo usuário e quero criar um novo documento Trilium para minhas notas",
|
||||
"sync-from-desktop": "Já tenho uma instância no desktop e quero configurar a sincronização com ela",
|
||||
"sync-from-server": "Já tenho uma instância no servidor e quero configurar a sincronização com ela",
|
||||
"next": "Avançar",
|
||||
"init-in-progress": "Inicialização do documento em andamento",
|
||||
"redirecting": "Você será redirecionado para o aplicativo em breve.",
|
||||
"title": "Setup"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Sincronizar com Desktop",
|
||||
"description": "Esta configuração deve ser iniciada a partir da instância do desktop:",
|
||||
"step1": "Abra sua instância do Trilium Notes no desktop.",
|
||||
"step2": "No menu do Trilium, clique em Opções.",
|
||||
"step3": "Clique na categoria Sincronização.",
|
||||
"step4": "Altere o endereço da instância do servidor para: {{- host}} e clique em Salvar.",
|
||||
"step5": "Clique no botão \"Testar sincronização\" para verificar se a conexão foi bem-sucedida.",
|
||||
"step6": "Depois de concluir essas etapas, clique em {{- link}}.",
|
||||
"step6-here": "Aqui"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Sincronizar do Servidor",
|
||||
"instructions": "Por favor, insira abaixo o endereço e as credenciais do servidor Trilium. Isso fará o download de todo o documento Trilium do servidor e configurará a sincronização com ele. Dependendo do tamanho do documento e da velocidade da conexão, isso pode levar algum tempo.",
|
||||
"server-host": "Endereço do servidor Trilium",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Servidor proxy (opcional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Nota:",
|
||||
"proxy-instruction": "Se você deixar o campo de proxy em branco, o proxy do sistema será usado (aplicável apenas ao aplicativo desktop)",
|
||||
"password": "Senha",
|
||||
"password-placeholder": "Senha",
|
||||
"back": "Voltar",
|
||||
"finish-setup": "Terminar configuração"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sincronização em andamento",
|
||||
"successful": "A sincronização foi configurada corretamente. Levará algum tempo para que a sincronização inicial seja concluída. Quando terminar, você será redirecionado para a página de login.",
|
||||
"outstanding-items": "Itens de sincronização pendentes:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Não encontrado",
|
||||
"heading": "Não encontrado"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "pai:",
|
||||
"clipped-from": "Esta nota foi originalmente extraída de {{- url}}",
|
||||
"child-notes": "Notas filhas:",
|
||||
"no-content": "Esta nota não possui conteúdo."
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Segunda-feira",
|
||||
"tuesday": "Terça-feira",
|
||||
"wednesday": "Quarta-feira",
|
||||
"thursday": "Quinta-feira",
|
||||
"friday": "Sexta-feira",
|
||||
"saturday": "Sábado",
|
||||
"sunday": "Domingo"
|
||||
},
|
||||
"months": {
|
||||
"january": "Janeiro",
|
||||
"february": "Fevereiro",
|
||||
"march": "Março",
|
||||
"april": "Abril",
|
||||
"may": "Maio",
|
||||
"june": "Junho",
|
||||
"july": "Julho",
|
||||
"august": "Agosto",
|
||||
"september": "Setembro",
|
||||
"october": "Outubro",
|
||||
"november": "Novembro",
|
||||
"december": "Dezembro"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "Pesquisar:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "O host do servidor de sincronização não está configurado. Por favor, configure a sincronização primeiro.",
|
||||
"successful": "A comunicação com o servidor de sincronização foi bem-sucedida, a sincronização foi iniciada."
|
||||
}
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "Abrir diálogo \"Ir para nota\"",
|
||||
"search-in-subtree": "Buscar notas na subárvore da nota atual",
|
||||
"expand-subtree": "Expandir subárvore da nota atual",
|
||||
"collapse-tree": "Colapsar a árvore completa de notas",
|
||||
"collapse-subtree": "Colapsar subárvore da nota atual",
|
||||
"sort-child-notes": "Ordenar notas filhas",
|
||||
"creating-and-moving-notes": "Criando e movendo notas",
|
||||
"create-note-into-inbox": "Crie uma nota na caixa de entrada (se definida) ou na nota do dia",
|
||||
"delete-note": "Deletar nota",
|
||||
"move-note-up": "Mover nota para cima",
|
||||
"move-note-down": "Mover nota para baixo",
|
||||
"move-note-up-in-hierarchy": "Mover nota para cima em hierarquia",
|
||||
"move-note-down-in-hierarchy": "Mover nota para baixo em hierarquia",
|
||||
"edit-note-title": "Pule da árvore para os detalhes da nota e edite o título",
|
||||
"edit-branch-prefix": "Exibir o diálogo \"Editar prefixo da ramificação\"",
|
||||
"note-clipboard": "Área de transferência de notas",
|
||||
"copy-notes-to-clipboard": "Copiar notas selecionadas para Área de transferência",
|
||||
"paste-notes-from-clipboard": "Colar notas da área de transferência na nota atual",
|
||||
"cut-notes-to-clipboard": "Recortar as notas selecionadas para a área de transferência",
|
||||
"select-all-notes-in-parent": "Selecionar todas as notas do nível atual da nota",
|
||||
"add-note-above-to-the-selection": "Adicionar nota acima à seleção",
|
||||
"add-note-below-to-selection": "Adicionar nota abaixo à seleção",
|
||||
"duplicate-subtree": "Duplicar subárvores",
|
||||
"tabs-and-windows": "Abas & Janelas",
|
||||
"open-new-tab": "Abre nova aba",
|
||||
"close-active-tab": "Fecha aba ativa",
|
||||
"reopen-last-tab": "Reabre a última aba fechada",
|
||||
"activate-next-tab": "Ativa aba à direita",
|
||||
"activate-previous-tab": "Ativa aba à esquerda",
|
||||
"open-new-window": "Abre nova janela vazia",
|
||||
"toggle-tray": "Mostrar/ocultar o aplicativo da bandeja do sistema",
|
||||
"first-tab": "Ativa a primeira aba na lista",
|
||||
"second-tab": "Ativa a segunda aba na lista",
|
||||
"third-tab": "Ativa a terceira aba na lista",
|
||||
"fourth-tab": "Ativa a quarta aba na lista",
|
||||
"fifth-tab": "Ativa a quinta aba na lista",
|
||||
"sixth-tab": "Ativa a sexta aba na lista",
|
||||
"seventh-tab": "Ativa a sétima aba na lista",
|
||||
"eight-tab": "Ativa a oitava aba na lista",
|
||||
"ninth-tab": "Ativa a nona aba na lista",
|
||||
"last-tab": "Ativa a última aba na lista",
|
||||
"dialogs": "Diálogos",
|
||||
"show-note-source": "Exibe o log de origem da nota",
|
||||
"show-options": "Mostrar log de configurações",
|
||||
"show-revisions": "Exibe log de revisões de nota",
|
||||
"show-recent-changes": "Exibe o log de alterações recentes",
|
||||
"show-sql-console": "Exibe o log do console SQL",
|
||||
"show-backend-log": "Exibe o log do backend",
|
||||
"text-note-operations": "Operações de nota de texto",
|
||||
"add-link-to-text": "Abrir log e adcionar link ao texto",
|
||||
"follow-link-under-cursor": "Seguir o link sob o cursor",
|
||||
"insert-date-and-time-to-text": "Inserir data e hora atuais no texto",
|
||||
"paste-markdown-into-text": "Colar Markdown da área de transferência em nota de texto",
|
||||
"cut-into-note": "Corta a seleção da nota atual e cria uma subnota com o texto selecionado",
|
||||
"add-include-note-to-text": "Abre o log para incluir uma nota",
|
||||
"edit-readonly-note": "Editar uma nota somente leitura",
|
||||
"attributes-labels-and-relations": "Atributos (rótulos e relações)",
|
||||
"add-new-label": "Criar novo rótulo",
|
||||
"create-new-relation": "Criar nova relação",
|
||||
"ribbon-tabs": "Abas da faixa",
|
||||
"toggle-basic-properties": "Alterar Propriedades Básicas",
|
||||
"toggle-file-properties": "Alterar Propriedades do Arquivo",
|
||||
"toggle-image-properties": "Alterar Propriedades da Imagem",
|
||||
"toggle-owned-attributes": "Alterar Atributos Próprios",
|
||||
"toggle-inherited-attributes": "Alterar Atributos Herdados",
|
||||
"toggle-promoted-attributes": "Alternar Atributos Promovidos",
|
||||
"toggle-link-map": "Alternar Mapa de Links",
|
||||
"toggle-note-info": "Alternar Informações da Nota",
|
||||
"toggle-note-paths": "Alternar Caminhos da Nota",
|
||||
"toggle-similar-notes": "Alternar Notas Similares",
|
||||
"other": "Outros",
|
||||
"toggle-right-pane": "Alternar a exibição do painel direito, que inclui Sumário e Destaques",
|
||||
"print-active-note": "Imprimir nota atual",
|
||||
"open-note-externally": "Abrir nota como arquivo no aplicativo padrão",
|
||||
"render-active-note": "Renderizar (re-renderizar) nota atual",
|
||||
"run-active-note": "Executar código JavaScript (frontend/backend) da nota",
|
||||
"toggle-note-hoisting": "Alternar a elevação da nota atual",
|
||||
"unhoist": "Desfazer elevação de tudo",
|
||||
"reload-frontend-app": "Recarregar Interface",
|
||||
"open-dev-tools": "Abrir ferramentas de desenvolvedor",
|
||||
"toggle-left-note-tree-panel": "Alternar painel esquerdo (árvore de notas)",
|
||||
"toggle-full-screen": "Alternar para tela cheia",
|
||||
"zoom-out": "Diminuir zoom",
|
||||
"zoom-in": "Aumentar zoom",
|
||||
"note-navigation": "Navegação de notas",
|
||||
"reset-zoom-level": "Redefinir nível de zoom",
|
||||
"copy-without-formatting": "Copiar texto selecionado sem formatação",
|
||||
"force-save-revision": "Forçar a criação/salvamento de uma nova revisão da nota atual",
|
||||
"show-help": "Exibir Ajuda integrada / colinha",
|
||||
"toggle-book-properties": "Alternar propriedades do book",
|
||||
"toggle-classic-editor-toolbar": "Alternar a aba de Formatação no editor com barra de ferramentas fixa",
|
||||
"back-in-note-history": "Navegar para a nota anterior no histórico",
|
||||
"forward-in-note-history": "Navegar para a próxima nota no histórico",
|
||||
"open-command-palette": "Abrir paleta de comandos",
|
||||
"scroll-to-active-note": "Rolar a árvore de notas até a nota atual",
|
||||
"quick-search": "Ativar barra de busca rápida",
|
||||
"create-note-after": "Criar nota após nota atual",
|
||||
"create-note-into": "Criar nota como subnota da nota atual",
|
||||
"clone-notes-to": "Clonar notas selecionadas",
|
||||
"move-notes-to": "Mover notas selecionadas",
|
||||
"find-in-text": "Alternar painel de busca",
|
||||
"export-as-pdf": "Exportar a nota atual como PDF",
|
||||
"toggle-zen-mode": "Ativa/desativa o modo zen (interface mínima para uma edição mais focada)",
|
||||
"show-cheatsheet": "Exibir um modal com operações comuns de teclado"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"heading": "Trilium login",
|
||||
"incorrect-password": "Senha incorreta. Tente novamente.",
|
||||
"password": "Senha",
|
||||
"remember-me": "Lembrar",
|
||||
"button": "Login",
|
||||
"incorrect-totp": "O código TOTP está incorreto. Por favor, tente novamente.",
|
||||
"sign_in_with_sso": "Fazer login com {{ ssoIssuerName }}"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "Definir senha",
|
||||
"heading": "Definir senha",
|
||||
"description": "Antes de começar a usar o Trilium web, você precisa definir uma senha. Você usará essa senha para fazer login.",
|
||||
"password": "Senha",
|
||||
"password-confirmation": "Confirmar Senha",
|
||||
"button": "Definir senha"
|
||||
},
|
||||
"javascript-required": "Trilium precisa que JavaScript esteja habilitado.",
|
||||
"setup": {
|
||||
"heading": "Trilium Notes setup",
|
||||
"new-document": "Sou um novo usuário e quero criar um novo documento Trilium para minhas notas",
|
||||
"sync-from-desktop": "Já tenho uma instância no desktop e quero configurar a sincronização com ela",
|
||||
"sync-from-server": "Já tenho uma instância no servidor e quero configurar a sincronização com ela",
|
||||
"next": "Avançar",
|
||||
"init-in-progress": "Inicialização do documento em andamento",
|
||||
"redirecting": "Você será redirecionado para o aplicativo em breve.",
|
||||
"title": "Setup"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Sincronizar com Desktop",
|
||||
"description": "Esta configuração deve ser iniciada a partir da instância do desktop:",
|
||||
"step1": "Abra sua instância do Trilium Notes no desktop.",
|
||||
"step2": "No menu do Trilium, clique em Opções.",
|
||||
"step3": "Clique na categoria Sincronização.",
|
||||
"step4": "Altere o endereço da instância do servidor para: {{- host}} e clique em Salvar.",
|
||||
"step5": "Clique no botão \"Testar sincronização\" para verificar se a conexão foi bem-sucedida.",
|
||||
"step6": "Depois de concluir essas etapas, clique em {{- link}}.",
|
||||
"step6-here": "Aqui"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Sincronizar do Servidor",
|
||||
"instructions": "Por favor, insira abaixo o endereço e as credenciais do servidor Trilium. Isso fará o download de todo o documento Trilium do servidor e configurará a sincronização com ele. Dependendo do tamanho do documento e da velocidade da conexão, isso pode levar algum tempo.",
|
||||
"server-host": "Endereço do servidor Trilium",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Servidor proxy (opcional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Nota:",
|
||||
"proxy-instruction": "Se você deixar o campo de proxy em branco, o proxy do sistema será usado (aplicável apenas ao aplicativo desktop)",
|
||||
"password": "Senha",
|
||||
"password-placeholder": "Senha",
|
||||
"back": "Voltar",
|
||||
"finish-setup": "Terminar configuração"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sincronização em andamento",
|
||||
"successful": "A sincronização foi configurada corretamente. Levará algum tempo para que a sincronização inicial seja concluída. Quando terminar, você será redirecionado para a página de login.",
|
||||
"outstanding-items": "Itens de sincronização pendentes:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Não encontrado",
|
||||
"heading": "Não encontrado"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "pai:",
|
||||
"clipped-from": "Esta nota foi originalmente extraída de {{- url}}",
|
||||
"child-notes": "Notas filhas:",
|
||||
"no-content": "Esta nota não possui conteúdo."
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Segunda-feira",
|
||||
"tuesday": "Terça-feira",
|
||||
"wednesday": "Quarta-feira",
|
||||
"thursday": "Quinta-feira",
|
||||
"friday": "Sexta-feira",
|
||||
"saturday": "Sábado",
|
||||
"sunday": "Domingo"
|
||||
},
|
||||
"months": {
|
||||
"january": "Janeiro",
|
||||
"february": "Fevereiro",
|
||||
"march": "Março",
|
||||
"april": "Abril",
|
||||
"may": "Maio",
|
||||
"june": "Junho",
|
||||
"july": "Julho",
|
||||
"august": "Agosto",
|
||||
"september": "Setembro",
|
||||
"october": "Outubro",
|
||||
"november": "Novembro",
|
||||
"december": "Dezembro"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "Pesquisar:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "O host do servidor de sincronização não está configurado. Por favor, configure a sincronização primeiro.",
|
||||
"successful": "A comunicação com o servidor de sincronização foi bem-sucedida, a sincronização foi iniciada."
|
||||
},
|
||||
"keyboard_action_names": {
|
||||
"back-in-note-history": "Voltar no histórico da nota",
|
||||
"forward-in-note-history": "Avançar no histórico da nota",
|
||||
"jump-to-note": "Ir para...",
|
||||
"command-palette": "Paleta de Comandos",
|
||||
"scroll-to-active-note": "Rolar até a nota atual",
|
||||
"quick-search": "Busca Rápida",
|
||||
"search-in-subtree": "Buscar na subárvore",
|
||||
"expand-subtree": "Expandir subárvore",
|
||||
"collapse-tree": "Recolher Árvore",
|
||||
"collapse-subtree": "Recolher Subárvore",
|
||||
"sort-child-notes": "Ordenar Notas Filhas",
|
||||
"create-note-after": "Criar Nota Após",
|
||||
"create-note-into": "Criar Nota Dentro",
|
||||
"create-note-into-inbox": "Criar Nota na Caixa de Entrada",
|
||||
"delete-notes": "Excluir Notas",
|
||||
"move-note-up": "Mover Nota Para Cima",
|
||||
"move-note-down": "Mover Nota Para Baixo",
|
||||
"move-note-up-in-hierarchy": "Mover Nota Para Cima na Hierarquia",
|
||||
"move-note-down-in-hierarchy": "Mover Nota Para Baixo na Hierarquia",
|
||||
"edit-note-title": "Editar Título da Nota",
|
||||
"edit-branch-prefix": "Editar prefixo da ramificação",
|
||||
"clone-notes-to": "Clonar Notas Para",
|
||||
"move-notes-to": "Mover Notas Para",
|
||||
"copy-notes-to-clipboard": "Copiar Notas para a Área de Transferência",
|
||||
"paste-notes-from-clipboard": "Colar Notas da Área de Transferência",
|
||||
"cut-notes-to-clipboard": "Recortar Notas para a Área de Transferência",
|
||||
"select-all-notes-in-parent": "Selecionar Todas as Notas no Pai",
|
||||
"add-note-above-to-selection": "Adicionar Nota Acima à Seleção",
|
||||
"add-note-below-to-selection": "Adicionar Nota Abaixo à Seleção",
|
||||
"duplicate-subtree": "Duplicar Subárvore",
|
||||
"open-new-tab": "Abrir Nova Guia",
|
||||
"close-active-tab": "Fechar Guia Ativa",
|
||||
"reopen-last-tab": "Reabrir Última Guia",
|
||||
"activate-next-tab": "Ativar Próxima Guia",
|
||||
"activate-previous-tab": "Ativar Guia Anterior",
|
||||
"open-new-window": "Abrir Nova Janela",
|
||||
"toggle-system-tray-icon": "Alternar Ícone da Bandeja do Sistema",
|
||||
"toggle-zen-mode": "Alternar Modo Zen",
|
||||
"switch-to-first-tab": "Alternar para a Primeira Guia",
|
||||
"switch-to-second-tab": "Alternar para a Segunda Guia",
|
||||
"switch-to-third-tab": "Alternar para a Terceira Guia",
|
||||
"switch-to-fourth-tab": "Alternar para a Quarta Guia",
|
||||
"switch-to-fifth-tab": "Alternar para a Quinta Guia",
|
||||
"switch-to-sixth-tab": "Alternar para a Sexta Guia",
|
||||
"switch-to-seventh-tab": "Alternar para a Sétima Guia",
|
||||
"switch-to-eighth-tab": "Alternar para a Oitava Guia",
|
||||
"switch-to-ninth-tab": "Alternar para a Nona Guia",
|
||||
"switch-to-last-tab": "Alternar para a Última Guia",
|
||||
"show-note-source": "Exibir Fonte da Nota",
|
||||
"show-options": "Exibir Opções",
|
||||
"show-revisions": "Exibir Revisões",
|
||||
"show-recent-changes": "Exibir Alterações Recentes",
|
||||
"show-sql-console": "Exibir Console SQL",
|
||||
"show-backend-log": "Exibir Log do Backend",
|
||||
"show-help": "Exibir Ajuda",
|
||||
"show-cheatsheet": "Exibir Cheatsheet",
|
||||
"add-link-to-text": "Adicionar Link ao Texto",
|
||||
"follow-link-under-cursor": "Seguir Link sob o Cursor",
|
||||
"insert-date-and-time-to-text": "Inserir Data e Hora ao Texto",
|
||||
"paste-markdown-into-text": "Colar Markdown no Texto",
|
||||
"cut-into-note": "Recortar em Nota",
|
||||
"add-include-note-to-text": "Adicionar Nota de Inclusão ao Texto",
|
||||
"edit-read-only-note": "Editar Nota Somente-Leitura",
|
||||
"add-new-label": "Adicionar Nova Etiqueta",
|
||||
"add-new-relation": "Adicionar Nova Relação",
|
||||
"toggle-ribbon-tab-classic-editor": "Alternar Guia da Faixa de Opções Editor Clássico",
|
||||
"toggle-ribbon-tab-basic-properties": "Alternar Guia da Faixa de Opções Propriedades Básicas",
|
||||
"toggle-ribbon-tab-book-properties": "Alternar Guia da Faixa de Opções Propriedades do Livro",
|
||||
"toggle-ribbon-tab-file-properties": "Alternar Guia da Faixa de Opções Propriedades do Arquivo",
|
||||
"toggle-ribbon-tab-image-properties": "Alternar Guia da Faixa de Opções Propriedades da Imagem",
|
||||
"toggle-ribbon-tab-owned-attributes": "Alternar Guia da Faixa de Opções Atributos Possuídos",
|
||||
"toggle-ribbon-tab-inherited-attributes": "Alternar Guia da Faixa de Opções Atributos Herdados",
|
||||
"toggle-ribbon-tab-promoted-attributes": "Alternar Guia da Faixa de Opções Atributos Promovidos",
|
||||
"toggle-ribbon-tab-note-map": "Alternar Guia da Faixa de Opções Mapa de Notas",
|
||||
"toggle-ribbon-tab-note-info": "Alternar Guia da Faixa de Opções Informações da Nota",
|
||||
"toggle-ribbon-tab-note-paths": "Alternar Guia da Faixa de Opções Caminhos de Nota",
|
||||
"toggle-ribbon-tab-similar-notes": "Alternar Guia da Faixa de Opções Notas Semelhantes",
|
||||
"toggle-right-pane": "Alternar Painel Direito",
|
||||
"print-active-note": "Imprimir Nota Atual",
|
||||
"export-active-note-as-pdf": "Exportar Nota Atual como PDF",
|
||||
"open-note-externally": "Abrir Nota Externamente",
|
||||
"render-active-note": "Renderizar Nota Atual",
|
||||
"run-active-note": "Executar Nota Atual",
|
||||
"toggle-note-hoisting": "Alternar Elevação de Nota",
|
||||
"unhoist-note": "Desfazer Elevação de Nota",
|
||||
"reload-frontend-app": "Recarregar Frontend",
|
||||
"open-developer-tools": "Abrir Ferramentas de Desenvolvedor",
|
||||
"find-in-text": "Localizar no Texto",
|
||||
"toggle-left-pane": "Alternar Painel Esquerdo",
|
||||
"toggle-full-screen": "Alternar Tela Cheia",
|
||||
"zoom-out": "Reduzir Zoom",
|
||||
"zoom-in": "Aumentar Zoom",
|
||||
"reset-zoom-level": "Redefinir Nível de Zoom",
|
||||
"copy-without-formatting": "Copiar Sem Formatação",
|
||||
"force-save-revision": "Forçar Salvamento da Revisão"
|
||||
},
|
||||
"weekdayNumber": "Semana {weekNumber}",
|
||||
"quarterNumber": "Trimestre {quarterNumber}",
|
||||
"hidden-subtree": {
|
||||
"root-title": "Notas Ocultas",
|
||||
"search-history-title": "Histórico de Pesquisa",
|
||||
"note-map-title": "Mapa de Notas",
|
||||
"sql-console-history-title": "Histórico do Console SQL",
|
||||
"shared-notes-title": "Notas Compartilhadas",
|
||||
"bulk-action-title": "Ação em Massa",
|
||||
"backend-log-title": "Log do Backend",
|
||||
"user-hidden-title": "Usuário Oculto",
|
||||
"launch-bar-templates-title": "Modelos da Barra de Atalhos",
|
||||
"built-in-widget-title": "Widget Incorporado",
|
||||
"spacer-title": "Espaçador",
|
||||
"custom-widget-title": "Widget Personalizado",
|
||||
"go-to-previous-note-title": "Ir para Nota Anterior",
|
||||
"go-to-next-note-title": "Ir para Próxima Nota",
|
||||
"new-note-title": "Nova Nota",
|
||||
"search-notes-title": "Pesquisar Notas",
|
||||
"jump-to-note-title": "Ir para...",
|
||||
"calendar-title": "Calendário",
|
||||
"recent-changes-title": "Alterações Recentes",
|
||||
"bookmarks-title": "Favoritos",
|
||||
"open-today-journal-note-title": "Abrir Nota do Diário de Hoje",
|
||||
"quick-search-title": "Pesquisa Rápida",
|
||||
"protected-session-title": "Sessão Protegida",
|
||||
"sync-status-title": "Status de Sincronização",
|
||||
"settings-title": "Configurações",
|
||||
"llm-chat-title": "Conversar com as Notas",
|
||||
"options-title": "Opções",
|
||||
"appearance-title": "Aparência",
|
||||
"shortcuts-title": "Atalhos",
|
||||
"text-notes": "Notas de Texto",
|
||||
"code-notes-title": "Notas de Código",
|
||||
"images-title": "Imagens",
|
||||
"spellcheck-title": "Verificação Ortográfica",
|
||||
"password-title": "Senha",
|
||||
"multi-factor-authentication-title": "MFA",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "Backup",
|
||||
"sync-title": "Sincronizar",
|
||||
"ai-llm-title": "AI/LLM",
|
||||
"other": "Outros",
|
||||
"advanced-title": "Avançado",
|
||||
"user-guide": "Guia do Usuário",
|
||||
"localization": "Idioma e Região",
|
||||
"inbox-title": "Inbox",
|
||||
"base-abstract-launcher-title": "Atalho Abstrato Base",
|
||||
"command-launcher-title": "Atalho de Comando",
|
||||
"note-launcher-title": "Atalho de Notas",
|
||||
"script-launcher-title": "Atalho de Script",
|
||||
"launch-bar-title": "Barra de Atalhos",
|
||||
"available-launchers-title": "Atalhos disponíveis",
|
||||
"visible-launchers-title": "Atalhos Visíveis"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Nova nota",
|
||||
"duplicate-note-suffix": "(dup)",
|
||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "O arquivo de log do backend '{{ fileName }}' ainda não existe.",
|
||||
"reading-log-failed": "Falha ao ler o arquivo de log do backend '{{ fileName }}'."
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "Esta nota não pode ser exibida."
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "Documento PDF (*.pdf)",
|
||||
"unable-to-export-message": "A nota atual não pôde ser exportada como PDF.",
|
||||
"unable-to-export-title": "Não foi possível exportar como PDF",
|
||||
"unable-to-save-message": "O arquivo selecionado não pôde ser salvo. Tente novamente ou selecione outro destino."
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "Trilium Notes",
|
||||
"close": "Sair do Trilium",
|
||||
"recents": "Notas recentes",
|
||||
"bookmarks": "Favoritos",
|
||||
"today": "Abrir a nota do diário de hoje",
|
||||
"new-note": "Nova nota",
|
||||
"show-windows": "Exibir janelas",
|
||||
"open_new_window": "Abrir nova janela"
|
||||
},
|
||||
"migration": {
|
||||
"old_version": "A migração direta da sua versão atual não é suportada. Por favor, atualize primeiro para a versão mais recente v0.60.4 e somente depois para esta versão.",
|
||||
"error_message": "Erro durante a migração para a versão {{version}}: {{stack}}",
|
||||
"wrong_db_version": "A versão do banco de dados ({{version}}) é mais recente do que a esperada pelo aplicativo ({{targetVersion}}), o que significa que ele foi criado por uma versão mais nova e incompatível do Trilium. Atualize para a versão mais recente do Trilium para resolver esse problema."
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "Erro"
|
||||
},
|
||||
"share_theme": {
|
||||
"site-theme": "Tema do site",
|
||||
"search_placeholder": "Pesquisar...",
|
||||
"image_alt": "Imagem do artigo",
|
||||
"last-updated": "Atualizado pela última vez em {{- date}}",
|
||||
"subpages": "Subpáginas:",
|
||||
"on-this-page": "Nesta página",
|
||||
"expand": "Expandir"
|
||||
},
|
||||
"hidden_subtree_templates": {
|
||||
"text-snippet": "Trecho de texto",
|
||||
"description": "Descrição",
|
||||
"list-view": "Visualização em lista",
|
||||
"grid-view": "Visualização em grade",
|
||||
"calendar": "Calendário",
|
||||
"table": "Tabela",
|
||||
"geo-map": "Mapa geográfico",
|
||||
"start-date": "Data de início",
|
||||
"end-date": "Data de término",
|
||||
"start-time": "Hora de início",
|
||||
"end-time": "Hora de término",
|
||||
"geolocation": "Geolocalização",
|
||||
"built-in-templates": "Modelos integrados",
|
||||
"board": "Quadro",
|
||||
"status": "Status",
|
||||
"board_note_first": "Primeira nota",
|
||||
"board_note_second": "Segunda nota",
|
||||
"board_note_third": "Terceira nota",
|
||||
"board_status_todo": "A fazer",
|
||||
"board_status_progress": "Em andamento",
|
||||
"board_status_done": "Concluído"
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/server/src/assets/translations/ru/server.json
Normal file
1
apps/server/src/assets/translations/ru/server.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
apps/server/src/assets/translations/sr/server.json
Normal file
1
apps/server/src/assets/translations/sr/server.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
apps/server/src/assets/translations/tr/server.json
Normal file
1
apps/server/src/assets/translations/tr/server.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -1,192 +1,192 @@
|
||||
{
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "打開「跳轉到筆記」對話框",
|
||||
"search-in-subtree": "在當前筆記的子樹中搜索筆記",
|
||||
"expand-subtree": "展開當前筆記的子樹",
|
||||
"collapse-tree": "折疊完整的筆記樹",
|
||||
"collapse-subtree": "折疊當前筆記的子樹",
|
||||
"sort-child-notes": "排序子筆記",
|
||||
"creating-and-moving-notes": "新增和移動筆記",
|
||||
"create-note-into-inbox": "在收件匣(如果有定義的話)或日記中新增筆記",
|
||||
"delete-note": "刪除筆記",
|
||||
"move-note-up": "上移筆記",
|
||||
"move-note-down": "下移筆記",
|
||||
"move-note-up-in-hierarchy": "上移筆記層級",
|
||||
"move-note-down-in-hierarchy": "下移筆記層級",
|
||||
"edit-note-title": "從筆記樹跳轉到筆記詳情並編輯標題",
|
||||
"edit-branch-prefix": "顯示編輯分支前綴對話框",
|
||||
"note-clipboard": "筆記剪貼簿",
|
||||
"copy-notes-to-clipboard": "複製選定的筆記到剪貼簿",
|
||||
"paste-notes-from-clipboard": "從剪貼簿粘貼筆記到活動筆記中",
|
||||
"cut-notes-to-clipboard": "剪下選定的筆記到剪貼簿",
|
||||
"select-all-notes-in-parent": "選擇當前筆記級別的所有筆記",
|
||||
"add-note-above-to-the-selection": "將上方筆記添加到選擇中",
|
||||
"add-note-below-to-selection": "將下方筆記添加到選擇中",
|
||||
"duplicate-subtree": "複製子樹",
|
||||
"tabs-and-windows": "標籤和窗口",
|
||||
"open-new-tab": "打開新標籤",
|
||||
"close-active-tab": "關閉活動標籤",
|
||||
"reopen-last-tab": "重新打開最後關閉的標籤",
|
||||
"activate-next-tab": "激活右側標籤",
|
||||
"activate-previous-tab": "激活左側標籤",
|
||||
"open-new-window": "打開新空白窗口",
|
||||
"toggle-tray": "顯示/隱藏應用程式的系統托盤",
|
||||
"first-tab": "激活列表中的第一個標籤",
|
||||
"second-tab": "激活列表中的第二個標籤",
|
||||
"third-tab": "激活列表中的第三個標籤",
|
||||
"fourth-tab": "激活列表中的第四個標籤",
|
||||
"fifth-tab": "激活列表中的第五個標籤",
|
||||
"sixth-tab": "激活列表中的第六個標籤",
|
||||
"seventh-tab": "激活列表中的第七個標籤",
|
||||
"eight-tab": "激活列表中的第八個標籤",
|
||||
"ninth-tab": "激活列表中的第九個標籤",
|
||||
"last-tab": "激活列表中的最後一個標籤",
|
||||
"dialogs": "對話框",
|
||||
"show-note-source": "顯示筆記源對話框",
|
||||
"show-options": "顯示選項對話框",
|
||||
"show-revisions": "顯示筆記歷史對話框",
|
||||
"show-recent-changes": "顯示最近更改對話框",
|
||||
"show-sql-console": "顯示SQL控制台對話框",
|
||||
"show-backend-log": "顯示後端日誌對話框",
|
||||
"text-note-operations": "文本筆記操作",
|
||||
"add-link-to-text": "打開對話框以將鏈接添加到文本",
|
||||
"follow-link-under-cursor": "跟隨遊標下的鏈接",
|
||||
"insert-date-and-time-to-text": "將當前日期和時間插入文本",
|
||||
"paste-markdown-into-text": "將剪貼簿中的Markdown粘貼到文本筆記中",
|
||||
"cut-into-note": "從當前筆記中剪下選擇並新增包含選定文本的子筆記",
|
||||
"add-include-note-to-text": "打開對話框以包含筆記",
|
||||
"edit-readonly-note": "編輯唯讀筆記",
|
||||
"attributes-labels-and-relations": "屬性(標籤和關係)",
|
||||
"add-new-label": "新增新標籤",
|
||||
"create-new-relation": "新增新關係",
|
||||
"ribbon-tabs": "功能區標籤",
|
||||
"toggle-basic-properties": "切換基本屬性",
|
||||
"toggle-file-properties": "切換文件屬性",
|
||||
"toggle-image-properties": "切換圖像屬性",
|
||||
"toggle-owned-attributes": "切換擁有的屬性",
|
||||
"toggle-inherited-attributes": "切換繼承的屬性",
|
||||
"toggle-promoted-attributes": "切換提升的屬性",
|
||||
"toggle-link-map": "切換鏈接地圖",
|
||||
"toggle-note-info": "切換筆記資訊",
|
||||
"toggle-note-paths": "切換筆記路徑",
|
||||
"toggle-similar-notes": "切換相似筆記",
|
||||
"other": "其他",
|
||||
"toggle-right-pane": "切換右側面板的顯示,包括目錄和高亮",
|
||||
"print-active-note": "打印活動筆記",
|
||||
"open-note-externally": "以預設應用程式打開筆記文件",
|
||||
"render-active-note": "渲染(重新渲染)活動筆記",
|
||||
"run-active-note": "運行主動的JavaScript(前端/後端)代碼筆記",
|
||||
"toggle-note-hoisting": "切換活動筆記的提升",
|
||||
"unhoist": "從任何地方取消提升",
|
||||
"reload-frontend-app": "重新加載前端應用",
|
||||
"open-dev-tools": "打開開發工具",
|
||||
"toggle-left-note-tree-panel": "切換左側(筆記樹)面板",
|
||||
"toggle-full-screen": "切換全熒幕",
|
||||
"zoom-out": "縮小",
|
||||
"zoom-in": "放大",
|
||||
"note-navigation": "筆記導航",
|
||||
"reset-zoom-level": "重置縮放級別",
|
||||
"copy-without-formatting": "複製不帶格式的選定文本",
|
||||
"force-save-revision": "強制新增/保存當前筆記的歷史版本",
|
||||
"show-help": "顯示內置說明/備忘單",
|
||||
"toggle-book-properties": "切換書籍屬性"
|
||||
},
|
||||
"login": {
|
||||
"title": "登入",
|
||||
"heading": "Trilium登入",
|
||||
"incorrect-password": "密碼不正確。請再試一次。",
|
||||
"password": "密碼",
|
||||
"remember-me": "記住我",
|
||||
"button": "登入"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "設定密碼",
|
||||
"heading": "設定密碼",
|
||||
"description": "在您可以從Web開始使用Trilium之前,您需要先設定一個密碼。然後您將使用此密碼登錄。",
|
||||
"password": "密碼",
|
||||
"password-confirmation": "密碼確認",
|
||||
"button": "設定密碼"
|
||||
},
|
||||
"javascript-required": "Trilium需要啓用JavaScript。",
|
||||
"setup": {
|
||||
"heading": "TriliumNext筆記設定",
|
||||
"new-document": "我是新用戶,我想為我的筆記新增一個新的Trilium檔案",
|
||||
"sync-from-desktop": "我已經有一個桌面實例,我想設定與它的同步",
|
||||
"sync-from-server": "我已經有一個伺服器實例,我想設定與它的同步",
|
||||
"next": "下一步",
|
||||
"init-in-progress": "檔案初始化進行中",
|
||||
"redirecting": "您將很快被重定向到應用程式。",
|
||||
"title": "設定"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "從桌面同步",
|
||||
"description": "此設定需要從桌面實例啓動:",
|
||||
"step1": "打開您的TriliumNext筆記桌面實例。",
|
||||
"step2": "從Trilium菜單中,點擊選項。",
|
||||
"step3": "點擊同步。",
|
||||
"step4": "將伺服器實例地址更改為:{{- host}}並點擊保存。",
|
||||
"step5": "點擊「測試同步」按鈕以驗證連接是否成功。",
|
||||
"step6": "完成這些步驟後,點擊{{- link}}。",
|
||||
"step6-here": "這裡"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "從伺服器同步",
|
||||
"instructions": "請在下面輸入Trilium伺服器地址和密碼。這將從伺服器下載整個Trilium數據庫檔案並設定同步。因應數據庫大小和您的連接速度,這可能需要一段時間。",
|
||||
"server-host": "Trilium伺服器地址",
|
||||
"server-host-placeholder": "https://<主機名稱>:<端口>",
|
||||
"proxy-server": "代理伺服器(可選)",
|
||||
"proxy-server-placeholder": "https://<主機名稱>:<端口>",
|
||||
"note": "注意:",
|
||||
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面程式)",
|
||||
"password": "密碼",
|
||||
"password-placeholder": "密碼",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成設定"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "同步中",
|
||||
"successful": "同步已正確設定。初始同步完成可能需要一些時間。完成後,您將被重定向到登入頁面。",
|
||||
"outstanding-items": "未完成的同步項目:",
|
||||
"outstanding-items-default": "無"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "未找到",
|
||||
"heading": "未找到"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "上級目錄:",
|
||||
"clipped-from": "此筆記最初剪下自 {{- url}}",
|
||||
"child-notes": "子筆記:",
|
||||
"no-content": "此筆記沒有內容。"
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "週一",
|
||||
"tuesday": "週二",
|
||||
"wednesday": "週三",
|
||||
"thursday": "週四",
|
||||
"friday": "週五",
|
||||
"saturday": "週六",
|
||||
"sunday": "週日"
|
||||
},
|
||||
"months": {
|
||||
"january": "一月",
|
||||
"february": "二月",
|
||||
"march": "三月",
|
||||
"april": "四月",
|
||||
"may": "五月",
|
||||
"june": "六月",
|
||||
"july": "七月",
|
||||
"august": "八月",
|
||||
"september": "九月",
|
||||
"october": "十月",
|
||||
"november": "十一月",
|
||||
"december": "十二月"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "搜尋:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "並未設定同步伺服器主機,請先設定同步",
|
||||
"successful": "成功與同步伺服器握手,現在開始同步"
|
||||
}
|
||||
"keyboard_actions": {
|
||||
"open-jump-to-note-dialog": "打開「跳轉到筆記」對話框",
|
||||
"search-in-subtree": "在當前筆記的子樹中搜索筆記",
|
||||
"expand-subtree": "展開當前筆記的子樹",
|
||||
"collapse-tree": "折疊完整的筆記樹",
|
||||
"collapse-subtree": "折疊當前筆記的子樹",
|
||||
"sort-child-notes": "排序子筆記",
|
||||
"creating-and-moving-notes": "新增和移動筆記",
|
||||
"create-note-into-inbox": "在收件匣(如果有定義的話)或日記中新增筆記",
|
||||
"delete-note": "刪除筆記",
|
||||
"move-note-up": "上移筆記",
|
||||
"move-note-down": "下移筆記",
|
||||
"move-note-up-in-hierarchy": "上移筆記層級",
|
||||
"move-note-down-in-hierarchy": "下移筆記層級",
|
||||
"edit-note-title": "從筆記樹跳轉到筆記詳情並編輯標題",
|
||||
"edit-branch-prefix": "顯示編輯分支前綴對話框",
|
||||
"note-clipboard": "筆記剪貼簿",
|
||||
"copy-notes-to-clipboard": "複製選定的筆記到剪貼簿",
|
||||
"paste-notes-from-clipboard": "從剪貼簿粘貼筆記到活動筆記中",
|
||||
"cut-notes-to-clipboard": "剪下選定的筆記到剪貼簿",
|
||||
"select-all-notes-in-parent": "選擇當前筆記級別的所有筆記",
|
||||
"add-note-above-to-the-selection": "將上方筆記添加到選擇中",
|
||||
"add-note-below-to-selection": "將下方筆記添加到選擇中",
|
||||
"duplicate-subtree": "複製子樹",
|
||||
"tabs-and-windows": "標籤和窗口",
|
||||
"open-new-tab": "打開新標籤",
|
||||
"close-active-tab": "關閉活動標籤",
|
||||
"reopen-last-tab": "重新打開最後關閉的標籤",
|
||||
"activate-next-tab": "激活右側標籤",
|
||||
"activate-previous-tab": "激活左側標籤",
|
||||
"open-new-window": "打開新空白窗口",
|
||||
"toggle-tray": "顯示/隱藏應用程式的系統托盤",
|
||||
"first-tab": "激活列表中的第一個標籤",
|
||||
"second-tab": "激活列表中的第二個標籤",
|
||||
"third-tab": "激活列表中的第三個標籤",
|
||||
"fourth-tab": "激活列表中的第四個標籤",
|
||||
"fifth-tab": "激活列表中的第五個標籤",
|
||||
"sixth-tab": "激活列表中的第六個標籤",
|
||||
"seventh-tab": "激活列表中的第七個標籤",
|
||||
"eight-tab": "激活列表中的第八個標籤",
|
||||
"ninth-tab": "激活列表中的第九個標籤",
|
||||
"last-tab": "激活列表中的最後一個標籤",
|
||||
"dialogs": "對話框",
|
||||
"show-note-source": "顯示筆記源對話框",
|
||||
"show-options": "顯示選項對話框",
|
||||
"show-revisions": "顯示筆記歷史對話框",
|
||||
"show-recent-changes": "顯示最近更改對話框",
|
||||
"show-sql-console": "顯示SQL控制台對話框",
|
||||
"show-backend-log": "顯示後端日誌對話框",
|
||||
"text-note-operations": "文本筆記操作",
|
||||
"add-link-to-text": "打開對話框以將鏈接添加到文本",
|
||||
"follow-link-under-cursor": "跟隨遊標下的鏈接",
|
||||
"insert-date-and-time-to-text": "將當前日期和時間插入文本",
|
||||
"paste-markdown-into-text": "將剪貼簿中的Markdown粘貼到文本筆記中",
|
||||
"cut-into-note": "從當前筆記中剪下選擇並新增包含選定文本的子筆記",
|
||||
"add-include-note-to-text": "打開對話框以包含筆記",
|
||||
"edit-readonly-note": "編輯唯讀筆記",
|
||||
"attributes-labels-and-relations": "屬性(標籤和關係)",
|
||||
"add-new-label": "新增新標籤",
|
||||
"create-new-relation": "新增新關係",
|
||||
"ribbon-tabs": "功能區標籤",
|
||||
"toggle-basic-properties": "切換基本屬性",
|
||||
"toggle-file-properties": "切換文件屬性",
|
||||
"toggle-image-properties": "切換圖像屬性",
|
||||
"toggle-owned-attributes": "切換擁有的屬性",
|
||||
"toggle-inherited-attributes": "切換繼承的屬性",
|
||||
"toggle-promoted-attributes": "切換提升的屬性",
|
||||
"toggle-link-map": "切換鏈接地圖",
|
||||
"toggle-note-info": "切換筆記資訊",
|
||||
"toggle-note-paths": "切換筆記路徑",
|
||||
"toggle-similar-notes": "切換相似筆記",
|
||||
"other": "其他",
|
||||
"toggle-right-pane": "切換右側面板的顯示,包括目錄和高亮",
|
||||
"print-active-note": "打印活動筆記",
|
||||
"open-note-externally": "以預設應用程式打開筆記文件",
|
||||
"render-active-note": "渲染(重新渲染)活動筆記",
|
||||
"run-active-note": "運行主動的JavaScript(前端/後端)代碼筆記",
|
||||
"toggle-note-hoisting": "切換活動筆記的提升",
|
||||
"unhoist": "從任何地方取消提升",
|
||||
"reload-frontend-app": "重新加載前端應用",
|
||||
"open-dev-tools": "打開開發工具",
|
||||
"toggle-left-note-tree-panel": "切換左側(筆記樹)面板",
|
||||
"toggle-full-screen": "切換全熒幕",
|
||||
"zoom-out": "縮小",
|
||||
"zoom-in": "放大",
|
||||
"note-navigation": "筆記導航",
|
||||
"reset-zoom-level": "重置縮放級別",
|
||||
"copy-without-formatting": "複製不帶格式的選定文本",
|
||||
"force-save-revision": "強制新增/保存當前筆記的歷史版本",
|
||||
"show-help": "顯示內置說明/備忘單",
|
||||
"toggle-book-properties": "切換書籍屬性"
|
||||
},
|
||||
"login": {
|
||||
"title": "登入",
|
||||
"heading": "Trilium登入",
|
||||
"incorrect-password": "密碼不正確。請再試一次。",
|
||||
"password": "密碼",
|
||||
"remember-me": "記住我",
|
||||
"button": "登入"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "設定密碼",
|
||||
"heading": "設定密碼",
|
||||
"description": "在您可以從Web開始使用Trilium之前,您需要先設定一個密碼。然後您將使用此密碼登錄。",
|
||||
"password": "密碼",
|
||||
"password-confirmation": "密碼確認",
|
||||
"button": "設定密碼"
|
||||
},
|
||||
"javascript-required": "Trilium需要啓用JavaScript。",
|
||||
"setup": {
|
||||
"heading": "TriliumNext筆記設定",
|
||||
"new-document": "我是新用戶,我想為我的筆記新增一個新的Trilium檔案",
|
||||
"sync-from-desktop": "我已經有一個桌面實例,我想設定與它的同步",
|
||||
"sync-from-server": "我已經有一個伺服器實例,我想設定與它的同步",
|
||||
"next": "下一步",
|
||||
"init-in-progress": "檔案初始化進行中",
|
||||
"redirecting": "您將很快被重定向到應用程式。",
|
||||
"title": "設定"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "從桌面同步",
|
||||
"description": "此設定需要從桌面實例啓動:",
|
||||
"step1": "打開您的TriliumNext筆記桌面實例。",
|
||||
"step2": "從Trilium菜單中,點擊選項。",
|
||||
"step3": "點擊同步。",
|
||||
"step4": "將伺服器實例地址更改為:{{- host}}並點擊保存。",
|
||||
"step5": "點擊「測試同步」按鈕以驗證連接是否成功。",
|
||||
"step6": "完成這些步驟後,點擊{{- link}}。",
|
||||
"step6-here": "這裡"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "從伺服器同步",
|
||||
"instructions": "請在下面輸入Trilium伺服器地址和密碼。這將從伺服器下載整個Trilium數據庫檔案並設定同步。因應數據庫大小和您的連接速度,這可能需要一段時間。",
|
||||
"server-host": "Trilium伺服器地址",
|
||||
"server-host-placeholder": "https://<主機名稱>:<端口>",
|
||||
"proxy-server": "代理伺服器(可選)",
|
||||
"proxy-server-placeholder": "https://<主機名稱>:<端口>",
|
||||
"note": "注意:",
|
||||
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面程式)",
|
||||
"password": "密碼",
|
||||
"password-placeholder": "密碼",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成設定"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "同步中",
|
||||
"successful": "同步已正確設定。初始同步完成可能需要一些時間。完成後,您將被重定向到登入頁面。",
|
||||
"outstanding-items": "未完成的同步項目:",
|
||||
"outstanding-items-default": "無"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "未找到",
|
||||
"heading": "未找到"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "上級目錄:",
|
||||
"clipped-from": "此筆記最初剪下自 {{- url}}",
|
||||
"child-notes": "子筆記:",
|
||||
"no-content": "此筆記沒有內容。"
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "週一",
|
||||
"tuesday": "週二",
|
||||
"wednesday": "週三",
|
||||
"thursday": "週四",
|
||||
"friday": "週五",
|
||||
"saturday": "週六",
|
||||
"sunday": "週日"
|
||||
},
|
||||
"months": {
|
||||
"january": "一月",
|
||||
"february": "二月",
|
||||
"march": "三月",
|
||||
"april": "四月",
|
||||
"may": "五月",
|
||||
"june": "六月",
|
||||
"july": "七月",
|
||||
"august": "八月",
|
||||
"september": "九月",
|
||||
"october": "十月",
|
||||
"november": "十一月",
|
||||
"december": "十二月"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "搜尋:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "並未設定同步伺服器主機,請先設定同步",
|
||||
"successful": "成功與同步伺服器握手,現在開始同步"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,19 +43,52 @@ vi.mock("../../services/llm/storage/chat_storage_service.js", () => ({
|
||||
|
||||
// Mock AI service manager
|
||||
const mockAiServiceManager = {
|
||||
getOrCreateAnyService: vi.fn()
|
||||
getOrCreateAnyService: vi.fn().mockResolvedValue({
|
||||
generateChatCompletion: vi.fn(),
|
||||
isAvailable: vi.fn(() => true),
|
||||
dispose: vi.fn()
|
||||
}),
|
||||
getService: vi.fn().mockResolvedValue({
|
||||
generateChatCompletion: vi.fn(),
|
||||
isAvailable: vi.fn(() => true),
|
||||
dispose: vi.fn()
|
||||
})
|
||||
};
|
||||
vi.mock("../../services/llm/ai_service_manager.js", () => ({
|
||||
default: mockAiServiceManager
|
||||
}));
|
||||
|
||||
// Mock chat pipeline
|
||||
const mockChatPipelineExecute = vi.fn();
|
||||
const MockChatPipeline = vi.fn().mockImplementation(() => ({
|
||||
execute: mockChatPipelineExecute
|
||||
// Mock simplified pipeline
|
||||
const mockPipelineExecute = vi.fn();
|
||||
vi.mock("../../services/llm/pipeline/simplified_pipeline.js", () => ({
|
||||
default: {
|
||||
execute: mockPipelineExecute
|
||||
}
|
||||
}));
|
||||
vi.mock("../../services/llm/pipeline/chat_pipeline.js", () => ({
|
||||
ChatPipeline: MockChatPipeline
|
||||
|
||||
// Mock logging service
|
||||
vi.mock("../../services/llm/pipeline/logging_service.js", () => ({
|
||||
default: {
|
||||
withRequestId: vi.fn(() => ({
|
||||
log: vi.fn()
|
||||
}))
|
||||
},
|
||||
LogLevel: {
|
||||
ERROR: 'error',
|
||||
WARN: 'warn',
|
||||
INFO: 'info',
|
||||
DEBUG: 'debug'
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock tool registry
|
||||
vi.mock("../../services/llm/tools/tool_registry.js", () => ({
|
||||
default: {
|
||||
getTools: vi.fn(() => []),
|
||||
getTool: vi.fn(),
|
||||
executeTool: vi.fn(),
|
||||
initialize: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock configuration helpers
|
||||
@@ -64,6 +97,56 @@ vi.mock("../../services/llm/config/configuration_helpers.js", () => ({
|
||||
getSelectedModelConfig: mockGetSelectedModelConfig
|
||||
}));
|
||||
|
||||
// Mock configuration service
|
||||
vi.mock("../../services/llm/pipeline/configuration_service.js", () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
ensureConfigLoaded: vi.fn(),
|
||||
getToolConfig: vi.fn(() => ({
|
||||
maxRetries: 3,
|
||||
timeout: 30000,
|
||||
enableSmartProcessing: true,
|
||||
maxToolIterations: 10,
|
||||
maxIterations: 10,
|
||||
enabled: true,
|
||||
parallelExecution: true
|
||||
})),
|
||||
getAIConfig: vi.fn(() => ({
|
||||
provider: 'test-provider',
|
||||
model: 'test-model'
|
||||
})),
|
||||
getDebugConfig: vi.fn(() => ({
|
||||
enableMetrics: true,
|
||||
enableLogging: true,
|
||||
enabled: true,
|
||||
logLevel: 'info',
|
||||
enableTracing: false
|
||||
})),
|
||||
getStreamingConfig: vi.fn(() => ({
|
||||
enableStreaming: true,
|
||||
enabled: true,
|
||||
chunkSize: 1024,
|
||||
flushInterval: 100
|
||||
})),
|
||||
getDefaultSystemPrompt: vi.fn(() => 'You are a helpful assistant.'),
|
||||
getDefaultConfig: vi.fn(() => ({
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
temperature: 0.7,
|
||||
maxTokens: 1000,
|
||||
topP: 1.0,
|
||||
presencePenalty: 0,
|
||||
frequencyPenalty: 0
|
||||
})),
|
||||
getDefaultCompletionOptions: vi.fn(() => ({
|
||||
temperature: 0.7,
|
||||
maxTokens: 1000,
|
||||
topP: 1.0,
|
||||
presencePenalty: 0,
|
||||
frequencyPenalty: 0
|
||||
}))
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock options service
|
||||
vi.mock("../../services/options.js", () => ({
|
||||
default: {
|
||||
@@ -349,7 +432,7 @@ describe("LLM API Tests", () => {
|
||||
|
||||
it("should initiate streaming for a chat message", async () => {
|
||||
// Setup streaming simulation
|
||||
mockChatPipelineExecute.mockImplementation(async (input) => {
|
||||
mockPipelineExecute.mockImplementation(async (input) => {
|
||||
const callback = input.streamCallback;
|
||||
// Simulate streaming chunks
|
||||
await callback('Hello', false, {});
|
||||
@@ -463,7 +546,7 @@ describe("LLM API Tests", () => {
|
||||
}));
|
||||
|
||||
// Setup streaming with mention context
|
||||
mockChatPipelineExecute.mockImplementation(async (input) => {
|
||||
mockPipelineExecute.mockImplementation(async (input) => {
|
||||
// Verify mention content is included
|
||||
expect(input.query).toContain('Tell me about this note');
|
||||
expect(input.query).toContain('Root note content for testing');
|
||||
@@ -506,7 +589,7 @@ describe("LLM API Tests", () => {
|
||||
});
|
||||
|
||||
it("should handle streaming with thinking states", async () => {
|
||||
mockChatPipelineExecute.mockImplementation(async (input) => {
|
||||
mockPipelineExecute.mockImplementation(async (input) => {
|
||||
const callback = input.streamCallback;
|
||||
// Simulate thinking states
|
||||
await callback('', false, { thinking: 'Analyzing the question...' });
|
||||
@@ -546,15 +629,25 @@ describe("LLM API Tests", () => {
|
||||
});
|
||||
|
||||
it("should handle streaming with tool executions", async () => {
|
||||
mockChatPipelineExecute.mockImplementation(async (input) => {
|
||||
mockPipelineExecute.mockImplementation(async (input) => {
|
||||
const callback = input.streamCallback;
|
||||
// Simulate tool execution
|
||||
// Simulate tool execution with standardized response format
|
||||
await callback('Let me calculate that', false, {});
|
||||
await callback('', false, {
|
||||
toolExecution: {
|
||||
tool: 'calculator',
|
||||
arguments: { expression: '2 + 2' },
|
||||
result: '4',
|
||||
result: {
|
||||
success: true,
|
||||
result: '4',
|
||||
nextSteps: {
|
||||
suggested: 'Calculation completed successfully'
|
||||
},
|
||||
metadata: {
|
||||
executionTime: 15,
|
||||
resourcesUsed: ['calculator']
|
||||
}
|
||||
},
|
||||
toolCallId: 'call_123',
|
||||
action: 'execute'
|
||||
}
|
||||
@@ -576,14 +669,24 @@ describe("LLM API Tests", () => {
|
||||
// Import ws service to access mock
|
||||
const ws = (await import("../../services/ws.js")).default;
|
||||
|
||||
// Verify tool execution message
|
||||
// Verify tool execution message with standardized response format
|
||||
expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({
|
||||
type: 'llm-stream',
|
||||
chatNoteId: testChatId,
|
||||
toolExecution: {
|
||||
tool: 'calculator',
|
||||
args: { expression: '2 + 2' },
|
||||
result: '4',
|
||||
result: {
|
||||
success: true,
|
||||
result: '4',
|
||||
nextSteps: {
|
||||
suggested: 'Calculation completed successfully'
|
||||
},
|
||||
metadata: {
|
||||
executionTime: 15,
|
||||
resourcesUsed: ['calculator']
|
||||
}
|
||||
},
|
||||
toolCallId: 'call_123',
|
||||
action: 'execute',
|
||||
error: undefined
|
||||
@@ -593,7 +696,7 @@ describe("LLM API Tests", () => {
|
||||
});
|
||||
|
||||
it("should handle streaming errors gracefully", async () => {
|
||||
mockChatPipelineExecute.mockRejectedValue(new Error('Pipeline error'));
|
||||
mockPipelineExecute.mockRejectedValue(new Error('Pipeline error'));
|
||||
|
||||
const response = await supertest(app)
|
||||
.post(`/api/llm/chat/${testChatId}/messages/stream`)
|
||||
@@ -648,7 +751,7 @@ describe("LLM API Tests", () => {
|
||||
|
||||
it("should save chat messages after streaming completion", async () => {
|
||||
const completeResponse = 'This is the complete response';
|
||||
mockChatPipelineExecute.mockImplementation(async (input) => {
|
||||
mockPipelineExecute.mockImplementation(async (input) => {
|
||||
const callback = input.streamCallback;
|
||||
await callback(completeResponse, true, {});
|
||||
});
|
||||
@@ -668,12 +771,12 @@ describe("LLM API Tests", () => {
|
||||
// Note: Due to the mocked environment, the actual chat storage might not be called
|
||||
// This test verifies the streaming endpoint works correctly
|
||||
// The actual chat storage behavior is tested in the service layer tests
|
||||
expect(mockChatPipelineExecute).toHaveBeenCalled();
|
||||
expect(mockPipelineExecute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle rapid consecutive streaming requests", async () => {
|
||||
let callCount = 0;
|
||||
mockChatPipelineExecute.mockImplementation(async (input) => {
|
||||
mockPipelineExecute.mockImplementation(async (input) => {
|
||||
callCount++;
|
||||
const callback = input.streamCallback;
|
||||
await callback(`Response ${callCount}`, true, {});
|
||||
@@ -700,12 +803,12 @@ describe("LLM API Tests", () => {
|
||||
});
|
||||
|
||||
// Verify all were processed
|
||||
expect(mockChatPipelineExecute).toHaveBeenCalledTimes(3);
|
||||
expect(mockPipelineExecute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("should handle large streaming responses", async () => {
|
||||
const largeContent = 'x'.repeat(10000); // 10KB of content
|
||||
mockChatPipelineExecute.mockImplementation(async (input) => {
|
||||
mockPipelineExecute.mockImplementation(async (input) => {
|
||||
const callback = input.streamCallback;
|
||||
// Simulate chunked delivery of large content
|
||||
for (let i = 0; i < 10; i++) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import options from "../../services/options.js";
|
||||
|
||||
import restChatService from "../../services/llm/rest_chat_service.js";
|
||||
import chatStorageService from '../../services/llm/chat_storage_service.js';
|
||||
import toolRegistry from '../../services/llm/tools/tool_registry.js';
|
||||
import aiServiceManager from '../../services/llm/ai_service_manager.js';
|
||||
|
||||
// Define basic interfaces
|
||||
interface ChatMessage {
|
||||
@@ -559,13 +561,9 @@ async function handleStreamingProcess(
|
||||
const aiServiceManager = await import('../../services/llm/ai_service_manager.js');
|
||||
await aiServiceManager.default.getOrCreateAnyService();
|
||||
|
||||
// Use the chat pipeline directly for streaming
|
||||
const { ChatPipeline } = await import('../../services/llm/pipeline/chat_pipeline.js');
|
||||
const pipeline = new ChatPipeline({
|
||||
enableStreaming: true,
|
||||
enableMetrics: true,
|
||||
maxToolCallIterations: 5
|
||||
});
|
||||
// Use the simplified chat pipeline directly for streaming
|
||||
const simplifiedPipeline = await import('../../services/llm/pipeline/simplified_pipeline.js');
|
||||
const pipeline = simplifiedPipeline.default;
|
||||
|
||||
// Get selected model
|
||||
const { getSelectedModelConfig } = await import('../../services/llm/config/configuration_helpers.js');
|
||||
@@ -646,6 +644,180 @@ async function handleStreamingProcess(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/llm/interactions/{interactionId}/respond:
|
||||
* post:
|
||||
* summary: Respond to a user interaction request (confirm/cancel tool execution)
|
||||
* operationId: llm-interaction-respond
|
||||
* parameters:
|
||||
* - name: interactionId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: The ID of the interaction to respond to
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* response:
|
||||
* type: string
|
||||
* enum: [confirm, cancel]
|
||||
* description: User's response to the interaction
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Response processed successfully
|
||||
* '404':
|
||||
* description: Interaction not found
|
||||
* '400':
|
||||
* description: Invalid response
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["llm"]
|
||||
*/
|
||||
async function respondToInteraction(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const interactionId = req.params.interactionId;
|
||||
const { response } = req.body;
|
||||
|
||||
if (!interactionId || !response) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing interactionId or response'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (response !== 'confirm' && response !== 'cancel') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Response must be either "confirm" or "cancel"'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Import the pipeline to access user interaction stage
|
||||
// Note: In a real implementation, you'd maintain a registry of active pipelines
|
||||
// For now, we'll send this via WebSocket to be handled by the active pipeline
|
||||
|
||||
const wsService = (await import('../../services/ws.js')).default;
|
||||
|
||||
// Send the user response via WebSocket to be picked up by the active pipeline
|
||||
wsService.sendMessageToAllClients({
|
||||
type: 'user-interaction-response',
|
||||
interactionId,
|
||||
response,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `User response "${response}" recorded for interaction ${interactionId}`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error handling user interaction response: ${error}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug endpoint to check tool recognition and registry status
|
||||
*/
|
||||
async function debugTools(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
log.info("========== DEBUG TOOLS ENDPOINT CALLED ==========");
|
||||
|
||||
// Get detailed tool registry info
|
||||
const registryDebugInfo = toolRegistry.getDebugInfo();
|
||||
|
||||
// Get AI service manager status
|
||||
const availableProviders = aiServiceManager.getAvailableProviders();
|
||||
const providerStatus: Record<string, any> = {};
|
||||
|
||||
for (const provider of availableProviders) {
|
||||
try {
|
||||
const service = await aiServiceManager.getService(provider);
|
||||
providerStatus[provider] = {
|
||||
available: true,
|
||||
type: service.constructor.name,
|
||||
supportsTools: 'generateChatCompletion' in service
|
||||
};
|
||||
} catch (error) {
|
||||
providerStatus[provider] = {
|
||||
available: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get current tool definitions being sent to LLM
|
||||
const currentToolDefinitions = toolRegistry.getAllToolDefinitions();
|
||||
|
||||
// Format tool definitions for debugging
|
||||
const toolDefinitionSummary = currentToolDefinitions.map(def => ({
|
||||
name: def.function.name,
|
||||
description: def.function.description || 'No description',
|
||||
parameterCount: Object.keys(def.function.parameters?.properties || {}).length,
|
||||
requiredParams: def.function.parameters?.required || [],
|
||||
type: def.type || 'function'
|
||||
}));
|
||||
|
||||
const debugData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: {
|
||||
registrySize: registryDebugInfo.registrySize,
|
||||
validToolCount: registryDebugInfo.validToolCount,
|
||||
definitionsForLLM: currentToolDefinitions.length,
|
||||
availableProviders: availableProviders.length,
|
||||
initializationAttempted: registryDebugInfo.initializationAttempted
|
||||
},
|
||||
toolRegistry: {
|
||||
...registryDebugInfo,
|
||||
toolDefinitionSummary
|
||||
},
|
||||
aiServiceManager: {
|
||||
availableProviders,
|
||||
providerStatus
|
||||
},
|
||||
fullToolDefinitions: currentToolDefinitions,
|
||||
troubleshooting: {
|
||||
commonIssues: [
|
||||
"No tools in registry - check tool initialization in AIServiceManager",
|
||||
"Tools failing validation - check execute methods and definitions",
|
||||
"Provider not supporting function calling - verify model capabilities",
|
||||
"Tool definitions not being sent to LLM - check enableTools option"
|
||||
],
|
||||
checkpoints: [
|
||||
`Tools registered: ${registryDebugInfo.registrySize > 0 ? '✓' : '✗'}`,
|
||||
`Tools valid: ${registryDebugInfo.validToolCount > 0 ? '✓' : '✗'}`,
|
||||
`Definitions available: ${currentToolDefinitions.length > 0 ? '✓' : '✗'}`,
|
||||
`Providers available: ${availableProviders.length > 0 ? '✓' : '✗'}`
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
log.info(`Debug tools response: ${JSON.stringify(debugData.summary, null, 2)}`);
|
||||
|
||||
res.status(200).json(debugData);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error in debug tools endpoint: ${errorMessage}`);
|
||||
res.status(500).json({
|
||||
error: 'Failed to retrieve debug information',
|
||||
message: errorMessage,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
// Chat session management
|
||||
createSession,
|
||||
@@ -654,5 +826,11 @@ export default {
|
||||
listSessions,
|
||||
deleteSession,
|
||||
sendMessage,
|
||||
streamMessage
|
||||
streamMessage,
|
||||
|
||||
// User interaction
|
||||
respondToInteraction,
|
||||
|
||||
// Debug endpoints
|
||||
debugTools
|
||||
};
|
||||
|
||||
152
apps/server/src/routes/api/llm_metrics.ts
Normal file
152
apps/server/src/routes/api/llm_metrics.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* LLM Metrics API Endpoint
|
||||
*
|
||||
* Provides metrics export endpoints for monitoring systems
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getProviderFactory } from '../../services/llm/providers/provider_factory.js';
|
||||
import log from '../../services/log.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/llm/metrics
|
||||
* Returns metrics in Prometheus format by default
|
||||
*/
|
||||
router.get('/llm/metrics', (req: Request, res: Response) => {
|
||||
try {
|
||||
const format = req.query.format as string || 'prometheus';
|
||||
const factory = getProviderFactory();
|
||||
|
||||
if (!factory) {
|
||||
return res.status(503).json({ error: 'LLM service not initialized' });
|
||||
}
|
||||
|
||||
const metrics = factory.exportMetrics(format as any);
|
||||
|
||||
if (!metrics) {
|
||||
return res.status(503).json({ error: 'Metrics not available' });
|
||||
}
|
||||
|
||||
// Set appropriate content type based on format
|
||||
switch (format) {
|
||||
case 'prometheus':
|
||||
res.set('Content-Type', 'text/plain; version=0.0.4');
|
||||
res.send(metrics);
|
||||
break;
|
||||
case 'json':
|
||||
res.json(metrics);
|
||||
break;
|
||||
case 'opentelemetry':
|
||||
res.json(metrics);
|
||||
break;
|
||||
case 'statsd':
|
||||
res.set('Content-Type', 'text/plain');
|
||||
res.send(Array.isArray(metrics) ? metrics.join('\n') : metrics);
|
||||
break;
|
||||
default:
|
||||
res.status(400).json({ error: `Unknown format: ${format}` });
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`[LLM Metrics API] Error exporting metrics: ${error.message}`);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/llm/metrics/summary
|
||||
* Returns a summary of metrics in JSON format
|
||||
*/
|
||||
router.get('/llm/metrics/summary', (req: Request, res: Response) => {
|
||||
try {
|
||||
const factory = getProviderFactory();
|
||||
|
||||
if (!factory) {
|
||||
return res.status(503).json({ error: 'LLM service not initialized' });
|
||||
}
|
||||
|
||||
const summary = factory.getMetricsSummary();
|
||||
|
||||
if (!summary) {
|
||||
return res.status(503).json({ error: 'Metrics not available' });
|
||||
}
|
||||
|
||||
res.json(summary);
|
||||
} catch (error: any) {
|
||||
log.error(`[LLM Metrics API] Error getting metrics summary: ${error.message}`);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* GET /api/llm/health
|
||||
* Returns overall health status of LLM service
|
||||
*/
|
||||
router.get('/llm/health', (req: Request, res: Response) => {
|
||||
try {
|
||||
const factory = getProviderFactory();
|
||||
|
||||
if (!factory) {
|
||||
return res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
error: 'LLM service not initialized'
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = factory.getMetricsSummary();
|
||||
const statistics = factory.getStatistics();
|
||||
const healthStatuses = factory.getAllHealthStatuses();
|
||||
|
||||
// Get available/unavailable providers from health statuses
|
||||
const available: string[] = [];
|
||||
const unavailable: string[] = [];
|
||||
|
||||
for (const [provider, status] of healthStatuses) {
|
||||
if (status.healthy) {
|
||||
available.push(provider);
|
||||
} else {
|
||||
unavailable.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
providers: {
|
||||
available,
|
||||
unavailable,
|
||||
cached: statistics?.cachedProviders || 0,
|
||||
healthy: statistics?.healthyProviders || 0,
|
||||
unhealthy: statistics?.unhealthyProviders || 0
|
||||
},
|
||||
metrics: {
|
||||
totalRequests: metrics?.system?.totalRequests || 0,
|
||||
totalFailures: metrics?.system?.totalFailures || 0,
|
||||
uptime: metrics?.system?.uptime || 0
|
||||
}
|
||||
};
|
||||
|
||||
// Determine overall health
|
||||
if (health.providers.available.length === 0) {
|
||||
health.status = 'unhealthy';
|
||||
} else if (health.providers.unavailable.length > 0) {
|
||||
health.status = 'degraded';
|
||||
}
|
||||
|
||||
const statusCode = health.status === 'healthy' ? 200 :
|
||||
health.status === 'degraded' ? 200 : 503;
|
||||
|
||||
res.status(statusCode).json(health);
|
||||
} catch (error: any) {
|
||||
log.error(`[LLM Metrics API] Error getting health status: ${error.message}`);
|
||||
res.status(500).json({
|
||||
status: 'unhealthy',
|
||||
error: 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
298
apps/server/src/routes/api/llm_tools.ts
Normal file
298
apps/server/src/routes/api/llm_tools.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* API routes for enhanced LLM tool functionality
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import log from '../../services/log.js';
|
||||
import { toolPreviewManager } from '../../services/llm/tools/tool_preview.js';
|
||||
import { toolFeedbackManager } from '../../services/llm/tools/tool_feedback.js';
|
||||
import { toolErrorRecoveryManager, ToolErrorType } from '../../services/llm/tools/tool_error_recovery.js';
|
||||
import toolRegistry from '../../services/llm/tools/tool_registry.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Get tool preview for pending executions
|
||||
*/
|
||||
router.post('/preview', async (req, res) => {
|
||||
try {
|
||||
const { toolCalls } = req.body;
|
||||
|
||||
if (!toolCalls || !Array.isArray(toolCalls)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid request: toolCalls array required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get tool handlers
|
||||
const handlers = new Map();
|
||||
for (const toolCall of toolCalls) {
|
||||
const tool = toolRegistry.getTool(toolCall.function.name);
|
||||
if (tool) {
|
||||
handlers.set(toolCall.function.name, tool);
|
||||
}
|
||||
}
|
||||
|
||||
// Create execution plan
|
||||
const plan = toolPreviewManager.createExecutionPlan(toolCalls, handlers);
|
||||
|
||||
res.json(plan);
|
||||
} catch (error: any) {
|
||||
log.error(`Error creating tool preview: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: 'Failed to create tool preview',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Submit tool approval/rejection
|
||||
*/
|
||||
router.post('/preview/:planId/approval', async (req, res) => {
|
||||
try {
|
||||
const { planId } = req.params;
|
||||
const approval = req.body;
|
||||
|
||||
if (!approval || typeof approval.approved === 'undefined') {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid approval data'
|
||||
});
|
||||
}
|
||||
|
||||
approval.planId = planId;
|
||||
toolPreviewManager.recordApproval(approval);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: approval.approved ? 'Execution approved' : 'Execution rejected'
|
||||
});
|
||||
} catch (error: any) {
|
||||
log.error(`Error recording approval: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: 'Failed to record approval',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get active tool executions
|
||||
*/
|
||||
router.get('/executions/active', async (req, res) => {
|
||||
try {
|
||||
const executions = toolFeedbackManager.getActiveExecutions();
|
||||
res.json(executions);
|
||||
} catch (error: any) {
|
||||
log.error(`Error getting active executions: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get active executions',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get tool execution history
|
||||
*/
|
||||
router.get('/executions/history', async (req, res) => {
|
||||
try {
|
||||
const { toolName, status, limit } = req.query;
|
||||
|
||||
const filter: any = {};
|
||||
if (toolName) filter.toolName = String(toolName);
|
||||
if (status) filter.status = String(status);
|
||||
if (limit) filter.limit = parseInt(String(limit), 10);
|
||||
|
||||
const history = toolFeedbackManager.getHistory(filter);
|
||||
res.json(history);
|
||||
} catch (error: any) {
|
||||
log.error(`Error getting execution history: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get execution history',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get tool execution statistics
|
||||
*/
|
||||
router.get('/executions/stats', async (req, res) => {
|
||||
try {
|
||||
const stats = toolFeedbackManager.getStatistics();
|
||||
res.json(stats);
|
||||
} catch (error: any) {
|
||||
log.error(`Error getting execution statistics: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get execution statistics',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Cancel a running tool execution
|
||||
*/
|
||||
router.post('/executions/:executionId/cancel', async (req, res) => {
|
||||
try {
|
||||
const { executionId } = req.params;
|
||||
const { reason } = req.body;
|
||||
|
||||
const success = toolFeedbackManager.cancelExecution(
|
||||
executionId,
|
||||
'api',
|
||||
reason
|
||||
);
|
||||
|
||||
if (success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Execution cancelled'
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
error: 'Execution not found or not cancellable'
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`Error cancelling execution: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: 'Failed to cancel execution',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get circuit breaker status for tools
|
||||
*/
|
||||
router.get('/circuit-breakers', async (req, res) => {
|
||||
try {
|
||||
const tools = toolRegistry.getAllTools();
|
||||
const statuses: any[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
const toolName = tool.definition.function.name;
|
||||
const state = toolErrorRecoveryManager.getCircuitBreakerState(toolName);
|
||||
|
||||
statuses.push({
|
||||
toolName,
|
||||
displayName: tool.definition.function.name,
|
||||
state: state || 'closed',
|
||||
errorHistory: toolErrorRecoveryManager.getErrorHistory(toolName).length
|
||||
});
|
||||
}
|
||||
|
||||
res.json(statuses);
|
||||
} catch (error: any) {
|
||||
log.error(`Error getting circuit breaker status: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get circuit breaker status',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Reset circuit breaker for a tool
|
||||
*/
|
||||
router.post('/circuit-breakers/:toolName/reset', async (req, res) => {
|
||||
try {
|
||||
const { toolName } = req.params;
|
||||
|
||||
toolErrorRecoveryManager.resetCircuitBreaker(toolName);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Circuit breaker reset for ${toolName}`
|
||||
});
|
||||
} catch (error: any) {
|
||||
log.error(`Error resetting circuit breaker: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: 'Failed to reset circuit breaker',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get error recovery suggestions
|
||||
*/
|
||||
router.post('/errors/suggest-recovery', async (req, res) => {
|
||||
try {
|
||||
const { toolName, error, parameters } = req.body;
|
||||
|
||||
if (!toolName || !error) {
|
||||
return res.status(400).json({
|
||||
error: 'toolName and error are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Categorize the error
|
||||
const categorizedError = toolErrorRecoveryManager.categorizeError(error);
|
||||
|
||||
// Get recovery suggestions
|
||||
const suggestions = toolErrorRecoveryManager.suggestRecoveryActions(
|
||||
toolName,
|
||||
categorizedError,
|
||||
parameters || {}
|
||||
);
|
||||
|
||||
res.json({
|
||||
error: categorizedError,
|
||||
suggestions
|
||||
});
|
||||
} catch (error: any) {
|
||||
log.error(`Error getting recovery suggestions: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get recovery suggestions',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test tool execution with mock data
|
||||
*/
|
||||
router.post('/test/:toolName', async (req, res) => {
|
||||
try {
|
||||
const { toolName } = req.params;
|
||||
const { parameters } = req.body;
|
||||
|
||||
const tool = toolRegistry.getTool(toolName);
|
||||
if (!tool) {
|
||||
return res.status(404).json({
|
||||
error: `Tool not found: ${toolName}`
|
||||
});
|
||||
}
|
||||
|
||||
// Create a mock tool call
|
||||
const toolCall = {
|
||||
id: `test-${Date.now()}`,
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: parameters || {}
|
||||
}
|
||||
};
|
||||
|
||||
// Execute with recovery
|
||||
const result = await toolErrorRecoveryManager.executeWithRecovery(
|
||||
toolCall,
|
||||
tool,
|
||||
(attempt, delay) => {
|
||||
log.info(`Test execution retry: attempt ${attempt}, delay ${delay}ms`);
|
||||
}
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
log.error(`Error testing tool: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: 'Failed to test tool',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -11,7 +11,7 @@ import auth from "../services/auth.js";
|
||||
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
|
||||
const MAX_ALLOWED_FILE_SIZE_MB = 250;
|
||||
const MAX_ALLOWED_FILE_SIZE_MB = 2500;
|
||||
export const router = express.Router();
|
||||
|
||||
// TODO: Deduplicate with etapi_utils.ts afterwards.
|
||||
|
||||
@@ -377,6 +377,9 @@ function register(app: express.Application) {
|
||||
asyncApiRoute(DEL, "/api/llm/chat/:chatNoteId", llmRoute.deleteSession);
|
||||
asyncApiRoute(PST, "/api/llm/chat/:chatNoteId/messages", llmRoute.sendMessage);
|
||||
asyncApiRoute(PST, "/api/llm/chat/:chatNoteId/messages/stream", llmRoute.streamMessage);
|
||||
|
||||
// Debug endpoints
|
||||
asyncApiRoute(GET, "/api/llm/debug/tools", llmRoute.debugTools);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "path";
|
||||
import build from "./build.js";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
import dataDir from "./data_dir.js";
|
||||
import { AppInfo } from "@triliumnext/commons";
|
||||
|
||||
const APP_DB_VERSION = 233;
|
||||
const SYNC_VERSION = 36;
|
||||
@@ -16,5 +17,5 @@ export default {
|
||||
buildRevision: build.buildRevision,
|
||||
dataDirectory: path.resolve(dataDir.TRILIUM_DATA_DIR),
|
||||
clipperProtocolVersion: CLIPPER_PROTOCOL_VERSION,
|
||||
utcDateTime: new Date().toISOString() // for timezone inference
|
||||
};
|
||||
utcDateTime: new Date().toISOString()
|
||||
} satisfies AppInfo;
|
||||
|
||||
@@ -412,6 +412,17 @@ export interface Api {
|
||||
*/
|
||||
backupNow(backupName: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Enables the complete duplication of the specified original note and all its children into the specified parent note.
|
||||
* The new note will be named the same as the original, with (Dup) added to the end of it.
|
||||
*
|
||||
* @param origNoteId - the noteId for the original note to be duplicated
|
||||
* @param newParentNoteId - the noteId for the parent note where the duplication is to be placed.
|
||||
*
|
||||
* @returns the note and the branch of the newly created note.
|
||||
*/
|
||||
duplicateSubtree(origNoteId: string, newParentNoteId: string): { note: BNote; branch: BBranch; }
|
||||
|
||||
/**
|
||||
* This object contains "at your risk" and "no BC guarantees" objects for advanced use cases.
|
||||
*/
|
||||
@@ -703,6 +714,7 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) {
|
||||
|
||||
this.runOutsideOfSync = syncMutex.doExclusively;
|
||||
this.backupNow = backupService.backupNow;
|
||||
this.duplicateSubtree = noteService.duplicateSubtree;
|
||||
|
||||
this.__private = {
|
||||
becca
|
||||
|
||||
@@ -83,6 +83,23 @@ describe("Hidden Subtree", () => {
|
||||
expect(updatedJumpToNote?.title).not.toBe("Renamed");
|
||||
});
|
||||
|
||||
it("maintains launchers hidden, if they were shown by default but moved by the user", () => {
|
||||
const launcher = becca.getNote("_lbLlmChat");
|
||||
const branch = launcher?.getParentBranches()[0];
|
||||
expect(branch).toBeDefined();
|
||||
expect(branch!.parentNoteId).toBe("_lbVisibleLaunchers");
|
||||
expect(launcher).toBeDefined();
|
||||
|
||||
cls.init(() => {
|
||||
branches.moveBranchToNote(branch!, "_lbAvailableLaunchers");
|
||||
hiddenSubtreeService.checkHiddenSubtree();
|
||||
});
|
||||
|
||||
const newBranches = launcher?.getParentBranches().filter(b => !b.isDeleted);
|
||||
expect(newBranches).toHaveLength(1);
|
||||
expect(newBranches![0].parentNoteId).toBe("_lbAvailableLaunchers");
|
||||
});
|
||||
|
||||
it("can restore names in all languages", async () => {
|
||||
const done = deferred<void>();
|
||||
cls.wrap(async () => {
|
||||
|
||||
@@ -356,20 +356,22 @@ function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtree
|
||||
} else {
|
||||
branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId);
|
||||
|
||||
// If the note exists but doesn't have a branch in the expected parent,
|
||||
// create the missing branch to ensure it's in the correct location
|
||||
if (!branch) {
|
||||
branch = new BBranch({
|
||||
noteId: item.id,
|
||||
parentNoteId: parentNoteId,
|
||||
notePosition: item.notePosition !== undefined ? item.notePosition : undefined,
|
||||
isExpanded: item.isExpanded !== undefined ? item.isExpanded : false
|
||||
}).save();
|
||||
}
|
||||
|
||||
// Clean up any branches that shouldn't exist according to the meta definition
|
||||
// For hidden subtree notes, we want to ensure they only exist in their designated locations
|
||||
if (item.enforceBranches || item.id.startsWith("_help")) {
|
||||
// If the note exists but doesn't have a branch in the expected parent,
|
||||
// create the missing branch to ensure it's in the correct location
|
||||
if (!branch) {
|
||||
console.log("Creating missing branch for note", item.id, "under parent", parentNoteId);
|
||||
branch = new BBranch({
|
||||
noteId: item.id,
|
||||
parentNoteId: parentNoteId,
|
||||
notePosition: item.notePosition !== undefined ? item.notePosition : undefined,
|
||||
isExpanded: item.isExpanded !== undefined ? item.isExpanded : false
|
||||
}).save();
|
||||
}
|
||||
|
||||
// Remove any branches that are not in the expected parent.
|
||||
const expectedParents = getExpectedParentIds(item.id, hiddenSubtreeDefinition);
|
||||
const currentBranches = note.getParentBranches();
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const DAYJS_LOADER: Record<LOCALE_IDS, () => Promise<typeof import("dayjs/locale
|
||||
"he": () => import("dayjs/locale/he.js"),
|
||||
"ku": () => import("dayjs/locale/ku.js"),
|
||||
"ro": () => import("dayjs/locale/ro.js"),
|
||||
"ru": () => import("dayjs/locale/ru.js"),
|
||||
"tw": () => import("dayjs/locale/zh-tw.js")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ToolCall } from './tools/tool_interfaces.js';
|
||||
import type { ModelMetadata } from './providers/provider_options.js';
|
||||
import type { ToolCall } from './tools/tool_interfaces.js';
|
||||
|
||||
// Re-export ToolCall so it's available from this module
|
||||
export type { ToolCall } from './tools/tool_interfaces.js';
|
||||
|
||||
/**
|
||||
* Interface for chat messages between client and LLM models
|
||||
@@ -31,12 +34,24 @@ export interface ToolData {
|
||||
}
|
||||
|
||||
export interface ToolExecutionInfo {
|
||||
type: 'start' | 'update' | 'complete' | 'error';
|
||||
type: 'start' | 'update' | 'complete' | 'error' | 'progress' | 'retry';
|
||||
action?: string;
|
||||
tool: {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
result?: string | Record<string, unknown>;
|
||||
progress?: {
|
||||
current: number;
|
||||
total: number;
|
||||
status: string;
|
||||
message: string;
|
||||
startTime?: number;
|
||||
executionTime?: number;
|
||||
resultSummary?: string;
|
||||
errorType?: string;
|
||||
estimatedDuration?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +95,12 @@ export interface StreamChunk {
|
||||
* Includes tool name, args, and execution status
|
||||
*/
|
||||
toolExecution?: ToolExecutionInfo;
|
||||
|
||||
/**
|
||||
* User interaction data (for confirmation/cancellation requests)
|
||||
* Contains interaction ID, tool info, and response options
|
||||
*/
|
||||
userInteraction?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,6 +232,21 @@ export interface ChatResponse {
|
||||
|
||||
/** Tool calls from the LLM (if tools were used and the model supports them) */
|
||||
tool_calls?: ToolCall[] | null;
|
||||
|
||||
/** Recovery metadata for advanced error recovery */
|
||||
recovery_metadata?: {
|
||||
total_attempts: number;
|
||||
successful_recoveries: number;
|
||||
failed_permanently: number;
|
||||
};
|
||||
|
||||
/** User interaction metadata for confirmation/cancellation features */
|
||||
interaction_metadata?: {
|
||||
total_interactions: number;
|
||||
confirmed: number;
|
||||
cancelled: number;
|
||||
timedout: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AIService {
|
||||
|
||||
@@ -8,6 +8,7 @@ import contextService from './context/services/context_service.js';
|
||||
import log from '../log.js';
|
||||
import { OllamaService } from './providers/ollama_service.js';
|
||||
import { OpenAIService } from './providers/openai_service.js';
|
||||
import { ProviderFactory, ProviderType, getProviderFactory } from './providers/provider_factory.js';
|
||||
|
||||
// Import interfaces
|
||||
import type {
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
clearConfigurationCache,
|
||||
validateConfiguration
|
||||
} from './config/configuration_helpers.js';
|
||||
import type { ProviderType } from './interfaces/configuration_interfaces.js';
|
||||
|
||||
/**
|
||||
* Interface representing relevant note context
|
||||
@@ -39,18 +39,46 @@ interface NoteContext {
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export class AIServiceManager implements IAIServiceManager {
|
||||
private currentService: AIService | null = null;
|
||||
private currentProvider: ServiceProviders | null = null;
|
||||
// Service cache entry with TTL
|
||||
interface ServiceCacheEntry {
|
||||
service: AIService;
|
||||
provider: ServiceProviders;
|
||||
createdAt: number;
|
||||
lastUsed: number;
|
||||
}
|
||||
|
||||
// Disposable interface for proper resource cleanup
|
||||
export interface Disposable {
|
||||
dispose(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export class AIServiceManager implements IAIServiceManager, Disposable {
|
||||
private serviceCache: Map<ServiceProviders, ServiceCacheEntry> = new Map();
|
||||
private readonly SERVICE_TTL_MS = 5 * 60 * 1000; // 5 minutes TTL
|
||||
private readonly CLEANUP_INTERVAL_MS = 60 * 1000; // Cleanup check every minute
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
private initialized = false;
|
||||
private disposed = false;
|
||||
private providerFactory: ProviderFactory | null = null;
|
||||
|
||||
constructor() {
|
||||
// Initialize provider factory
|
||||
this.providerFactory = getProviderFactory({
|
||||
enableHealthChecks: true,
|
||||
healthCheckInterval: 60000,
|
||||
enableFallback: true,
|
||||
enableCaching: true,
|
||||
cacheTimeout: this.SERVICE_TTL_MS,
|
||||
enableMetrics: true
|
||||
});
|
||||
|
||||
// Initialize tools immediately
|
||||
this.initializeTools().catch(error => {
|
||||
log.error(`Error initializing LLM tools during AIServiceManager construction: ${error.message || String(error)}`);
|
||||
});
|
||||
|
||||
// Removed complex provider change listener - we'll read options fresh each time
|
||||
// Start periodic cleanup of stale services
|
||||
this.startCleanupTimer();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
@@ -372,88 +400,172 @@ export class AIServiceManager implements IAIServiceManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current provider (forces recreation on next access)
|
||||
* Start the cleanup timer for removing stale services
|
||||
*/
|
||||
public clearCurrentProvider(): void {
|
||||
this.currentService = null;
|
||||
this.currentProvider = null;
|
||||
log.info('Cleared current provider - will be recreated on next access');
|
||||
private startCleanupTimer(): void {
|
||||
if (this.cleanupTimer) return;
|
||||
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.cleanupStaleServices();
|
||||
}, this.CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the current provider instance - only one instance total
|
||||
* Stop the cleanup timer
|
||||
*/
|
||||
private stopCleanupTimer(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stale services that haven't been used recently
|
||||
*/
|
||||
private cleanupStaleServices(): void {
|
||||
if (this.disposed) return;
|
||||
|
||||
const now = Date.now();
|
||||
const staleProviders: ServiceProviders[] = [];
|
||||
|
||||
for (const [provider, entry] of this.serviceCache.entries()) {
|
||||
if (now - entry.lastUsed > this.SERVICE_TTL_MS) {
|
||||
staleProviders.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
for (const provider of staleProviders) {
|
||||
this.disposeService(provider);
|
||||
}
|
||||
|
||||
if (staleProviders.length > 0) {
|
||||
log.info(`Cleaned up ${staleProviders.length} stale service(s): ${staleProviders.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose a specific service
|
||||
*/
|
||||
private disposeService(provider: ServiceProviders): void {
|
||||
const entry = this.serviceCache.get(provider);
|
||||
if (entry) {
|
||||
// If the service implements disposable, call dispose
|
||||
if ('dispose' in entry.service && typeof (entry.service as any).dispose === 'function') {
|
||||
try {
|
||||
(entry.service as any).dispose();
|
||||
} catch (error) {
|
||||
log.error(`Error disposing ${provider} service: ${error}`);
|
||||
}
|
||||
}
|
||||
this.serviceCache.delete(provider);
|
||||
log.info(`Disposed ${provider} service`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached providers (forces recreation on next access)
|
||||
*/
|
||||
public clearCurrentProvider(): void {
|
||||
// Clear provider factory cache
|
||||
if (this.providerFactory) {
|
||||
this.providerFactory.clearCache();
|
||||
}
|
||||
|
||||
// Clear local cache
|
||||
for (const provider of this.serviceCache.keys()) {
|
||||
this.disposeService(provider);
|
||||
}
|
||||
log.info('Cleared all cached providers - will be recreated on next access');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a provider instance using the provider factory
|
||||
*/
|
||||
private async getOrCreateChatProvider(providerName: ServiceProviders): Promise<AIService | null> {
|
||||
// If provider type changed, clear the old one
|
||||
if (this.currentProvider && this.currentProvider !== providerName) {
|
||||
log.info(`Provider changed from ${this.currentProvider} to ${providerName}, clearing old service`);
|
||||
this.currentService = null;
|
||||
this.currentProvider = null;
|
||||
if (this.disposed) {
|
||||
throw new Error('AIServiceManager has been disposed');
|
||||
}
|
||||
|
||||
// Return existing service if it matches and is available
|
||||
if (this.currentService && this.currentProvider === providerName && this.currentService.isAvailable()) {
|
||||
return this.currentService;
|
||||
if (!this.providerFactory) {
|
||||
throw new Error('Provider factory not initialized');
|
||||
}
|
||||
|
||||
// Clear invalid service
|
||||
if (this.currentService) {
|
||||
this.currentService = null;
|
||||
this.currentProvider = null;
|
||||
}
|
||||
|
||||
// Create new service for the requested provider
|
||||
try {
|
||||
let service: AIService | null = null;
|
||||
// Map ServiceProviders to ProviderType
|
||||
const providerTypeMap: Record<ServiceProviders, ProviderType> = {
|
||||
'openai': ProviderType.OPENAI,
|
||||
'anthropic': ProviderType.ANTHROPIC,
|
||||
'ollama': ProviderType.OLLAMA
|
||||
};
|
||||
|
||||
const providerType = providerTypeMap[providerName];
|
||||
if (!providerType) {
|
||||
log.error(`Unknown provider name: ${providerName}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if provider is configured
|
||||
switch (providerName) {
|
||||
case 'openai': {
|
||||
const apiKey = options.getOption('openaiApiKey');
|
||||
const baseUrl = options.getOption('openaiBaseUrl');
|
||||
if (!apiKey && !baseUrl) return null;
|
||||
|
||||
service = new OpenAIService();
|
||||
if (!service.isAvailable()) {
|
||||
throw new Error('OpenAI service not available');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'anthropic': {
|
||||
const apiKey = options.getOption('anthropicApiKey');
|
||||
if (!apiKey) return null;
|
||||
|
||||
service = new AnthropicService();
|
||||
if (!service.isAvailable()) {
|
||||
throw new Error('Anthropic service not available');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ollama': {
|
||||
const baseUrl = options.getOption('ollamaBaseUrl');
|
||||
if (!baseUrl) return null;
|
||||
|
||||
service = new OllamaService();
|
||||
if (!service.isAvailable()) {
|
||||
throw new Error('Ollama service not available');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (service) {
|
||||
// Cache the new service
|
||||
this.currentService = service;
|
||||
this.currentProvider = providerName;
|
||||
log.info(`Created and cached new ${providerName} service`);
|
||||
// Use provider factory to create the service
|
||||
const service = await this.providerFactory.createProvider(providerType);
|
||||
|
||||
if (service && service.isAvailable()) {
|
||||
log.info(`Created ${providerName} service via provider factory`);
|
||||
return service;
|
||||
}
|
||||
|
||||
throw new Error(`${providerName} service not available`);
|
||||
} catch (error: any) {
|
||||
log.error(`Failed to create ${providerName} chat provider: ${error.message || 'Unknown error'}`);
|
||||
|
||||
// Provider factory handles fallback internally if configured
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of all resources and cleanup
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
|
||||
log.info('Disposing AIServiceManager...');
|
||||
this.disposed = true;
|
||||
|
||||
// Stop cleanup timer
|
||||
this.stopCleanupTimer();
|
||||
|
||||
// Dispose provider factory
|
||||
if (this.providerFactory) {
|
||||
this.providerFactory.dispose();
|
||||
this.providerFactory = null;
|
||||
}
|
||||
|
||||
return null;
|
||||
// Dispose all cached services
|
||||
for (const provider of this.serviceCache.keys()) {
|
||||
this.disposeService(provider);
|
||||
}
|
||||
|
||||
log.info('AIServiceManager disposed successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -643,16 +755,36 @@ export class AIServiceManager implements IAIServiceManager {
|
||||
return 'openai';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a service cache entry is stale
|
||||
*/
|
||||
private isServiceStale(entry: ServiceCacheEntry): boolean {
|
||||
const now = Date.now();
|
||||
return now - entry.lastUsed > this.SERVICE_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific provider is available
|
||||
*/
|
||||
isProviderAvailable(provider: string): boolean {
|
||||
// Check if this is the current provider and if it's available
|
||||
if (this.currentProvider === provider && this.currentService) {
|
||||
return this.currentService.isAvailable();
|
||||
// Check health status from provider factory
|
||||
if (this.providerFactory) {
|
||||
const providerTypeMap: Record<string, ProviderType> = {
|
||||
'openai': ProviderType.OPENAI,
|
||||
'anthropic': ProviderType.ANTHROPIC,
|
||||
'ollama': ProviderType.OLLAMA
|
||||
};
|
||||
|
||||
const providerType = providerTypeMap[provider];
|
||||
if (providerType) {
|
||||
const healthStatus = this.providerFactory.getHealthStatus(providerType);
|
||||
if (healthStatus) {
|
||||
return healthStatus.healthy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For other providers, check configuration
|
||||
// Fallback to configuration check
|
||||
try {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
@@ -673,21 +805,43 @@ export class AIServiceManager implements IAIServiceManager {
|
||||
* Get metadata about a provider
|
||||
*/
|
||||
getProviderMetadata(provider: string): ProviderMetadata | null {
|
||||
// Only return metadata if this is the current active provider
|
||||
if (this.currentProvider === provider && this.currentService) {
|
||||
return {
|
||||
name: provider,
|
||||
capabilities: {
|
||||
chat: true,
|
||||
streaming: true,
|
||||
functionCalling: provider === 'openai' // Only OpenAI has function calling
|
||||
},
|
||||
models: ['default'], // Placeholder, could be populated from the service
|
||||
defaultModel: 'default'
|
||||
// Get capabilities from provider factory
|
||||
if (this.providerFactory) {
|
||||
const providerTypeMap: Record<string, ProviderType> = {
|
||||
'openai': ProviderType.OPENAI,
|
||||
'anthropic': ProviderType.ANTHROPIC,
|
||||
'ollama': ProviderType.OLLAMA
|
||||
};
|
||||
|
||||
const providerType = providerTypeMap[provider];
|
||||
if (providerType) {
|
||||
const capabilities = this.providerFactory.getCapabilities(providerType);
|
||||
if (capabilities) {
|
||||
return {
|
||||
name: provider,
|
||||
capabilities: {
|
||||
chat: true,
|
||||
streaming: capabilities.streaming,
|
||||
functionCalling: capabilities.functionCalling
|
||||
},
|
||||
models: ['default'], // Could be enhanced to get actual models
|
||||
defaultModel: 'default'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
// Fallback
|
||||
return {
|
||||
name: provider,
|
||||
capabilities: {
|
||||
chat: true,
|
||||
streaming: true,
|
||||
functionCalling: provider === 'openai'
|
||||
},
|
||||
models: ['default'],
|
||||
defaultModel: 'default'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -706,21 +860,40 @@ export class AIServiceManager implements IAIServiceManager {
|
||||
|
||||
}
|
||||
|
||||
// Don't create singleton immediately, use a lazy-loading pattern
|
||||
// Singleton instance (lazy-loaded) - can be disposed and recreated
|
||||
let instance: AIServiceManager | null = null;
|
||||
|
||||
/**
|
||||
* Get the AIServiceManager instance (creates it if not already created)
|
||||
* Get the AIServiceManager instance (creates it if not already created or disposed)
|
||||
*/
|
||||
function getInstance(): AIServiceManager {
|
||||
if (!instance) {
|
||||
if (!instance || (instance as any).disposed) {
|
||||
instance = new AIServiceManager();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new AIServiceManager instance (for testing or isolated contexts)
|
||||
*/
|
||||
function createNewInstance(): AIServiceManager {
|
||||
return new AIServiceManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the current singleton instance
|
||||
*/
|
||||
async function disposeInstance(): Promise<void> {
|
||||
if (instance) {
|
||||
await instance.dispose();
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getInstance,
|
||||
createNewInstance,
|
||||
disposeInstance,
|
||||
// Also export methods directly for convenience
|
||||
isAnyServiceAvailable(): boolean {
|
||||
return getInstance().isAnyServiceAvailable();
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import options from '../options.js';
|
||||
import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js';
|
||||
import { DEFAULT_SYSTEM_PROMPT } from './constants/llm_prompt_constants.js';
|
||||
import log from '../log.js';
|
||||
|
||||
export abstract class BaseAIService implements AIService {
|
||||
/**
|
||||
* Disposable interface for proper resource cleanup
|
||||
*/
|
||||
export interface Disposable {
|
||||
dispose(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class BaseAIService implements AIService, Disposable {
|
||||
protected name: string;
|
||||
protected disposed: boolean = false;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
@@ -12,6 +21,9 @@ export abstract class BaseAIService implements AIService {
|
||||
abstract generateChatCompletion(messages: Message[], options?: ChatCompletionOptions): Promise<ChatResponse>;
|
||||
|
||||
isAvailable(): boolean {
|
||||
if (this.disposed) {
|
||||
return false;
|
||||
}
|
||||
return options.getOptionBool('aiEnabled'); // Base check if AI is enabled globally
|
||||
}
|
||||
|
||||
@@ -23,4 +35,37 @@ export abstract class BaseAIService implements AIService {
|
||||
// Use prompt from constants file if no custom prompt is provided
|
||||
return customPrompt || DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of any resources held by this service
|
||||
* Override in subclasses to clean up specific resources
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Disposing ${this.name} service`);
|
||||
this.disposed = true;
|
||||
|
||||
// Subclasses should override this to clean up their specific resources
|
||||
await this.disposeResources();
|
||||
}
|
||||
|
||||
/**
|
||||
* Template method for subclasses to implement resource cleanup
|
||||
*/
|
||||
protected async disposeResources(): Promise<void> {
|
||||
// Default implementation does nothing
|
||||
// Subclasses should override to clean up their resources
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the service has been disposed
|
||||
*/
|
||||
protected checkDisposed(): void {
|
||||
if (this.disposed) {
|
||||
throw new Error(`${this.name} service has been disposed and cannot be used`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Enhanced Handler for LLM tool executions with preview, feedback, and error recovery
|
||||
*/
|
||||
import log from "../../../log.js";
|
||||
import type { Message } from "../../ai_interface.js";
|
||||
import type { ToolCall } from "../../tools/tool_interfaces.js";
|
||||
import { toolPreviewManager, type ToolExecutionPlan, type ToolApproval } from "../../tools/tool_preview.js";
|
||||
import { toolFeedbackManager, type ToolExecutionProgress } from "../../tools/tool_feedback.js";
|
||||
import { toolErrorRecoveryManager, type ToolError } from "../../tools/tool_error_recovery.js";
|
||||
|
||||
/**
|
||||
* Tool execution options
|
||||
*/
|
||||
export interface ToolExecutionOptions {
|
||||
requireConfirmation?: boolean;
|
||||
enablePreview?: boolean;
|
||||
enableFeedback?: boolean;
|
||||
enableErrorRecovery?: boolean;
|
||||
timeout?: number;
|
||||
/** Maximum parallel executions (default: 3) */
|
||||
maxConcurrency?: number;
|
||||
/** Enable dependency analysis for parallel execution (default: true) */
|
||||
analyzeDependencies?: boolean;
|
||||
/** Provider for tool execution */
|
||||
provider?: string;
|
||||
/** Custom timeout per tool in ms */
|
||||
customTimeouts?: Map<string, number>;
|
||||
/** Enable caching for read operations */
|
||||
enableCache?: boolean;
|
||||
onPreview?: (plan: ToolExecutionPlan) => Promise<ToolApproval>;
|
||||
onProgress?: (executionId: string, progress: ToolExecutionProgress) => void;
|
||||
onStep?: (executionId: string, step: any) => void;
|
||||
onError?: (executionId: string, error: ToolError) => void;
|
||||
onComplete?: (executionId: string, result: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced tool handler with preview, feedback, and error recovery
|
||||
*/
|
||||
export class EnhancedToolHandler {
|
||||
/**
|
||||
* Execute tool calls with enhanced features
|
||||
*/
|
||||
static async executeToolCalls(
|
||||
response: any,
|
||||
chatNoteId?: string,
|
||||
options: ToolExecutionOptions = {}
|
||||
): Promise<Message[]> {
|
||||
log.info(`========== ENHANCED TOOL EXECUTION FLOW ==========`);
|
||||
|
||||
if (!response.tool_calls || response.tool_calls.length === 0) {
|
||||
log.info(`No tool calls to execute, returning early`);
|
||||
return [];
|
||||
}
|
||||
|
||||
log.info(`Executing ${response.tool_calls.length} tool calls with enhanced features`);
|
||||
|
||||
try {
|
||||
// Import tool registry
|
||||
const toolRegistry = (await import('../../tools/tool_registry.js')).default;
|
||||
|
||||
// Check if tools are available
|
||||
const availableTools = toolRegistry.getAllTools();
|
||||
log.info(`Available tools in registry: ${availableTools.length}`);
|
||||
|
||||
if (availableTools.length === 0) {
|
||||
log.error('No tools available in registry for execution');
|
||||
throw new Error('Tool execution failed: No tools available');
|
||||
}
|
||||
|
||||
// Create handlers map
|
||||
const handlers = new Map<string, any>();
|
||||
for (const toolCall of response.tool_calls) {
|
||||
const tool = toolRegistry.getTool(toolCall.function.name);
|
||||
if (tool) {
|
||||
handlers.set(toolCall.function.name, tool);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Tool Preview
|
||||
let executionPlan: ToolExecutionPlan | undefined;
|
||||
let approval: ToolApproval | undefined;
|
||||
|
||||
if (options.enablePreview !== false) {
|
||||
executionPlan = toolPreviewManager.createExecutionPlan(response.tool_calls, handlers);
|
||||
log.info(`Created execution plan ${executionPlan.id} with ${executionPlan.tools.length} tools`);
|
||||
log.info(`Estimated duration: ${executionPlan.totalEstimatedDuration}ms`);
|
||||
log.info(`Requires confirmation: ${executionPlan.requiresConfirmation}`);
|
||||
|
||||
// Check if confirmation is required
|
||||
if (options.requireConfirmation && executionPlan.requiresConfirmation) {
|
||||
if (options.onPreview) {
|
||||
// Get approval from client
|
||||
approval = await options.onPreview(executionPlan);
|
||||
toolPreviewManager.recordApproval(approval);
|
||||
|
||||
if (!approval.approved) {
|
||||
log.info(`Execution plan ${executionPlan.id} was rejected`);
|
||||
return [{
|
||||
role: 'system',
|
||||
content: 'Tool execution was cancelled by user'
|
||||
}];
|
||||
}
|
||||
} else {
|
||||
// Auto-approve if no preview handler provided
|
||||
approval = {
|
||||
planId: executionPlan.id,
|
||||
approved: true,
|
||||
approvedBy: 'system'
|
||||
};
|
||||
toolPreviewManager.recordApproval(approval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Execute tools with feedback and error recovery
|
||||
const toolResults = await Promise.all(response.tool_calls.map(async (toolCall: ToolCall) => {
|
||||
// Check if this tool was rejected
|
||||
if (approval?.rejectedTools?.includes(toolCall.function.name)) {
|
||||
log.info(`Skipping rejected tool: ${toolCall.function.name}`);
|
||||
return {
|
||||
role: 'tool',
|
||||
content: 'Tool execution was rejected by user',
|
||||
name: toolCall.function.name,
|
||||
tool_call_id: toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
};
|
||||
}
|
||||
|
||||
// Start feedback tracking
|
||||
let executionId: string | undefined;
|
||||
if (options.enableFeedback !== false) {
|
||||
executionId = toolFeedbackManager.startExecution(toolCall, options.timeout);
|
||||
}
|
||||
|
||||
try {
|
||||
log.info(`Executing tool: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
|
||||
|
||||
// Get the tool from registry
|
||||
const tool = toolRegistry.getTool(toolCall.function.name);
|
||||
if (!tool) {
|
||||
const error = `Tool not found: ${toolCall.function.name}`;
|
||||
if (executionId) {
|
||||
toolFeedbackManager.failExecution(executionId, error);
|
||||
}
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
// Parse arguments (with modifications if provided)
|
||||
let args = typeof toolCall.function.arguments === 'string'
|
||||
? JSON.parse(toolCall.function.arguments)
|
||||
: toolCall.function.arguments;
|
||||
|
||||
// Apply parameter modifications from approval if any
|
||||
if (approval?.modifiedParameters?.[toolCall.function.name]) {
|
||||
args = { ...args, ...approval.modifiedParameters[toolCall.function.name] };
|
||||
log.info(`Applied modified parameters for ${toolCall.function.name}`);
|
||||
}
|
||||
|
||||
// Add execution step
|
||||
if (executionId) {
|
||||
toolFeedbackManager.addStep(executionId, {
|
||||
timestamp: new Date(),
|
||||
message: `Starting ${toolCall.function.name} execution`,
|
||||
type: 'info',
|
||||
data: { arguments: args }
|
||||
});
|
||||
|
||||
if (options.onStep) {
|
||||
options.onStep(executionId, {
|
||||
type: 'start',
|
||||
tool: toolCall.function.name,
|
||||
arguments: args
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Execute with error recovery if enabled
|
||||
let result: any;
|
||||
let executionTime: number;
|
||||
|
||||
if (options.enableErrorRecovery !== false) {
|
||||
const executionResult = await toolErrorRecoveryManager.executeWithRecovery(
|
||||
{ ...toolCall, function: { ...toolCall.function, arguments: args } },
|
||||
tool,
|
||||
(attempt, delay) => {
|
||||
if (executionId) {
|
||||
toolFeedbackManager.addStep(executionId, {
|
||||
timestamp: new Date(),
|
||||
message: `Retry attempt ${attempt} after ${delay}ms`,
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
if (options.onProgress) {
|
||||
options.onProgress(executionId, {
|
||||
current: attempt,
|
||||
total: 3,
|
||||
percentage: (attempt / 3) * 100,
|
||||
message: `Retrying...`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!executionResult.success) {
|
||||
const error = executionResult.error;
|
||||
if (executionId) {
|
||||
toolFeedbackManager.failExecution(executionId, error?.message || 'Unknown error');
|
||||
}
|
||||
|
||||
if (options.onError && executionId && error) {
|
||||
options.onError(executionId, error);
|
||||
}
|
||||
|
||||
// Suggest recovery actions
|
||||
if (error) {
|
||||
const recoveryActions = toolErrorRecoveryManager.suggestRecoveryActions(
|
||||
toolCall.function.name,
|
||||
error,
|
||||
args
|
||||
);
|
||||
log.info(`Recovery suggestions: ${recoveryActions.map(a => a.description).join(', ')}`);
|
||||
}
|
||||
|
||||
throw new Error(error?.userMessage || error?.message || 'Tool execution failed');
|
||||
}
|
||||
|
||||
result = executionResult.data;
|
||||
executionTime = executionResult.totalDuration;
|
||||
|
||||
if (executionResult.recovered) {
|
||||
log.info(`Tool ${toolCall.function.name} recovered after ${executionResult.attempts} attempts`);
|
||||
}
|
||||
} else {
|
||||
// Direct execution without error recovery
|
||||
const startTime = Date.now();
|
||||
result = await tool.execute(args);
|
||||
executionTime = Date.now() - startTime;
|
||||
}
|
||||
|
||||
// Complete feedback tracking
|
||||
if (executionId) {
|
||||
toolFeedbackManager.completeExecution(executionId, result);
|
||||
|
||||
if (options.onComplete) {
|
||||
options.onComplete(executionId, result);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Tool execution completed in ${executionTime}ms`);
|
||||
|
||||
// Log the result preview
|
||||
const resultPreview = typeof result === 'string'
|
||||
? result.substring(0, 100) + (result.length > 100 ? '...' : '')
|
||||
: JSON.stringify(result).substring(0, 100) + '...';
|
||||
log.info(`Tool result: ${resultPreview}`);
|
||||
|
||||
// Format result as a proper message
|
||||
return {
|
||||
role: 'tool',
|
||||
content: typeof result === 'string' ? result : JSON.stringify(result),
|
||||
name: toolCall.function.name,
|
||||
tool_call_id: toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing tool ${toolCall.function.name}: ${error.message}`);
|
||||
|
||||
// Fail execution tracking
|
||||
if (executionId) {
|
||||
toolFeedbackManager.failExecution(executionId, error.message);
|
||||
}
|
||||
|
||||
// Categorize error for better reporting
|
||||
const categorizedError = toolErrorRecoveryManager.categorizeError(error);
|
||||
|
||||
if (options.onError && executionId) {
|
||||
options.onError(executionId, categorizedError);
|
||||
}
|
||||
|
||||
// Return error as tool result
|
||||
return {
|
||||
role: 'tool',
|
||||
content: categorizedError.userMessage || `Error: ${error.message}`,
|
||||
name: toolCall.function.name,
|
||||
tool_call_id: toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
log.info(`Completed execution of ${toolResults.length} tools`);
|
||||
|
||||
// Get execution statistics if feedback is enabled
|
||||
if (options.enableFeedback !== false) {
|
||||
const stats = toolFeedbackManager.getStatistics();
|
||||
log.info(`Execution statistics: ${stats.successfulExecutions} successful, ${stats.failedExecutions} failed`);
|
||||
}
|
||||
|
||||
return toolResults;
|
||||
|
||||
} catch (error: any) {
|
||||
log.error(`Error in enhanced tool execution handler: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool execution history
|
||||
*/
|
||||
static getExecutionHistory(filter?: any) {
|
||||
return toolFeedbackManager.getHistory(filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool execution statistics
|
||||
*/
|
||||
static getExecutionStatistics() {
|
||||
return toolFeedbackManager.getStatistics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running tool execution
|
||||
*/
|
||||
static cancelExecution(executionId: string, reason?: string): boolean {
|
||||
return toolFeedbackManager.cancelExecution(executionId, 'user', reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active tool executions
|
||||
*/
|
||||
static getActiveExecutions() {
|
||||
return toolFeedbackManager.getActiveExecutions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old execution data
|
||||
*/
|
||||
static cleanup() {
|
||||
toolPreviewManager.cleanup();
|
||||
toolFeedbackManager.clear();
|
||||
toolErrorRecoveryManager.clearHistory();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@
|
||||
*/
|
||||
import log from "../../../log.js";
|
||||
import type { Message } from "../../ai_interface.js";
|
||||
import { toolPreviewManager } from "../../tools/tool_preview.js";
|
||||
import { toolFeedbackManager } from "../../tools/tool_feedback.js";
|
||||
import { toolErrorRecoveryManager } from "../../tools/tool_error_recovery.js";
|
||||
import { toolTimeoutEnforcer } from "../../tools/tool_timeout_enforcer.js";
|
||||
import { parameterCoercer } from "../../tools/parameter_coercer.js";
|
||||
import { toolExecutionMonitor } from "../../monitoring/tool_execution_monitor.js";
|
||||
|
||||
/**
|
||||
* Handles the execution of LLM tools
|
||||
@@ -12,8 +18,17 @@ export class ToolHandler {
|
||||
* Execute tool calls from the LLM response
|
||||
* @param response The LLM response containing tool calls
|
||||
* @param chatNoteId Optional chat note ID for tracking
|
||||
* @param options Execution options
|
||||
*/
|
||||
static async executeToolCalls(response: any, chatNoteId?: string): Promise<Message[]> {
|
||||
static async executeToolCalls(
|
||||
response: any,
|
||||
chatNoteId?: string,
|
||||
options?: {
|
||||
requireConfirmation?: boolean;
|
||||
onProgress?: (executionId: string, progress: any) => void;
|
||||
onError?: (executionId: string, error: any) => void;
|
||||
}
|
||||
): Promise<Message[]> {
|
||||
log.info(`========== TOOL EXECUTION FLOW ==========`);
|
||||
if (!response.tool_calls || response.tool_calls.length === 0) {
|
||||
log.info(`No tool calls to execute, returning early`);
|
||||
|
||||
@@ -6,7 +6,7 @@ import log from "../../log.js";
|
||||
import type { Request, Response } from "express";
|
||||
import type { Message, ChatCompletionOptions } from "../ai_interface.js";
|
||||
import aiServiceManager from "../ai_service_manager.js";
|
||||
import { ChatPipeline } from "../pipeline/chat_pipeline.js";
|
||||
import { ChatPipeline } from "../pipeline/pipeline_adapter.js";
|
||||
import type { ChatPipelineInput } from "../pipeline/interfaces.js";
|
||||
import options from "../../options.js";
|
||||
import { ToolHandler } from "./handlers/tool_handler.js";
|
||||
|
||||
@@ -1,861 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ChatService } from './chat_service.js';
|
||||
import type { Message, ChatCompletionOptions } from './ai_interface.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./chat_storage_service.js', () => ({
|
||||
default: {
|
||||
createChat: vi.fn(),
|
||||
getChat: vi.fn(),
|
||||
updateChat: vi.fn(),
|
||||
deleteChat: vi.fn(),
|
||||
getAllChats: vi.fn(),
|
||||
recordSources: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('./constants/llm_prompt_constants.js', () => ({
|
||||
CONTEXT_PROMPTS: {
|
||||
NOTE_CONTEXT_PROMPT: 'Context: {context}',
|
||||
SEMANTIC_NOTE_CONTEXT_PROMPT: 'Query: {query}\nContext: {context}'
|
||||
},
|
||||
ERROR_PROMPTS: {
|
||||
USER_ERRORS: {
|
||||
GENERAL_ERROR: 'Sorry, I encountered an error processing your request.',
|
||||
CONTEXT_ERROR: 'Sorry, I encountered an error processing the context.'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('./pipeline/chat_pipeline.js', () => ({
|
||||
ChatPipeline: vi.fn().mockImplementation((config) => ({
|
||||
config,
|
||||
execute: vi.fn(),
|
||||
getMetrics: vi.fn(),
|
||||
resetMetrics: vi.fn(),
|
||||
stages: {
|
||||
contextExtraction: {
|
||||
execute: vi.fn()
|
||||
},
|
||||
semanticContextExtraction: {
|
||||
execute: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('./ai_service_manager.js', () => ({
|
||||
default: {
|
||||
getService: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('ChatService', () => {
|
||||
let chatService: ChatService;
|
||||
let mockChatStorageService: any;
|
||||
let mockAiServiceManager: any;
|
||||
let mockChatPipeline: any;
|
||||
let mockLog: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Get mocked modules
|
||||
mockChatStorageService = (await import('./chat_storage_service.js')).default;
|
||||
mockAiServiceManager = (await import('./ai_service_manager.js')).default;
|
||||
mockLog = (await import('../log.js')).default;
|
||||
|
||||
// Setup pipeline mock
|
||||
mockChatPipeline = {
|
||||
execute: vi.fn(),
|
||||
getMetrics: vi.fn(),
|
||||
resetMetrics: vi.fn(),
|
||||
stages: {
|
||||
contextExtraction: {
|
||||
execute: vi.fn()
|
||||
},
|
||||
semanticContextExtraction: {
|
||||
execute: vi.fn()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new ChatService instance
|
||||
chatService = new ChatService();
|
||||
|
||||
// Replace the internal pipelines with our mock
|
||||
(chatService as any).pipelines.set('default', mockChatPipeline);
|
||||
(chatService as any).pipelines.set('agent', mockChatPipeline);
|
||||
(chatService as any).pipelines.set('performance', mockChatPipeline);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with default pipelines', () => {
|
||||
expect(chatService).toBeDefined();
|
||||
// Verify pipelines are created by checking internal state
|
||||
expect((chatService as any).pipelines).toBeDefined();
|
||||
expect((chatService as any).sessionCache).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a new chat session with default title', async () => {
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.createChat.mockResolvedValueOnce(mockChat);
|
||||
|
||||
const session = await chatService.createSession();
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-123',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.createChat).toHaveBeenCalledWith('New Chat', []);
|
||||
});
|
||||
|
||||
it('should create a new chat session with custom title and messages', async () => {
|
||||
const initialMessages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' }
|
||||
];
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-456',
|
||||
title: 'Custom Chat',
|
||||
messages: initialMessages,
|
||||
noteId: 'chat-456',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.createChat.mockResolvedValueOnce(mockChat);
|
||||
|
||||
const session = await chatService.createSession('Custom Chat', initialMessages);
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-456',
|
||||
title: 'Custom Chat',
|
||||
messages: initialMessages,
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.createChat).toHaveBeenCalledWith('Custom Chat', initialMessages);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrCreateSession', () => {
|
||||
it('should return cached session if available', async () => {
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
const cachedSession = {
|
||||
id: 'chat-123',
|
||||
title: 'Old Title',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
// Pre-populate cache
|
||||
(chatService as any).sessionCache.set('chat-123', cachedSession);
|
||||
mockChatStorageService.getChat.mockResolvedValueOnce(mockChat);
|
||||
|
||||
const session = await chatService.getOrCreateSession('chat-123');
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat', // Should be updated from storage
|
||||
messages: [{ role: 'user', content: 'Hello' }], // Should be updated from storage
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.getChat).toHaveBeenCalledWith('chat-123');
|
||||
});
|
||||
|
||||
it('should load session from storage if not cached', async () => {
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValueOnce(mockChat);
|
||||
|
||||
const session = await chatService.getOrCreateSession('chat-123');
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.getChat).toHaveBeenCalledWith('chat-123');
|
||||
});
|
||||
|
||||
it('should create new session if not found', async () => {
|
||||
mockChatStorageService.getChat.mockResolvedValueOnce(null);
|
||||
|
||||
const mockNewChat = {
|
||||
id: 'chat-new',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-new',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.createChat.mockResolvedValueOnce(mockNewChat);
|
||||
|
||||
const session = await chatService.getOrCreateSession('nonexistent');
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-new',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.getChat).toHaveBeenCalledWith('nonexistent');
|
||||
expect(mockChatStorageService.createChat).toHaveBeenCalledWith('New Chat', []);
|
||||
});
|
||||
|
||||
it('should create new session when no sessionId provided', async () => {
|
||||
const mockNewChat = {
|
||||
id: 'chat-new',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-new',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.createChat.mockResolvedValueOnce(mockNewChat);
|
||||
|
||||
const session = await chatService.getOrCreateSession();
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-new',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.createChat).toHaveBeenCalledWith('New Chat', []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessage', () => {
|
||||
beforeEach(() => {
|
||||
const mockSession = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValue(mockChat);
|
||||
mockChatStorageService.updateChat.mockResolvedValue(mockChat);
|
||||
|
||||
mockChatPipeline.execute.mockResolvedValue({
|
||||
text: 'Hello! How can I help you?',
|
||||
model: 'gpt-3.5-turbo',
|
||||
provider: 'OpenAI',
|
||||
usage: { promptTokens: 10, completionTokens: 8, totalTokens: 18 }
|
||||
});
|
||||
});
|
||||
|
||||
it('should send message and get AI response', async () => {
|
||||
const session = await chatService.sendMessage('chat-123', 'Hello');
|
||||
|
||||
expect(session.messages).toHaveLength(2);
|
||||
expect(session.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: 'Hello'
|
||||
});
|
||||
expect(session.messages[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'Hello! How can I help you?',
|
||||
tool_calls: undefined
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.updateChat).toHaveBeenCalledTimes(2); // Once for user message, once for complete conversation
|
||||
expect(mockChatPipeline.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle streaming callback', async () => {
|
||||
const streamCallback = vi.fn();
|
||||
|
||||
await chatService.sendMessage('chat-123', 'Hello', {}, streamCallback);
|
||||
|
||||
expect(mockChatPipeline.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
streamCallback
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should update title for first message', async () => {
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValue(mockChat);
|
||||
|
||||
await chatService.sendMessage('chat-123', 'What is the weather like?');
|
||||
|
||||
// Should update title based on first message
|
||||
expect(mockChatStorageService.updateChat).toHaveBeenLastCalledWith(
|
||||
'chat-123',
|
||||
expect.any(Array),
|
||||
'What is the weather like?'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockChatPipeline.execute.mockRejectedValueOnce(new Error('AI service error'));
|
||||
|
||||
const session = await chatService.sendMessage('chat-123', 'Hello');
|
||||
|
||||
expect(session.messages).toHaveLength(2);
|
||||
expect(session.messages[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error processing your request.'
|
||||
});
|
||||
|
||||
expect(session.isStreaming).toBe(false);
|
||||
expect(mockChatStorageService.updateChat).toHaveBeenCalledWith(
|
||||
'chat-123',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error processing your request.'
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tool calls in response', async () => {
|
||||
const toolCalls = [{
|
||||
id: 'call_123',
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'searchNotes',
|
||||
arguments: '{"query": "test"}'
|
||||
}
|
||||
}];
|
||||
|
||||
mockChatPipeline.execute.mockResolvedValueOnce({
|
||||
text: 'I need to search for notes.',
|
||||
model: 'gpt-4',
|
||||
provider: 'OpenAI',
|
||||
tool_calls: toolCalls,
|
||||
usage: { promptTokens: 10, completionTokens: 8, totalTokens: 18 }
|
||||
});
|
||||
|
||||
const session = await chatService.sendMessage('chat-123', 'Search for notes about AI');
|
||||
|
||||
expect(session.messages[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'I need to search for notes.',
|
||||
tool_calls: toolCalls
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendContextAwareMessage', () => {
|
||||
beforeEach(() => {
|
||||
const mockSession = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValue(mockChat);
|
||||
mockChatStorageService.updateChat.mockResolvedValue(mockChat);
|
||||
|
||||
mockChatPipeline.execute.mockResolvedValue({
|
||||
text: 'Based on the context, here is my response.',
|
||||
model: 'gpt-4',
|
||||
provider: 'OpenAI',
|
||||
usage: { promptTokens: 20, completionTokens: 15, totalTokens: 35 }
|
||||
});
|
||||
});
|
||||
|
||||
it('should send context-aware message with note ID', async () => {
|
||||
const session = await chatService.sendContextAwareMessage(
|
||||
'chat-123',
|
||||
'What is this note about?',
|
||||
'note-456'
|
||||
);
|
||||
|
||||
expect(session.messages).toHaveLength(2);
|
||||
expect(session.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: 'What is this note about?'
|
||||
});
|
||||
|
||||
expect(mockChatPipeline.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
noteId: 'note-456',
|
||||
query: 'What is this note about?',
|
||||
showThinking: false
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockChatStorageService.updateChat).toHaveBeenLastCalledWith(
|
||||
'chat-123',
|
||||
expect.any(Array),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
contextNoteId: 'note-456'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use agent pipeline when showThinking is enabled', async () => {
|
||||
await chatService.sendContextAwareMessage(
|
||||
'chat-123',
|
||||
'Analyze this note',
|
||||
'note-456',
|
||||
{ showThinking: true }
|
||||
);
|
||||
|
||||
expect(mockChatPipeline.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
showThinking: true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors in context-aware messages', async () => {
|
||||
mockChatPipeline.execute.mockRejectedValueOnce(new Error('Context error'));
|
||||
|
||||
const session = await chatService.sendContextAwareMessage(
|
||||
'chat-123',
|
||||
'What is this note about?',
|
||||
'note-456'
|
||||
);
|
||||
|
||||
expect(session.messages[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error processing the context.'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addNoteContext', () => {
|
||||
it('should add note context to session', async () => {
|
||||
const mockSession = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Tell me about AI features' }
|
||||
],
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: mockSession.messages,
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValue(mockChat);
|
||||
mockChatStorageService.updateChat.mockResolvedValue(mockChat);
|
||||
|
||||
// Mock the pipeline's context extraction stage
|
||||
mockChatPipeline.stages.contextExtraction.execute.mockResolvedValue({
|
||||
context: 'This note contains information about AI features...',
|
||||
sources: [
|
||||
{
|
||||
noteId: 'note-456',
|
||||
title: 'AI Features',
|
||||
similarity: 0.95,
|
||||
content: 'AI features content'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const session = await chatService.addNoteContext('chat-123', 'note-456');
|
||||
|
||||
expect(session.messages).toHaveLength(2);
|
||||
expect(session.messages[1]).toEqual({
|
||||
role: 'user',
|
||||
content: 'Context: This note contains information about AI features...'
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.recordSources).toHaveBeenCalledWith(
|
||||
'chat-123',
|
||||
[expect.objectContaining({
|
||||
noteId: 'note-456',
|
||||
title: 'AI Features',
|
||||
similarity: 0.95,
|
||||
content: 'AI features content'
|
||||
})]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addSemanticNoteContext', () => {
|
||||
it('should add semantic note context to session', async () => {
|
||||
const mockSession = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValue(mockChat);
|
||||
mockChatStorageService.updateChat.mockResolvedValue(mockChat);
|
||||
|
||||
mockChatPipeline.stages.semanticContextExtraction.execute.mockResolvedValue({
|
||||
context: 'Semantic context about machine learning...',
|
||||
sources: []
|
||||
});
|
||||
|
||||
const session = await chatService.addSemanticNoteContext(
|
||||
'chat-123',
|
||||
'note-456',
|
||||
'machine learning algorithms'
|
||||
);
|
||||
|
||||
expect(session.messages).toHaveLength(1);
|
||||
expect(session.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: 'Query: machine learning algorithms\nContext: Semantic context about machine learning...'
|
||||
});
|
||||
|
||||
expect(mockChatPipeline.stages.semanticContextExtraction.execute).toHaveBeenCalledWith({
|
||||
noteId: 'note-456',
|
||||
query: 'machine learning algorithms'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllSessions', () => {
|
||||
it('should return all chat sessions', async () => {
|
||||
const mockChats = [
|
||||
{
|
||||
id: 'chat-1',
|
||||
title: 'Chat 1',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
noteId: 'chat-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
},
|
||||
{
|
||||
id: 'chat-2',
|
||||
title: 'Chat 2',
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
noteId: 'chat-2',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
}
|
||||
];
|
||||
|
||||
mockChatStorageService.getAllChats.mockResolvedValue(mockChats);
|
||||
|
||||
const sessions = await chatService.getAllSessions();
|
||||
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0]).toEqual({
|
||||
id: 'chat-1',
|
||||
title: 'Chat 1',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
isStreaming: false
|
||||
});
|
||||
expect(sessions[1]).toEqual({
|
||||
id: 'chat-2',
|
||||
title: 'Chat 2',
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
isStreaming: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should update cached sessions with latest data', async () => {
|
||||
const mockChats = [
|
||||
{
|
||||
id: 'chat-1',
|
||||
title: 'Updated Title',
|
||||
messages: [{ role: 'user', content: 'Updated message' }],
|
||||
noteId: 'chat-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
}
|
||||
];
|
||||
|
||||
// Pre-populate cache with old data
|
||||
(chatService as any).sessionCache.set('chat-1', {
|
||||
id: 'chat-1',
|
||||
title: 'Old Title',
|
||||
messages: [{ role: 'user', content: 'Old message' }],
|
||||
isStreaming: true
|
||||
});
|
||||
|
||||
mockChatStorageService.getAllChats.mockResolvedValue(mockChats);
|
||||
|
||||
const sessions = await chatService.getAllSessions();
|
||||
|
||||
expect(sessions[0]).toEqual({
|
||||
id: 'chat-1',
|
||||
title: 'Updated Title',
|
||||
messages: [{ role: 'user', content: 'Updated message' }],
|
||||
isStreaming: true // Should preserve streaming state
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSession', () => {
|
||||
it('should delete session from cache and storage', async () => {
|
||||
// Pre-populate cache
|
||||
(chatService as any).sessionCache.set('chat-123', {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
mockChatStorageService.deleteChat.mockResolvedValue(true);
|
||||
|
||||
const result = await chatService.deleteSession('chat-123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect((chatService as any).sessionCache.has('chat-123')).toBe(false);
|
||||
expect(mockChatStorageService.deleteChat).toHaveBeenCalledWith('chat-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateChatCompletion', () => {
|
||||
it('should use AI service directly for simple completion', async () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' }
|
||||
];
|
||||
|
||||
const mockService = {
|
||||
getName: () => 'OpenAI',
|
||||
generateChatCompletion: vi.fn().mockResolvedValue({
|
||||
text: 'Hello! How can I help?',
|
||||
model: 'gpt-3.5-turbo',
|
||||
provider: 'OpenAI'
|
||||
})
|
||||
};
|
||||
|
||||
mockAiServiceManager.getService.mockResolvedValue(mockService);
|
||||
|
||||
const result = await chatService.generateChatCompletion(messages);
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Hello! How can I help?',
|
||||
model: 'gpt-3.5-turbo',
|
||||
provider: 'OpenAI'
|
||||
});
|
||||
|
||||
expect(mockService.generateChatCompletion).toHaveBeenCalledWith(messages, {});
|
||||
});
|
||||
|
||||
it('should use pipeline for advanced context', async () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' }
|
||||
];
|
||||
|
||||
const options = {
|
||||
useAdvancedContext: true,
|
||||
noteId: 'note-123'
|
||||
};
|
||||
|
||||
// Mock AI service for this test
|
||||
const mockService = {
|
||||
getName: () => 'OpenAI',
|
||||
generateChatCompletion: vi.fn()
|
||||
};
|
||||
mockAiServiceManager.getService.mockResolvedValue(mockService);
|
||||
|
||||
mockChatPipeline.execute.mockResolvedValue({
|
||||
text: 'Response with context',
|
||||
model: 'gpt-4',
|
||||
provider: 'OpenAI',
|
||||
tool_calls: []
|
||||
});
|
||||
|
||||
const result = await chatService.generateChatCompletion(messages, options);
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Response with context',
|
||||
model: 'gpt-4',
|
||||
provider: 'OpenAI',
|
||||
tool_calls: []
|
||||
});
|
||||
|
||||
expect(mockChatPipeline.execute).toHaveBeenCalledWith({
|
||||
messages,
|
||||
options,
|
||||
query: 'Hello',
|
||||
noteId: 'note-123'
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when no AI service available', async () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' }
|
||||
];
|
||||
|
||||
mockAiServiceManager.getService.mockResolvedValue(null);
|
||||
|
||||
await expect(chatService.generateChatCompletion(messages)).rejects.toThrow(
|
||||
'No AI service available'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipeline metrics', () => {
|
||||
it('should get pipeline metrics', () => {
|
||||
mockChatPipeline.getMetrics.mockReturnValue({ requestCount: 5 });
|
||||
|
||||
const metrics = chatService.getPipelineMetrics();
|
||||
|
||||
expect(metrics).toEqual({ requestCount: 5 });
|
||||
expect(mockChatPipeline.getMetrics).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset pipeline metrics', () => {
|
||||
chatService.resetPipelineMetrics();
|
||||
|
||||
expect(mockChatPipeline.resetMetrics).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle different pipeline types', () => {
|
||||
mockChatPipeline.getMetrics.mockReturnValue({ requestCount: 3 });
|
||||
|
||||
const metrics = chatService.getPipelineMetrics('agent');
|
||||
|
||||
expect(metrics).toEqual({ requestCount: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTitleFromMessages', () => {
|
||||
it('should generate title from first user message', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'What is machine learning?' },
|
||||
{ role: 'assistant', content: 'Machine learning is...' }
|
||||
];
|
||||
|
||||
// Access private method for testing
|
||||
const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService);
|
||||
const title = generateTitle(messages);
|
||||
|
||||
expect(title).toBe('What is machine learning?');
|
||||
});
|
||||
|
||||
it('should truncate long titles', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'This is a very long message that should be truncated because it exceeds the maximum length' },
|
||||
{ role: 'assistant', content: 'Response' }
|
||||
];
|
||||
|
||||
const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService);
|
||||
const title = generateTitle(messages);
|
||||
|
||||
expect(title).toBe('This is a very long message...');
|
||||
expect(title.length).toBe(30);
|
||||
});
|
||||
|
||||
it('should return default title for empty or invalid messages', () => {
|
||||
const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService);
|
||||
|
||||
expect(generateTitle([])).toBe('New Chat');
|
||||
expect(generateTitle([{ role: 'assistant', content: 'Hello' }])).toBe('New Chat');
|
||||
});
|
||||
|
||||
it('should use first line for multiline messages', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'First line\nSecond line\nThird line' },
|
||||
{ role: 'assistant', content: 'Response' }
|
||||
];
|
||||
|
||||
const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService);
|
||||
const title = generateTitle(messages);
|
||||
|
||||
expect(title).toBe('First line');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,595 +0,0 @@
|
||||
import type { Message, ChatCompletionOptions, ChatResponse } from './ai_interface.js';
|
||||
import chatStorageService from './chat_storage_service.js';
|
||||
import log from '../log.js';
|
||||
import { CONTEXT_PROMPTS, ERROR_PROMPTS } from './constants/llm_prompt_constants.js';
|
||||
import { ChatPipeline } from './pipeline/chat_pipeline.js';
|
||||
import type { ChatPipelineConfig, StreamCallback } from './pipeline/interfaces.js';
|
||||
import aiServiceManager from './ai_service_manager.js';
|
||||
import type { ChatPipelineInput } from './pipeline/interfaces.js';
|
||||
import type { NoteSearchResult } from './interfaces/context_interfaces.js';
|
||||
|
||||
// Update the ChatCompletionOptions interface to include the missing properties
|
||||
declare module './ai_interface.js' {
|
||||
interface ChatCompletionOptions {
|
||||
pipeline?: string;
|
||||
noteId?: string;
|
||||
useAdvancedContext?: boolean;
|
||||
showThinking?: boolean;
|
||||
enableTools?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Add a type for context extraction result
|
||||
interface ContextExtractionResult {
|
||||
context: string;
|
||||
sources?: NoteSearchResult[];
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
isStreaming?: boolean;
|
||||
options?: ChatCompletionOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat pipeline configurations for different use cases
|
||||
*/
|
||||
const PIPELINE_CONFIGS: Record<string, Partial<ChatPipelineConfig>> = {
|
||||
default: {
|
||||
enableStreaming: true,
|
||||
enableMetrics: true
|
||||
},
|
||||
agent: {
|
||||
enableStreaming: true,
|
||||
enableMetrics: true,
|
||||
maxToolCallIterations: 5
|
||||
},
|
||||
performance: {
|
||||
enableStreaming: false,
|
||||
enableMetrics: true
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Service for managing chat interactions and history
|
||||
*/
|
||||
export class ChatService {
|
||||
private sessionCache: Map<string, ChatSession> = new Map();
|
||||
private pipelines: Map<string, ChatPipeline> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Initialize pipelines
|
||||
Object.entries(PIPELINE_CONFIGS).forEach(([name, config]) => {
|
||||
this.pipelines.set(name, new ChatPipeline(config));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pipeline by name, or the default one
|
||||
*/
|
||||
private getPipeline(name: string = 'default'): ChatPipeline {
|
||||
return this.pipelines.get(name) || this.pipelines.get('default')!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat session
|
||||
*/
|
||||
async createSession(title?: string, initialMessages: Message[] = []): Promise<ChatSession> {
|
||||
// Create a new Chat Note as the source of truth
|
||||
const chat = await chatStorageService.createChat(title || 'New Chat', initialMessages);
|
||||
|
||||
const session: ChatSession = {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
messages: chat.messages,
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
// Session is just a cache now
|
||||
this.sessionCache.set(chat.id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing session or create a new one
|
||||
*/
|
||||
async getOrCreateSession(sessionId?: string): Promise<ChatSession> {
|
||||
if (sessionId) {
|
||||
// First check the cache
|
||||
const cachedSession = this.sessionCache.get(sessionId);
|
||||
if (cachedSession) {
|
||||
// Refresh the data from the source of truth
|
||||
const chat = await chatStorageService.getChat(sessionId);
|
||||
if (chat) {
|
||||
// Update the cached session with latest data from the note
|
||||
cachedSession.title = chat.title;
|
||||
cachedSession.messages = chat.messages;
|
||||
return cachedSession;
|
||||
}
|
||||
} else {
|
||||
// Not in cache, load from the chat note
|
||||
const chat = await chatStorageService.getChat(sessionId);
|
||||
if (chat) {
|
||||
const session: ChatSession = {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
messages: chat.messages,
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
this.sessionCache.set(chat.id, session);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.createSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message in a chat session and get the AI response
|
||||
*/
|
||||
async sendMessage(
|
||||
sessionId: string,
|
||||
content: string,
|
||||
options?: ChatCompletionOptions,
|
||||
streamCallback?: StreamCallback
|
||||
): Promise<ChatSession> {
|
||||
const session = await this.getOrCreateSession(sessionId);
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content
|
||||
};
|
||||
|
||||
session.messages.push(userMessage);
|
||||
session.isStreaming = true;
|
||||
|
||||
try {
|
||||
// Immediately save the user message
|
||||
await chatStorageService.updateChat(session.id, session.messages);
|
||||
|
||||
// Log message processing
|
||||
log.info(`Processing message: "${content.substring(0, 100)}..."`);
|
||||
|
||||
// Select pipeline to use
|
||||
const pipeline = this.getPipeline();
|
||||
|
||||
// Include sessionId in the options for tool execution tracking
|
||||
const pipelineOptions = {
|
||||
...(options || session.options || {}),
|
||||
sessionId: session.id
|
||||
};
|
||||
|
||||
// Execute the pipeline
|
||||
const response = await pipeline.execute({
|
||||
messages: session.messages,
|
||||
options: pipelineOptions,
|
||||
query: content,
|
||||
streamCallback
|
||||
});
|
||||
|
||||
// Add assistant message
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: response.text,
|
||||
tool_calls: response.tool_calls
|
||||
};
|
||||
|
||||
session.messages.push(assistantMessage);
|
||||
session.isStreaming = false;
|
||||
|
||||
// Save metadata about the response
|
||||
const metadata = {
|
||||
model: response.model,
|
||||
provider: response.provider,
|
||||
usage: response.usage
|
||||
};
|
||||
|
||||
// If there are tool calls, make sure they're stored in metadata
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
// Let the storage service extract and save tool executions
|
||||
// The tool results are already in the messages
|
||||
}
|
||||
|
||||
// Save the complete conversation with metadata
|
||||
await chatStorageService.updateChat(session.id, session.messages, undefined, metadata);
|
||||
|
||||
// If first message, update the title based on content
|
||||
if (session.messages.length <= 2 && (!session.title || session.title === 'New Chat')) {
|
||||
const title = this.generateTitleFromMessages(session.messages);
|
||||
session.title = title;
|
||||
await chatStorageService.updateChat(session.id, session.messages, title);
|
||||
}
|
||||
|
||||
return session;
|
||||
|
||||
} catch (error: unknown) {
|
||||
session.isStreaming = false;
|
||||
console.error('Error in AI chat:', this.handleError(error));
|
||||
|
||||
// Add error message
|
||||
const errorMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: ERROR_PROMPTS.USER_ERRORS.GENERAL_ERROR
|
||||
};
|
||||
|
||||
session.messages.push(errorMessage);
|
||||
|
||||
// Save the conversation with error
|
||||
await chatStorageService.updateChat(session.id, session.messages);
|
||||
|
||||
// Notify streaming error if callback provided
|
||||
if (streamCallback) {
|
||||
streamCallback(errorMessage.content, true);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message with context from a specific note
|
||||
*/
|
||||
async sendContextAwareMessage(
|
||||
sessionId: string,
|
||||
content: string,
|
||||
noteId: string,
|
||||
options?: ChatCompletionOptions,
|
||||
streamCallback?: StreamCallback
|
||||
): Promise<ChatSession> {
|
||||
const session = await this.getOrCreateSession(sessionId);
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content
|
||||
};
|
||||
|
||||
session.messages.push(userMessage);
|
||||
session.isStreaming = true;
|
||||
|
||||
try {
|
||||
// Immediately save the user message
|
||||
await chatStorageService.updateChat(session.id, session.messages);
|
||||
|
||||
// Log message processing
|
||||
log.info(`Processing context-aware message: "${content.substring(0, 100)}..."`);
|
||||
log.info(`Using context from note: ${noteId}`);
|
||||
|
||||
// Get showThinking option if it exists
|
||||
const showThinking = options?.showThinking === true;
|
||||
|
||||
// Select appropriate pipeline based on whether agent tools are needed
|
||||
const pipelineType = showThinking ? 'agent' : 'default';
|
||||
const pipeline = this.getPipeline(pipelineType);
|
||||
|
||||
// Include sessionId in the options for tool execution tracking
|
||||
const pipelineOptions = {
|
||||
...(options || session.options || {}),
|
||||
sessionId: session.id
|
||||
};
|
||||
|
||||
// Execute the pipeline with note context
|
||||
const response = await pipeline.execute({
|
||||
messages: session.messages,
|
||||
options: pipelineOptions,
|
||||
noteId,
|
||||
query: content,
|
||||
showThinking,
|
||||
streamCallback
|
||||
});
|
||||
|
||||
// Add assistant message
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: response.text,
|
||||
tool_calls: response.tool_calls
|
||||
};
|
||||
|
||||
session.messages.push(assistantMessage);
|
||||
session.isStreaming = false;
|
||||
|
||||
// Save metadata about the response
|
||||
const metadata = {
|
||||
model: response.model,
|
||||
provider: response.provider,
|
||||
usage: response.usage,
|
||||
contextNoteId: noteId // Store the note ID used for context
|
||||
};
|
||||
|
||||
// If there are tool calls, make sure they're stored in metadata
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
// Let the storage service extract and save tool executions
|
||||
// The tool results are already in the messages
|
||||
}
|
||||
|
||||
// Save the complete conversation with metadata to the Chat Note (the single source of truth)
|
||||
await chatStorageService.updateChat(session.id, session.messages, undefined, metadata);
|
||||
|
||||
// If first message, update the title
|
||||
if (session.messages.length <= 2 && (!session.title || session.title === 'New Chat')) {
|
||||
const title = this.generateTitleFromMessages(session.messages);
|
||||
session.title = title;
|
||||
await chatStorageService.updateChat(session.id, session.messages, title);
|
||||
}
|
||||
|
||||
return session;
|
||||
|
||||
} catch (error: unknown) {
|
||||
session.isStreaming = false;
|
||||
console.error('Error in context-aware chat:', this.handleError(error));
|
||||
|
||||
// Add error message
|
||||
const errorMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: ERROR_PROMPTS.USER_ERRORS.CONTEXT_ERROR
|
||||
};
|
||||
|
||||
session.messages.push(errorMessage);
|
||||
|
||||
// Save the conversation with error to the Chat Note
|
||||
await chatStorageService.updateChat(session.id, session.messages);
|
||||
|
||||
// Notify streaming error if callback provided
|
||||
if (streamCallback) {
|
||||
streamCallback(errorMessage.content, true);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add context from the current note to the chat
|
||||
*
|
||||
* @param sessionId - The ID of the chat session
|
||||
* @param noteId - The ID of the note to add context from
|
||||
* @param useSmartContext - Whether to use smart context extraction (default: true)
|
||||
* @returns The updated chat session
|
||||
*/
|
||||
async addNoteContext(sessionId: string, noteId: string, useSmartContext = true): Promise<ChatSession> {
|
||||
const session = await this.getOrCreateSession(sessionId);
|
||||
|
||||
// Get the last user message to use as context for semantic search
|
||||
const lastUserMessage = [...session.messages].reverse()
|
||||
.find(msg => msg.role === 'user' && msg.content.length > 10)?.content || '';
|
||||
|
||||
// Use the context extraction stage from the pipeline
|
||||
const pipeline = this.getPipeline();
|
||||
const contextResult = await pipeline.stages.contextExtraction.execute({
|
||||
noteId,
|
||||
query: lastUserMessage,
|
||||
useSmartContext
|
||||
}) as ContextExtractionResult;
|
||||
|
||||
const contextMessage: Message = {
|
||||
role: 'user',
|
||||
content: CONTEXT_PROMPTS.NOTE_CONTEXT_PROMPT.replace('{context}', contextResult.context)
|
||||
};
|
||||
|
||||
session.messages.push(contextMessage);
|
||||
|
||||
// Store the context note id in metadata
|
||||
const metadata = {
|
||||
contextNoteId: noteId
|
||||
};
|
||||
|
||||
// Check if the context extraction result has sources
|
||||
if (contextResult.sources && contextResult.sources.length > 0) {
|
||||
// Convert the sources to match expected format (handling null vs undefined)
|
||||
const sources = contextResult.sources.map(source => ({
|
||||
noteId: source.noteId,
|
||||
title: source.title,
|
||||
similarity: source.similarity,
|
||||
// Replace null with undefined for content
|
||||
content: source.content === null ? undefined : source.content
|
||||
}));
|
||||
|
||||
// Store these sources in metadata
|
||||
await chatStorageService.recordSources(session.id, sources);
|
||||
}
|
||||
|
||||
await chatStorageService.updateChat(session.id, session.messages, undefined, metadata);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add semantically relevant context from a note based on a specific query
|
||||
*/
|
||||
async addSemanticNoteContext(sessionId: string, noteId: string, query: string): Promise<ChatSession> {
|
||||
const session = await this.getOrCreateSession(sessionId);
|
||||
|
||||
// Use the semantic context extraction stage from the pipeline
|
||||
const pipeline = this.getPipeline();
|
||||
const contextResult = await pipeline.stages.semanticContextExtraction.execute({
|
||||
noteId,
|
||||
query
|
||||
});
|
||||
|
||||
const contextMessage: Message = {
|
||||
role: 'user',
|
||||
content: CONTEXT_PROMPTS.SEMANTIC_NOTE_CONTEXT_PROMPT
|
||||
.replace('{query}', query)
|
||||
.replace('{context}', contextResult.context)
|
||||
};
|
||||
|
||||
session.messages.push(contextMessage);
|
||||
|
||||
// Store the context note id and query in metadata
|
||||
const metadata = {
|
||||
contextNoteId: noteId
|
||||
};
|
||||
|
||||
// Check if the semantic context extraction result has sources
|
||||
const contextSources = (contextResult as ContextExtractionResult).sources || [];
|
||||
if (contextSources && contextSources.length > 0) {
|
||||
// Convert the sources to the format expected by recordSources
|
||||
const sources = contextSources.map((source) => ({
|
||||
noteId: source.noteId,
|
||||
title: source.title,
|
||||
similarity: source.similarity,
|
||||
content: source.content === null ? undefined : source.content
|
||||
}));
|
||||
|
||||
// Store these sources in metadata
|
||||
await chatStorageService.recordSources(session.id, sources);
|
||||
}
|
||||
|
||||
await chatStorageService.updateChat(session.id, session.messages, undefined, metadata);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user's chat sessions
|
||||
*/
|
||||
async getAllSessions(): Promise<ChatSession[]> {
|
||||
// Always fetch the latest data from notes
|
||||
const chats = await chatStorageService.getAllChats();
|
||||
|
||||
// Update the cache with the latest data
|
||||
return chats.map(chat => {
|
||||
const cachedSession = this.sessionCache.get(chat.id);
|
||||
|
||||
const session: ChatSession = {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
messages: chat.messages,
|
||||
isStreaming: cachedSession?.isStreaming || false
|
||||
};
|
||||
|
||||
// Update the cache
|
||||
if (cachedSession) {
|
||||
cachedSession.title = chat.title;
|
||||
cachedSession.messages = chat.messages;
|
||||
} else {
|
||||
this.sessionCache.set(chat.id, session);
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a chat session
|
||||
*/
|
||||
async deleteSession(sessionId: string): Promise<boolean> {
|
||||
this.sessionCache.delete(sessionId);
|
||||
return chatStorageService.deleteChat(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pipeline performance metrics
|
||||
*/
|
||||
getPipelineMetrics(pipelineType: string = 'default'): unknown {
|
||||
const pipeline = this.getPipeline(pipelineType);
|
||||
return pipeline.getMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset pipeline metrics
|
||||
*/
|
||||
resetPipelineMetrics(pipelineType: string = 'default'): void {
|
||||
const pipeline = this.getPipeline(pipelineType);
|
||||
pipeline.resetMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a title from the first messages in a conversation
|
||||
*/
|
||||
private generateTitleFromMessages(messages: Message[]): string {
|
||||
if (messages.length < 2) {
|
||||
return 'New Chat';
|
||||
}
|
||||
|
||||
// Get the first user message
|
||||
const firstUserMessage = messages.find(m => m.role === 'user');
|
||||
if (!firstUserMessage) {
|
||||
return 'New Chat';
|
||||
}
|
||||
|
||||
// Extract first line or first few words
|
||||
const firstLine = firstUserMessage.content.split('\n')[0].trim();
|
||||
|
||||
if (firstLine.length <= 30) {
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
// Take first 30 chars if too long
|
||||
return firstLine.substring(0, 27) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a chat completion with a sequence of messages
|
||||
* @param messages Messages array to send to the AI provider
|
||||
* @param options Chat completion options
|
||||
*/
|
||||
async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise<ChatResponse> {
|
||||
log.info(`========== CHAT SERVICE FLOW CHECK ==========`);
|
||||
log.info(`Entered generateChatCompletion in ChatService`);
|
||||
log.info(`Using pipeline for chat completion: ${this.getPipeline(options.pipeline).constructor.name}`);
|
||||
log.info(`Tool support enabled: ${options.enableTools !== false}`);
|
||||
|
||||
try {
|
||||
// Get AI service
|
||||
const service = await aiServiceManager.getService();
|
||||
if (!service) {
|
||||
throw new Error('No AI service available');
|
||||
}
|
||||
|
||||
log.info(`Using AI service: ${service.getName()}`);
|
||||
|
||||
// Prepare query extraction
|
||||
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
|
||||
const query = lastUserMessage ? lastUserMessage.content : undefined;
|
||||
|
||||
// For advanced context processing, use the pipeline
|
||||
if (options.useAdvancedContext && query) {
|
||||
log.info(`Using chat pipeline for advanced context with query: ${query.substring(0, 50)}...`);
|
||||
|
||||
// Create a pipeline input with the query and messages
|
||||
const pipelineInput: ChatPipelineInput = {
|
||||
messages,
|
||||
options,
|
||||
query,
|
||||
noteId: options.noteId
|
||||
};
|
||||
|
||||
// Execute the pipeline
|
||||
const pipeline = this.getPipeline(options.pipeline);
|
||||
const response = await pipeline.execute(pipelineInput);
|
||||
log.info(`Pipeline execution complete, response contains tools: ${response.tool_calls ? 'yes' : 'no'}`);
|
||||
if (response.tool_calls) {
|
||||
log.info(`Tool calls in pipeline response: ${response.tool_calls.length}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// If not using advanced context, use direct service call
|
||||
return await service.generateChatCompletion(messages, options);
|
||||
} catch (error: unknown) {
|
||||
console.error('Error in generateChatCompletion:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error handler utility
|
||||
*/
|
||||
private handleError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message || String(error);
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const chatService = new ChatService();
|
||||
export default chatService;
|
||||
161
apps/server/src/services/llm/cleanup_obsolete_files.sh
Executable file
161
apps/server/src/services/llm/cleanup_obsolete_files.sh
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Cleanup script for obsolete LLM files after Phase 1 and Phase 2 refactoring
|
||||
# This script removes files that have been replaced by the simplified architecture
|
||||
|
||||
echo "======================================"
|
||||
echo "LLM Cleanup Script - Phase 1 & 2"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
echo "This script will remove obsolete files replaced by:"
|
||||
echo "- Simplified 4-stage pipeline"
|
||||
echo "- Centralized configuration service"
|
||||
echo "- New tool format adapter"
|
||||
echo ""
|
||||
|
||||
# Safety check
|
||||
read -p "Are you sure you want to remove obsolete LLM files? (y/N): " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Cleanup cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Counter for removed files
|
||||
removed_count=0
|
||||
|
||||
# Function to safely remove a file
|
||||
remove_file() {
|
||||
local file=$1
|
||||
if [ -f "$file" ]; then
|
||||
echo "Removing: $file"
|
||||
rm "$file"
|
||||
((removed_count++))
|
||||
else
|
||||
echo "Already removed or doesn't exist: $file"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "Starting cleanup..."
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# PIPELINE STAGES - Replaced by simplified_pipeline.ts
|
||||
# ============================================
|
||||
echo "Removing old pipeline stages (replaced by 4-stage simplified pipeline)..."
|
||||
|
||||
# Old 9-stage pipeline implementation
|
||||
remove_file "pipeline/stages/agent_tools_context_stage.ts"
|
||||
remove_file "pipeline/stages/context_extraction_stage.ts"
|
||||
remove_file "pipeline/stages/error_recovery_stage.ts"
|
||||
remove_file "pipeline/stages/llm_completion_stage.ts"
|
||||
remove_file "pipeline/stages/message_preparation_stage.ts"
|
||||
remove_file "pipeline/stages/model_selection_stage.ts"
|
||||
remove_file "pipeline/stages/response_processing_stage.ts"
|
||||
remove_file "pipeline/stages/semantic_context_extraction_stage.ts"
|
||||
remove_file "pipeline/stages/tool_calling_stage.ts"
|
||||
remove_file "pipeline/stages/user_interaction_stage.ts"
|
||||
|
||||
# Old pipeline base class
|
||||
remove_file "pipeline/pipeline_stage.ts"
|
||||
|
||||
# Old complex pipeline (replaced by simplified_pipeline.ts)
|
||||
remove_file "pipeline/chat_pipeline.ts"
|
||||
remove_file "pipeline/chat_pipeline.spec.ts"
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# CONFIGURATION - Replaced by configuration_service.ts
|
||||
# ============================================
|
||||
echo "Removing old configuration files (replaced by centralized configuration_service.ts)..."
|
||||
|
||||
# Old configuration helpers are still used, but configuration_manager can be removed if it exists
|
||||
remove_file "config/configuration_manager.ts"
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# FORMATTERS - Consolidated into tool_format_adapter.ts
|
||||
# ============================================
|
||||
echo "Removing old formatter files (replaced by tool_format_adapter.ts)..."
|
||||
|
||||
# Old individual formatters if they exist
|
||||
remove_file "formatters/base_formatter.ts"
|
||||
remove_file "formatters/openai_formatter.ts"
|
||||
remove_file "formatters/anthropic_formatter.ts"
|
||||
remove_file "formatters/ollama_formatter.ts"
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# DUPLICATE SERVICES - Consolidated
|
||||
# ============================================
|
||||
echo "Removing duplicate service files..."
|
||||
|
||||
# ChatService is replaced by RestChatService with simplified pipeline
|
||||
remove_file "chat_service.ts"
|
||||
remove_file "chat_service.spec.ts"
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# OLD INTERFACES - Check which are still needed
|
||||
# ============================================
|
||||
echo "Checking interfaces..."
|
||||
|
||||
# Note: Some interfaces may still be needed, so we'll be careful here
|
||||
# The pipeline/interfaces.ts is still used by pipeline_adapter.ts
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# UNUSED CONTEXT EXTRACTORS
|
||||
# ============================================
|
||||
echo "Checking context extractors..."
|
||||
|
||||
# These might still be used, so let's check first
|
||||
echo "Note: Context extractors in context_extractors/ may still be in use"
|
||||
echo "Skipping context_extractors for safety"
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# REMOVE EMPTY DIRECTORIES
|
||||
# ============================================
|
||||
echo "Removing empty directories..."
|
||||
|
||||
# Remove stages directory if empty
|
||||
if [ -d "pipeline/stages" ]; then
|
||||
if [ -z "$(ls -A pipeline/stages)" ]; then
|
||||
echo "Removing empty directory: pipeline/stages"
|
||||
rmdir "pipeline/stages"
|
||||
((removed_count++))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Remove formatters directory if empty
|
||||
if [ -d "formatters" ]; then
|
||||
if [ -z "$(ls -A formatters)" ]; then
|
||||
echo "Removing empty directory: formatters"
|
||||
rmdir "formatters"
|
||||
((removed_count++))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "Cleanup Complete!"
|
||||
echo "======================================"
|
||||
echo "Removed $removed_count files/directories"
|
||||
echo ""
|
||||
echo "Remaining structure:"
|
||||
echo "- simplified_pipeline.ts (new 4-stage pipeline)"
|
||||
echo "- pipeline_adapter.ts (backward compatibility)"
|
||||
echo "- configuration_service.ts (centralized config)"
|
||||
echo "- model_registry.ts (model capabilities)"
|
||||
echo "- logging_service.ts (structured logging)"
|
||||
echo "- tool_format_adapter.ts (unified tool conversion)"
|
||||
echo ""
|
||||
echo "Note: The pipeline_adapter.ts provides backward compatibility"
|
||||
echo "until all references to the old pipeline are updated."
|
||||
@@ -1,20 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as configHelpers from './configuration_helpers.js';
|
||||
import configurationManager from './configuration_manager.js';
|
||||
import optionService from '../../options.js';
|
||||
import type { ProviderType, ModelIdentifier, ModelConfig } from '../interfaces/configuration_interfaces.js';
|
||||
|
||||
// Mock dependencies - configuration manager is no longer used
|
||||
vi.mock('./configuration_manager.js', () => ({
|
||||
default: {
|
||||
parseModelIdentifier: vi.fn(),
|
||||
createModelConfig: vi.fn(),
|
||||
getAIConfig: vi.fn(),
|
||||
validateConfig: vi.fn(),
|
||||
clearCache: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../options.js', () => ({
|
||||
default: {
|
||||
getOption: vi.fn(),
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
import options from '../../options.js';
|
||||
import log from '../../log.js';
|
||||
import type {
|
||||
AIConfig,
|
||||
ProviderPrecedenceConfig,
|
||||
ModelIdentifier,
|
||||
ModelConfig,
|
||||
ProviderType,
|
||||
ConfigValidationResult,
|
||||
ProviderSettings,
|
||||
OpenAISettings,
|
||||
AnthropicSettings,
|
||||
OllamaSettings
|
||||
} from '../interfaces/configuration_interfaces.js';
|
||||
|
||||
/**
|
||||
* Configuration manager that handles conversion from string-based options
|
||||
* to proper typed configuration objects.
|
||||
*
|
||||
* This is the ONLY place where string parsing should happen for LLM configurations.
|
||||
*/
|
||||
export class ConfigurationManager {
|
||||
private static instance: ConfigurationManager | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): ConfigurationManager {
|
||||
if (!ConfigurationManager.instance) {
|
||||
ConfigurationManager.instance = new ConfigurationManager();
|
||||
}
|
||||
return ConfigurationManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the complete AI configuration - always fresh, no caching
|
||||
*/
|
||||
public async getAIConfig(): Promise<AIConfig> {
|
||||
try {
|
||||
const config: AIConfig = {
|
||||
enabled: await this.getAIEnabled(),
|
||||
selectedProvider: await this.getSelectedProvider(),
|
||||
defaultModels: await this.getDefaultModels(),
|
||||
providerSettings: await this.getProviderSettings()
|
||||
};
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
log.error(`Error loading AI configuration: ${error}`);
|
||||
return this.getDefaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected AI provider
|
||||
*/
|
||||
public async getSelectedProvider(): Promise<ProviderType | null> {
|
||||
try {
|
||||
const selectedProvider = options.getOption('aiSelectedProvider');
|
||||
return selectedProvider as ProviderType || null;
|
||||
} catch (error) {
|
||||
log.error(`Error getting selected provider: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse model identifier with optional provider prefix
|
||||
* Handles formats like "gpt-4", "openai:gpt-4", "ollama:llama2:7b"
|
||||
*/
|
||||
public parseModelIdentifier(modelString: string): ModelIdentifier {
|
||||
if (!modelString) {
|
||||
return {
|
||||
modelId: '',
|
||||
fullIdentifier: ''
|
||||
};
|
||||
}
|
||||
|
||||
const parts = modelString.split(':');
|
||||
|
||||
if (parts.length === 1) {
|
||||
// No provider prefix, just model name
|
||||
return {
|
||||
modelId: modelString,
|
||||
fullIdentifier: modelString
|
||||
};
|
||||
}
|
||||
|
||||
// Check if first part is a known provider
|
||||
const potentialProvider = parts[0].toLowerCase();
|
||||
const knownProviders: ProviderType[] = ['openai', 'anthropic', 'ollama'];
|
||||
|
||||
if (knownProviders.includes(potentialProvider as ProviderType)) {
|
||||
// Provider prefix format
|
||||
const provider = potentialProvider as ProviderType;
|
||||
const modelId = parts.slice(1).join(':'); // Rejoin in case model has colons
|
||||
|
||||
return {
|
||||
provider,
|
||||
modelId,
|
||||
fullIdentifier: modelString
|
||||
};
|
||||
}
|
||||
|
||||
// Not a provider prefix, treat whole string as model name
|
||||
return {
|
||||
modelId: modelString,
|
||||
fullIdentifier: modelString
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create model configuration from string
|
||||
*/
|
||||
public createModelConfig(modelString: string, defaultProvider?: ProviderType): ModelConfig {
|
||||
const identifier = this.parseModelIdentifier(modelString);
|
||||
const provider = identifier.provider || defaultProvider || 'openai';
|
||||
|
||||
return {
|
||||
provider,
|
||||
modelId: identifier.modelId,
|
||||
displayName: identifier.fullIdentifier
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default models for each provider - ONLY from user configuration
|
||||
*/
|
||||
public async getDefaultModels(): Promise<Record<ProviderType, string | undefined>> {
|
||||
try {
|
||||
const openaiModel = options.getOption('openaiDefaultModel');
|
||||
const anthropicModel = options.getOption('anthropicDefaultModel');
|
||||
const ollamaModel = options.getOption('ollamaDefaultModel');
|
||||
|
||||
return {
|
||||
openai: openaiModel || undefined,
|
||||
anthropic: anthropicModel || undefined,
|
||||
ollama: ollamaModel || undefined
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`Error loading default models: ${error}`);
|
||||
// Return undefined for all providers if we can't load config
|
||||
return {
|
||||
openai: undefined,
|
||||
anthropic: undefined,
|
||||
ollama: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider-specific settings
|
||||
*/
|
||||
public async getProviderSettings(): Promise<ProviderSettings> {
|
||||
try {
|
||||
const openaiApiKey = options.getOption('openaiApiKey');
|
||||
const openaiBaseUrl = options.getOption('openaiBaseUrl');
|
||||
const openaiDefaultModel = options.getOption('openaiDefaultModel');
|
||||
const anthropicApiKey = options.getOption('anthropicApiKey');
|
||||
const anthropicBaseUrl = options.getOption('anthropicBaseUrl');
|
||||
const anthropicDefaultModel = options.getOption('anthropicDefaultModel');
|
||||
const ollamaBaseUrl = options.getOption('ollamaBaseUrl');
|
||||
const ollamaDefaultModel = options.getOption('ollamaDefaultModel');
|
||||
|
||||
const settings: ProviderSettings = {};
|
||||
|
||||
if (openaiApiKey || openaiBaseUrl || openaiDefaultModel) {
|
||||
settings.openai = {
|
||||
apiKey: openaiApiKey,
|
||||
baseUrl: openaiBaseUrl,
|
||||
defaultModel: openaiDefaultModel
|
||||
};
|
||||
}
|
||||
|
||||
if (anthropicApiKey || anthropicBaseUrl || anthropicDefaultModel) {
|
||||
settings.anthropic = {
|
||||
apiKey: anthropicApiKey,
|
||||
baseUrl: anthropicBaseUrl,
|
||||
defaultModel: anthropicDefaultModel
|
||||
};
|
||||
}
|
||||
|
||||
if (ollamaBaseUrl || ollamaDefaultModel) {
|
||||
settings.ollama = {
|
||||
baseUrl: ollamaBaseUrl,
|
||||
defaultModel: ollamaDefaultModel
|
||||
};
|
||||
}
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
log.error(`Error loading provider settings: ${error}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration
|
||||
*/
|
||||
public async validateConfig(): Promise<ConfigValidationResult> {
|
||||
const result: ConfigValidationResult = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
try {
|
||||
const config = await this.getAIConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
result.warnings.push('AI features are disabled');
|
||||
return result;
|
||||
}
|
||||
|
||||
// Validate selected provider
|
||||
if (!config.selectedProvider) {
|
||||
result.errors.push('No AI provider selected');
|
||||
result.isValid = false;
|
||||
} else {
|
||||
// Validate selected provider settings
|
||||
const providerConfig = config.providerSettings[config.selectedProvider];
|
||||
|
||||
if (config.selectedProvider === 'openai') {
|
||||
const openaiConfig = providerConfig as OpenAISettings | undefined;
|
||||
if (!openaiConfig?.apiKey) {
|
||||
result.warnings.push('OpenAI API key is not configured');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.selectedProvider === 'anthropic') {
|
||||
const anthropicConfig = providerConfig as AnthropicSettings | undefined;
|
||||
if (!anthropicConfig?.apiKey) {
|
||||
result.warnings.push('Anthropic API key is not configured');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.selectedProvider === 'ollama') {
|
||||
const ollamaConfig = providerConfig as OllamaSettings | undefined;
|
||||
if (!ollamaConfig?.baseUrl) {
|
||||
result.warnings.push('Ollama base URL is not configured');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push(`Configuration validation error: ${error}`);
|
||||
result.isValid = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private async getAIEnabled(): Promise<boolean> {
|
||||
try {
|
||||
return options.getOptionBool('aiEnabled');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private parseProviderList(precedenceOption: string | null): string[] {
|
||||
if (!precedenceOption) {
|
||||
// Don't assume any defaults - return empty array
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle JSON array format
|
||||
if (precedenceOption.startsWith('[') && precedenceOption.endsWith(']')) {
|
||||
const parsed = JSON.parse(precedenceOption);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map(p => String(p).trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Handle comma-separated format
|
||||
if (precedenceOption.includes(',')) {
|
||||
return precedenceOption.split(',').map(p => p.trim());
|
||||
}
|
||||
|
||||
// Handle single provider
|
||||
return [precedenceOption.trim()];
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error parsing provider list "${precedenceOption}": ${error}`);
|
||||
// Don't assume defaults on parse error
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultConfig(): AIConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
selectedProvider: null,
|
||||
defaultModels: {
|
||||
openai: undefined,
|
||||
anthropic: undefined,
|
||||
ollama: undefined
|
||||
},
|
||||
providerSettings: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export default ConfigurationManager.getInstance();
|
||||
245
apps/server/src/services/llm/config/llm_options.ts
Normal file
245
apps/server/src/services/llm/config/llm_options.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* LLM Service Configuration Options
|
||||
*
|
||||
* Defines all configurable options for the LLM service that can be
|
||||
* managed through Trilium's options system.
|
||||
*/
|
||||
|
||||
import optionService from '../../options.js';
|
||||
import type { OptionNames, FilterOptionsByType } from '@triliumnext/commons';
|
||||
import { ExportFormat } from '../metrics/metrics_exporter.js';
|
||||
|
||||
/**
|
||||
* LLM configuration options
|
||||
*/
|
||||
export interface LLMOptions {
|
||||
// Metrics Configuration
|
||||
metricsEnabled: boolean;
|
||||
metricsExportFormat: ExportFormat;
|
||||
metricsExportEndpoint?: string;
|
||||
metricsExportInterval: number;
|
||||
metricsPrometheusEnabled: boolean;
|
||||
metricsStatsdHost?: string;
|
||||
metricsStatsdPort?: number;
|
||||
metricsStatsdPrefix: string;
|
||||
|
||||
// Provider Configuration
|
||||
providerHealthCheckEnabled: boolean;
|
||||
providerHealthCheckInterval: number;
|
||||
providerCachingEnabled: boolean;
|
||||
providerCacheTimeout: number;
|
||||
providerFallbackEnabled: boolean;
|
||||
providerFallbackList: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default LLM options
|
||||
*/
|
||||
const DEFAULT_OPTIONS: LLMOptions = {
|
||||
// Metrics Defaults
|
||||
metricsEnabled: true,
|
||||
metricsExportFormat: 'prometheus' as ExportFormat,
|
||||
metricsExportInterval: 60000, // 1 minute
|
||||
metricsPrometheusEnabled: true,
|
||||
metricsStatsdPrefix: 'trilium.llm',
|
||||
|
||||
// Provider Defaults
|
||||
providerHealthCheckEnabled: true,
|
||||
providerHealthCheckInterval: 60000, // 1 minute
|
||||
providerCachingEnabled: true,
|
||||
providerCacheTimeout: 300000, // 5 minutes
|
||||
providerFallbackEnabled: true,
|
||||
providerFallbackList: ['ollama']
|
||||
};
|
||||
|
||||
/**
|
||||
* Option keys in Trilium's option system
|
||||
*/
|
||||
export const LLM_OPTION_KEYS = {
|
||||
// Metrics
|
||||
METRICS_ENABLED: 'llmMetricsEnabled' as const,
|
||||
METRICS_EXPORT_FORMAT: 'llmMetricsExportFormat' as const,
|
||||
METRICS_EXPORT_ENDPOINT: 'llmMetricsExportEndpoint' as const,
|
||||
METRICS_EXPORT_INTERVAL: 'llmMetricsExportInterval' as const,
|
||||
METRICS_PROMETHEUS_ENABLED: 'llmMetricsPrometheusEnabled' as const,
|
||||
METRICS_STATSD_HOST: 'llmMetricsStatsdHost' as const,
|
||||
METRICS_STATSD_PORT: 'llmMetricsStatsdPort' as const,
|
||||
METRICS_STATSD_PREFIX: 'llmMetricsStatsdPrefix' as const,
|
||||
|
||||
// Provider
|
||||
PROVIDER_HEALTH_CHECK_ENABLED: 'llmProviderHealthCheckEnabled' as const,
|
||||
PROVIDER_HEALTH_CHECK_INTERVAL: 'llmProviderHealthCheckInterval' as const,
|
||||
PROVIDER_CACHING_ENABLED: 'llmProviderCachingEnabled' as const,
|
||||
PROVIDER_CACHE_TIMEOUT: 'llmProviderCacheTimeout' as const,
|
||||
PROVIDER_FALLBACK_ENABLED: 'llmProviderFallbackEnabled' as const,
|
||||
PROVIDER_FALLBACK_LIST: 'llmProviderFallbackList' as const
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get LLM options from Trilium's option service
|
||||
*/
|
||||
export function getLLMOptions(): LLMOptions {
|
||||
// Helper function to safely get option with fallback
|
||||
function getOptionSafe<T>(getter: () => T, defaultValue: T): T {
|
||||
try {
|
||||
return getter() ?? defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Metrics
|
||||
metricsEnabled: getOptionSafe(
|
||||
() => optionService.getOptionBool(LLM_OPTION_KEYS.METRICS_ENABLED),
|
||||
DEFAULT_OPTIONS.metricsEnabled
|
||||
),
|
||||
metricsExportFormat: getOptionSafe(
|
||||
() => optionService.getOption(LLM_OPTION_KEYS.METRICS_EXPORT_FORMAT) as ExportFormat,
|
||||
DEFAULT_OPTIONS.metricsExportFormat
|
||||
),
|
||||
metricsExportEndpoint: getOptionSafe(
|
||||
() => optionService.getOption(LLM_OPTION_KEYS.METRICS_EXPORT_ENDPOINT),
|
||||
undefined
|
||||
),
|
||||
metricsExportInterval: getOptionSafe(
|
||||
() => optionService.getOptionInt(LLM_OPTION_KEYS.METRICS_EXPORT_INTERVAL),
|
||||
DEFAULT_OPTIONS.metricsExportInterval
|
||||
),
|
||||
metricsPrometheusEnabled: getOptionSafe(
|
||||
() => optionService.getOptionBool(LLM_OPTION_KEYS.METRICS_PROMETHEUS_ENABLED),
|
||||
DEFAULT_OPTIONS.metricsPrometheusEnabled
|
||||
),
|
||||
metricsStatsdHost: getOptionSafe(
|
||||
() => optionService.getOption(LLM_OPTION_KEYS.METRICS_STATSD_HOST),
|
||||
undefined
|
||||
),
|
||||
metricsStatsdPort: getOptionSafe(
|
||||
() => optionService.getOptionInt(LLM_OPTION_KEYS.METRICS_STATSD_PORT),
|
||||
undefined
|
||||
),
|
||||
metricsStatsdPrefix: getOptionSafe(
|
||||
() => optionService.getOption(LLM_OPTION_KEYS.METRICS_STATSD_PREFIX),
|
||||
DEFAULT_OPTIONS.metricsStatsdPrefix
|
||||
),
|
||||
|
||||
// Provider
|
||||
providerHealthCheckEnabled: getOptionSafe(
|
||||
() => optionService.getOptionBool(LLM_OPTION_KEYS.PROVIDER_HEALTH_CHECK_ENABLED),
|
||||
DEFAULT_OPTIONS.providerHealthCheckEnabled
|
||||
),
|
||||
providerHealthCheckInterval: getOptionSafe(
|
||||
() => optionService.getOptionInt(LLM_OPTION_KEYS.PROVIDER_HEALTH_CHECK_INTERVAL),
|
||||
DEFAULT_OPTIONS.providerHealthCheckInterval
|
||||
),
|
||||
providerCachingEnabled: getOptionSafe(
|
||||
() => optionService.getOptionBool(LLM_OPTION_KEYS.PROVIDER_CACHING_ENABLED),
|
||||
DEFAULT_OPTIONS.providerCachingEnabled
|
||||
),
|
||||
providerCacheTimeout: getOptionSafe(
|
||||
() => optionService.getOptionInt(LLM_OPTION_KEYS.PROVIDER_CACHE_TIMEOUT),
|
||||
DEFAULT_OPTIONS.providerCacheTimeout
|
||||
),
|
||||
providerFallbackEnabled: getOptionSafe(
|
||||
() => optionService.getOptionBool(LLM_OPTION_KEYS.PROVIDER_FALLBACK_ENABLED),
|
||||
DEFAULT_OPTIONS.providerFallbackEnabled
|
||||
),
|
||||
providerFallbackList: getOptionSafe(
|
||||
() => {
|
||||
const value = optionService.getOption(LLM_OPTION_KEYS.PROVIDER_FALLBACK_LIST);
|
||||
if (typeof value === 'string' && value) {
|
||||
return value.split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return DEFAULT_OPTIONS.providerFallbackList;
|
||||
},
|
||||
DEFAULT_OPTIONS.providerFallbackList
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an LLM option
|
||||
*/
|
||||
export async function setLLMOption(key: OptionNames, value: any): Promise<void> {
|
||||
await optionService.setOption(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize LLM options with defaults if not set
|
||||
*/
|
||||
export async function initializeLLMOptions(): Promise<void> {
|
||||
// Set defaults for any unset options
|
||||
const keysToCheck = Object.values(LLM_OPTION_KEYS) as OptionNames[];
|
||||
|
||||
for (const key of keysToCheck) {
|
||||
try {
|
||||
const currentValue = optionService.getOption(key);
|
||||
|
||||
if (currentValue === null || currentValue === undefined) {
|
||||
// Set default based on key
|
||||
const defaultKey = Object.entries(LLM_OPTION_KEYS)
|
||||
.find(([_, v]) => v === key)?.[0];
|
||||
|
||||
if (defaultKey) {
|
||||
const defaultPath = defaultKey
|
||||
.replace(/_([a-z])/g, (_, char) => char.toUpperCase())
|
||||
.replace(/^[A-Z]/, char => char.toLowerCase())
|
||||
.replace(/_/g, '');
|
||||
|
||||
const defaultValue = (DEFAULT_OPTIONS as any)[defaultPath];
|
||||
|
||||
if (defaultValue !== undefined) {
|
||||
await setLLMOption(key,
|
||||
Array.isArray(defaultValue) ? defaultValue.join(',') : defaultValue
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Option doesn't exist yet, create it with default
|
||||
const defaultKey = Object.entries(LLM_OPTION_KEYS)
|
||||
.find(([_, v]) => v === key)?.[0];
|
||||
|
||||
if (defaultKey) {
|
||||
const defaultPath = defaultKey
|
||||
.replace(/_([a-z])/g, (_, char) => char.toUpperCase())
|
||||
.replace(/^[A-Z]/, char => char.toLowerCase())
|
||||
.replace(/_/g, '');
|
||||
|
||||
const defaultValue = (DEFAULT_OPTIONS as any)[defaultPath];
|
||||
|
||||
if (defaultValue !== undefined) {
|
||||
await setLLMOption(key,
|
||||
Array.isArray(defaultValue) ? defaultValue.join(',') : defaultValue
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create provider factory options from LLM options
|
||||
*/
|
||||
export function createProviderFactoryOptions() {
|
||||
const options = getLLMOptions();
|
||||
|
||||
return {
|
||||
enableHealthChecks: options.providerHealthCheckEnabled,
|
||||
healthCheckInterval: options.providerHealthCheckInterval,
|
||||
enableFallback: options.providerFallbackEnabled,
|
||||
fallbackProviders: options.providerFallbackList as any[],
|
||||
enableCaching: options.providerCachingEnabled,
|
||||
cacheTimeout: options.providerCacheTimeout,
|
||||
enableMetrics: options.metricsEnabled,
|
||||
metricsExporterConfig: {
|
||||
enabled: options.metricsEnabled,
|
||||
format: options.metricsExportFormat,
|
||||
endpoint: options.metricsExportEndpoint,
|
||||
interval: options.metricsExportInterval,
|
||||
statsdHost: options.metricsStatsdHost,
|
||||
statsdPort: options.metricsStatsdPort,
|
||||
prefix: options.metricsStatsdPrefix
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -187,18 +187,45 @@ When responding:
|
||||
|
||||
// Tool instructions for Anthropic Claude
|
||||
TOOL_INSTRUCTIONS: `<instructions>
|
||||
When using tools to search for information, follow these requirements:
|
||||
You are an interactive assistant specializing in thorough information retrieval and analysis. Your primary goal is to help users by utilizing available tools comprehensively and systematically.
|
||||
|
||||
1. ALWAYS TRY MULTIPLE SEARCH APPROACHES before concluding information isn't available
|
||||
2. YOU MUST PERFORM AT LEAST 3 DIFFERENT SEARCHES with varied parameters before giving up
|
||||
3. If a search returns no results:
|
||||
- Try broader terms (e.g., "Kubernetes" instead of "Kubernetes deployment")
|
||||
- Use synonyms (e.g., "meeting" instead of "conference")
|
||||
- Remove specific qualifiers (e.g., "report" instead of "Q3 financial report")
|
||||
- Try different search tools (vector_search for conceptual matches, keyword_search for exact matches)
|
||||
4. NEVER tell the user "there are no notes about X" until you've tried multiple search variations
|
||||
5. EXPLAIN your search strategy when adjusting parameters (e.g., "I'll try a broader search for...")
|
||||
6. When searches fail, AUTOMATICALLY try different approaches rather than asking the user what to do
|
||||
CRITICAL TOOL USAGE MANDATES:
|
||||
|
||||
1. **IMMEDIATE TOOL REACTION**: After receiving ANY tool result, you MUST analyze it thoroughly and determine if additional tools are needed. NEVER stop after a single tool execution unless you have completely fulfilled the user's request.
|
||||
|
||||
2. **AUTOMATIC CONTINUATION**: When you receive tool results, ALWAYS consider them as part of an ongoing investigation. Use the information to:
|
||||
- Plan additional searches if more information is needed
|
||||
- Cross-reference results with different search approaches
|
||||
- Verify findings with alternative tools
|
||||
- Build upon partial results to construct a complete answer
|
||||
|
||||
3. **MANDATORY MULTI-TOOL SEQUENCES**:
|
||||
- ALWAYS perform at least 2-3 different tool calls for any information request
|
||||
- Chain tools together: use results from one tool to inform parameters for the next
|
||||
- If initial searches return partial results, IMMEDIATELY run complementary searches
|
||||
- Never accept empty or minimal results without trying alternative approaches
|
||||
|
||||
4. **SEARCH STRATEGY REQUIREMENTS**:
|
||||
- Try broader terms if specific searches fail (e.g., "Kubernetes" instead of "Kubernetes deployment")
|
||||
- Use synonyms and alternative terminology (e.g., "meeting" vs "conference" vs "discussion")
|
||||
- Remove qualifiers progressively (e.g., "Q3 financial report" → "financial report" → "report")
|
||||
- Combine different search tools for comprehensive coverage
|
||||
|
||||
5. **NEVER GIVE UP EASILY**:
|
||||
- NEVER tell the user "there are no notes about X" until you've tried at least 3 different search variations
|
||||
- If search_notes fails, try keyword_search and vice versa
|
||||
- Automatically try different approaches rather than asking the user what to do next
|
||||
- Use read_note if you find relevant note IDs in search results
|
||||
|
||||
6. **CONTINUATION SIGNALS**: When you receive tool results, phrases like "Based on these results, I'll now..." or "Let me search for additional information..." indicate you understand this is ongoing work requiring further analysis.
|
||||
|
||||
7. **COMPREHENSIVE ANALYSIS**: After using tools, always:
|
||||
- Synthesize information from multiple sources
|
||||
- Identify gaps that require additional searches
|
||||
- Cross-reference findings for completeness
|
||||
- Provide thorough, well-researched responses
|
||||
|
||||
Remember: Tool usage is iterative and cumulative. Each tool result should inform your next action, leading to comprehensive assistance.
|
||||
</instructions>`,
|
||||
|
||||
ACKNOWLEDGMENT: "I understand. I'll follow those instructions.",
|
||||
@@ -222,18 +249,41 @@ Be concise and informative in your responses.
|
||||
</system_prompt>`,
|
||||
|
||||
// Tool instructions for OpenAI models
|
||||
TOOL_INSTRUCTIONS: `When using tools to search for information, you must follow these requirements:
|
||||
TOOL_INSTRUCTIONS: `You are an interactive assistant specializing in comprehensive information retrieval and analysis. Your goal is systematic and thorough tool usage.
|
||||
|
||||
1. ALWAYS TRY MULTIPLE SEARCH APPROACHES before concluding information isn't available
|
||||
2. YOU MUST PERFORM AT LEAST 3 DIFFERENT SEARCHES with varied parameters before giving up
|
||||
3. If a search returns no results:
|
||||
- Try broader terms (e.g., "Kubernetes" instead of "Kubernetes deployment")
|
||||
- Use synonyms (e.g., "meeting" instead of "conference")
|
||||
- Remove specific qualifiers (e.g., "report" instead of "Q3 financial report")
|
||||
- Try different search tools (vector_search for conceptual matches, keyword_search for exact matches)
|
||||
4. NEVER tell the user "there are no notes about X" until you've tried multiple search variations
|
||||
5. EXPLAIN your search strategy when adjusting parameters (e.g., "I'll try a broader search for...")
|
||||
6. When searches fail, AUTOMATICALLY try different approaches rather than asking the user what to do`
|
||||
MANDATORY TOOL USAGE PROTOCOLS:
|
||||
|
||||
1. **CONTINUOUS TOOL ENGAGEMENT**: After receiving ANY tool result, immediately analyze it and determine what additional tools are needed. Never stop after a single tool execution.
|
||||
|
||||
2. **ITERATIVE INVESTIGATION**: Treat every tool result as part of an ongoing investigation:
|
||||
- Use results to plan follow-up searches
|
||||
- Cross-reference findings with alternative approaches
|
||||
- Build upon partial results systematically
|
||||
- Chain tools together for comprehensive coverage
|
||||
|
||||
3. **MULTI-TOOL REQUIREMENTS**:
|
||||
- Always perform 2-3 different tool calls minimum for information requests
|
||||
- Use results from one tool to inform parameters for the next tool
|
||||
- If searches return partial results, immediately run complementary searches
|
||||
- Never accept empty results without trying alternative approaches
|
||||
|
||||
4. **SEARCH ESCALATION STRATEGY**:
|
||||
- Progress from specific to broader terms (e.g., "Kubernetes deployment" → "Kubernetes" → "containers")
|
||||
- Try synonyms and variations (e.g., "meeting" → "conference" → "discussion")
|
||||
- Remove qualifiers systematically (e.g., "Q3 2024 report" → "financial report" → "report")
|
||||
- Combine different search tools for maximum coverage
|
||||
|
||||
5. **PERSISTENCE REQUIREMENTS**:
|
||||
- Never tell the user "no information found" until trying at least 3 different search variations
|
||||
- If search_notes fails, immediately try keyword_search
|
||||
- Automatically pivot to alternative approaches without asking the user
|
||||
- Use read_note when search results include relevant note IDs
|
||||
|
||||
6. **CONTINUATION INDICATORS**: Use phrases like "Based on these results, I'll now search for..." to signal ongoing analysis and additional tool usage.
|
||||
|
||||
7. **COMPREHENSIVE SYNTHESIS**: After tool usage, always synthesize findings, identify information gaps, and provide thoroughly researched responses.
|
||||
|
||||
Remember: Each tool result should drive further investigation until the user's request is completely fulfilled.`
|
||||
},
|
||||
|
||||
OLLAMA: {
|
||||
@@ -248,18 +298,44 @@ Based on this information, please answer: <query>${query}</query>`,
|
||||
// Tool instructions for Ollama
|
||||
TOOL_INSTRUCTIONS: `
|
||||
CRITICAL INSTRUCTIONS FOR TOOL USAGE:
|
||||
1. YOU MUST TRY MULTIPLE TOOLS AND SEARCH VARIATIONS before concluding information isn't available
|
||||
2. ALWAYS PERFORM AT LEAST 3 DIFFERENT SEARCHES with different parameters before giving up on finding information
|
||||
3. If a search returns no results, IMMEDIATELY TRY ANOTHER SEARCH with different parameters:
|
||||
- Use broader terms: If "Kubernetes deployment" fails, try just "Kubernetes" or "container orchestration"
|
||||
- Try synonyms: If "meeting notes" fails, try "conference", "discussion", or "conversation"
|
||||
- Remove specific qualifiers: If "quarterly financial report 2024" fails, try just "financial report"
|
||||
- Try semantic variations: If keyword_search fails, use vector_search which finds conceptually related content
|
||||
4. CHAIN TOOLS TOGETHER: Use the results of one tool to inform parameters for the next tool
|
||||
5. NEVER respond with "there are no notes about X" until you've tried at least 3 different search variations
|
||||
6. DO NOT ask the user what to do next when searches fail - AUTOMATICALLY try different approaches
|
||||
7. ALWAYS EXPLAIN what you're doing: "I didn't find results for X, so I'm now searching for Y instead"
|
||||
8. If all reasonable search variations fail (minimum 3 attempts), THEN you may inform the user that the information might not be in their notes`
|
||||
|
||||
MANDATORY TOOL CONTINUATION PROTOCOL:
|
||||
- After receiving ANY tool result, you MUST immediately analyze it and continue with additional tools
|
||||
- NEVER stop after a single tool execution - always plan follow-up actions
|
||||
- Treat each tool result as a step in an ongoing investigation, not a final answer
|
||||
|
||||
AGGRESSIVE TOOL CHAINING REQUIREMENTS:
|
||||
1. ALWAYS PERFORM MULTIPLE TOOL SEQUENCES: Minimum 2-3 tools per request, even if first tool succeeds
|
||||
2. IMMEDIATE FOLLOW-UP: When you get tool results, instantly decide what additional tools to use:
|
||||
- If search finds results → use read_note to get full content
|
||||
- If search is partial → try broader/alternative search terms
|
||||
- If results seem incomplete → cross-reference with different tools
|
||||
3. CHAIN TOOLS SYSTEMATICALLY: Use results from one tool to inform parameters for the next tool
|
||||
4. AUTOMATIC ESCALATION: If search returns no results, IMMEDIATELY try another search with different parameters:
|
||||
- Broader terms: "Kubernetes deployment" → "Kubernetes" → "container orchestration"
|
||||
- Synonyms: "meeting notes" → "conference" → "discussion" → "conversation"
|
||||
- Remove qualifiers: "quarterly financial report 2024" → "financial report" → "report"
|
||||
- Different tools: If keyword_search fails, use search_notes for semantic matching
|
||||
|
||||
PERSISTENCE MANDATES:
|
||||
5. NEVER respond with "there are no notes about X" until trying at least 3-4 different search variations
|
||||
6. DO NOT ask the user what to do next - AUTOMATICALLY try different approaches
|
||||
7. ALWAYS EXPLAIN your continuation strategy: "I found some results, now I'll search for additional details..."
|
||||
8. If tool results are empty/minimal, IMMEDIATELY pivot to alternative approaches
|
||||
|
||||
CONTINUATION SIGNALS:
|
||||
- Use phrases like "Based on these results, I'll now..." to show ongoing work
|
||||
- "Let me search for additional information..."
|
||||
- "I'll cross-reference this with..."
|
||||
- These phrases signal you understand this is continuing work requiring more analysis
|
||||
|
||||
COMPREHENSIVE COVERAGE:
|
||||
9. Synthesize information from multiple tool calls before responding
|
||||
10. Identify gaps in information and use additional tools to fill them
|
||||
11. Only provide final answers after exhausting relevant tool combinations
|
||||
12. If all reasonable variations fail (minimum 3-4 attempts), THEN inform user that information might not be available
|
||||
|
||||
Remember: Tool usage is ITERATIVE and CONTINUOUS. Each result drives the next action until complete information is gathered.`
|
||||
},
|
||||
|
||||
// Common prompts across providers
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import type { Message } from '../ai_interface.js';
|
||||
import type { MessageFormatter } from '../interfaces/message_formatter.js';
|
||||
import { DEFAULT_SYSTEM_PROMPT, PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js';
|
||||
import {
|
||||
HTML_ALLOWED_TAGS,
|
||||
HTML_ALLOWED_ATTRIBUTES,
|
||||
HTML_TRANSFORMS,
|
||||
HTML_TO_MARKDOWN_PATTERNS,
|
||||
HTML_ENTITY_REPLACEMENTS,
|
||||
ENCODING_FIXES,
|
||||
FORMATTER_LOGS
|
||||
} from '../constants/formatter_constants.js';
|
||||
|
||||
/**
|
||||
* Base formatter with common functionality for all providers
|
||||
* Provider-specific formatters should extend this class
|
||||
*/
|
||||
export abstract class BaseMessageFormatter implements MessageFormatter {
|
||||
/**
|
||||
* Format messages for the LLM API
|
||||
* Each provider should override this method with its specific formatting logic
|
||||
*/
|
||||
abstract formatMessages(messages: Message[], systemPrompt?: string, context?: string): Message[];
|
||||
|
||||
/**
|
||||
* Get the maximum recommended context length for this provider
|
||||
* Each provider should override this with appropriate value
|
||||
*/
|
||||
abstract getMaxContextLength(): number;
|
||||
|
||||
/**
|
||||
* Get the default system prompt
|
||||
* Uses the default prompt from constants
|
||||
*/
|
||||
protected getDefaultSystemPrompt(systemPrompt?: string): string {
|
||||
return systemPrompt || DEFAULT_SYSTEM_PROMPT || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean context content - common method with standard HTML cleaning
|
||||
* Provider-specific formatters can override for custom behavior
|
||||
*/
|
||||
cleanContextContent(content: string): string {
|
||||
if (!content) return '';
|
||||
|
||||
try {
|
||||
// First fix any encoding issues
|
||||
const fixedContent = this.fixEncodingIssues(content);
|
||||
|
||||
// Convert HTML to markdown for better readability
|
||||
const cleaned = sanitizeHtml(fixedContent, {
|
||||
allowedTags: HTML_ALLOWED_TAGS.STANDARD,
|
||||
allowedAttributes: HTML_ALLOWED_ATTRIBUTES.STANDARD,
|
||||
transformTags: HTML_TRANSFORMS.STANDARD
|
||||
});
|
||||
|
||||
// Process inline elements to markdown
|
||||
let markdown = cleaned;
|
||||
|
||||
// Apply all HTML to Markdown patterns
|
||||
const patterns = HTML_TO_MARKDOWN_PATTERNS;
|
||||
for (const pattern of Object.values(patterns)) {
|
||||
markdown = markdown.replace(pattern.pattern, pattern.replacement);
|
||||
}
|
||||
|
||||
// Process list items
|
||||
markdown = this.processListItems(markdown);
|
||||
|
||||
// Fix common HTML entities
|
||||
const entityPatterns = HTML_ENTITY_REPLACEMENTS;
|
||||
for (const pattern of Object.values(entityPatterns)) {
|
||||
markdown = markdown.replace(pattern.pattern, pattern.replacement);
|
||||
}
|
||||
|
||||
return markdown.trim();
|
||||
} catch (error) {
|
||||
console.error(FORMATTER_LOGS.ERROR.CONTEXT_CLEANING("Base"), error);
|
||||
return content; // Return original if cleaning fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process HTML list items in markdown conversion
|
||||
* This is a helper method that safely processes HTML list items
|
||||
*/
|
||||
protected processListItems(content: string): string {
|
||||
// Process unordered lists
|
||||
let result = content.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (match: string, listContent: string) => {
|
||||
return listContent.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n');
|
||||
});
|
||||
|
||||
// Process ordered lists
|
||||
result = result.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (match: string, listContent: string) => {
|
||||
let index = 1;
|
||||
return listContent.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (itemMatch: string, item: string) => {
|
||||
return `${index++}. ${item}\n`;
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix common encoding issues in content
|
||||
* This fixes issues like broken quote characters and other encoding problems
|
||||
*
|
||||
* @param content The content to fix encoding issues in
|
||||
* @returns Content with encoding issues fixed
|
||||
*/
|
||||
protected fixEncodingIssues(content: string): string {
|
||||
if (!content) return '';
|
||||
|
||||
try {
|
||||
// Fix common encoding issues
|
||||
let fixed = content.replace(ENCODING_FIXES.BROKEN_QUOTES.pattern, ENCODING_FIXES.BROKEN_QUOTES.replacement);
|
||||
|
||||
// Fix other common broken unicode
|
||||
fixed = fixed.replace(/[\u{0080}-\u{FFFF}]/gu, (match) => {
|
||||
// Use replacements from constants
|
||||
const replacements = ENCODING_FIXES.UNICODE_REPLACEMENTS;
|
||||
return replacements[match as keyof typeof replacements] || match;
|
||||
});
|
||||
|
||||
return fixed;
|
||||
} catch (error) {
|
||||
console.error(FORMATTER_LOGS.ERROR.ENCODING, error);
|
||||
return content; // Return original if fixing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user