Merge remote-tracking branch 'origin/main' into feature/rtl_ui

This commit is contained in:
Elian Doran 2025-10-09 17:46:26 +03:00
commit 9d7c513fb7
No known key found for this signature in database
61 changed files with 1662 additions and 670 deletions

View File

@ -12,7 +12,7 @@ jobs:
steps: steps:
- name: Check if PRs have conflicts - name: Check if PRs have conflicts
uses: eps1lon/actions-label-merge-conflict@v3 uses: eps1lon/actions-label-merge-conflict@v3
if: github.repository == ${{ vars.REPO_MAIN }} if: github.repository == ${{ vars.REPO_MAIN }} && ${{secrets.MERGE_CONFLICT_LABEL_PAT}}
with: with:
dirtyLabel: "merge-conflicts" dirtyLabel: "merge-conflicts"
repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}" repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}"

View File

@ -67,7 +67,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
@ -95,6 +95,6 @@ jobs:
exit 1 exit 1
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v4
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"

View File

@ -55,7 +55,7 @@ jobs:
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: '3.13' python-version: '3.14'
cache: 'pip' cache: 'pip'
cache-dependency-path: 'requirements-docs.txt' cache-dependency-path: 'requirements-docs.txt'
@ -118,6 +118,7 @@ jobs:
- name: Deploy - name: Deploy
uses: ./.github/actions/deploy-to-cloudflare-pages uses: ./.github/actions/deploy-to-cloudflare-pages
if: github.repository == ${{ vars.REPO_MAIN }} && ${{secrets.CLOUDFLARE_API_TOKEN}}
with: with:
project_name: "trilium-docs" project_name: "trilium-docs"
comment_body: "📚 Documentation preview is ready" comment_body: "📚 Documentation preview is ready"

View File

@ -77,7 +77,7 @@ jobs:
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }} GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Publish release - name: Publish release
uses: softprops/action-gh-release@v2.3.4 uses: softprops/action-gh-release@v2.4.0
if: ${{ github.event_name != 'pull_request' }} if: ${{ github.event_name != 'pull_request' }}
with: with:
make_latest: false make_latest: false
@ -118,7 +118,7 @@ jobs:
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
- name: Publish release - name: Publish release
uses: softprops/action-gh-release@v2.3.4 uses: softprops/action-gh-release@v2.4.0
if: ${{ github.event_name != 'pull_request' }} if: ${{ github.event_name != 'pull_request' }}
with: with:
make_latest: false make_latest: false

View File

@ -127,7 +127,7 @@ jobs:
path: upload path: upload
- name: Publish stable release - name: Publish stable release
uses: softprops/action-gh-release@v2.3.4 uses: softprops/action-gh-release@v2.4.0
with: with:
draft: false draft: false
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md

View File

@ -44,7 +44,7 @@
"eslint": "9.37.0", "eslint": "9.37.0",
"eslint-plugin-simple-import-sort": "12.1.1", "eslint-plugin-simple-import-sort": "12.1.1",
"esm": "3.2.25", "esm": "3.2.25",
"jsdoc": "4.0.4", "jsdoc": "4.0.5",
"lorem-ipsum": "2.0.8", "lorem-ipsum": "2.0.8",
"rcedit": "4.0.1", "rcedit": "4.0.1",
"rimraf": "6.0.1", "rimraf": "6.0.1",

View File

@ -51,9 +51,9 @@
"leaflet": "1.9.4", "leaflet": "1.9.4",
"leaflet-gpx": "2.2.0", "leaflet-gpx": "2.2.0",
"mark.js": "8.11.1", "mark.js": "8.11.1",
"marked": "16.3.0", "marked": "16.4.0",
"mermaid": "11.12.0", "mermaid": "11.12.0",
"mind-elixir": "5.1.1", "mind-elixir": "5.3.2",
"normalize.css": "8.0.1", "normalize.css": "8.0.1",
"panzoom": "9.4.3", "panzoom": "9.4.3",
"preact": "10.27.2", "preact": "10.27.2",

View File

@ -6,7 +6,8 @@
"sync_version": "اصدار المزامنه:", "sync_version": "اصدار المزامنه:",
"build_date": "تاريخ الانشاء:", "build_date": "تاريخ الانشاء:",
"build_revision": "مراجعة الاصدار:", "build_revision": "مراجعة الاصدار:",
"data_directory": "مجلد البيانات:" "data_directory": "مجلد البيانات:",
"db_version": "اصدار قاعدة البيانات:"
}, },
"toast": { "toast": {
"critical-error": { "critical-error": {
@ -26,6 +27,369 @@
"save": "حفظ" "save": "حفظ"
}, },
"bulk_actions": { "bulk_actions": {
"bulk_actions": "اجراءات جماعية" "bulk_actions": "اجراءات جماعية",
"available_actions": "الاجراءات المتاحة",
"chosen_actions": "الأجراءات المختارة",
"execute_bulk_actions": "تنفيذ الأجراءات الجماعية",
"bulk_actions_executed": "تم تنفيذ الاجراءات الجماعية بنجاح،",
"none_yet": "لايوجد أجراء بعد... اضف اجراء بالنقر على احد الأجراءات المتاحة اعلاه.",
"relations": "العلاقات",
"notes": "الملاحظات",
"other": "أخرى",
"affected_notes": "الملاحظات المتأثرة"
},
"upload_attachments": {
"options": "خيارات",
"upload": "تحميل"
},
"attribute_detail": {
"name": "الاسم",
"value": "قيمة",
"promoted": "تمت ترقيته",
"promoted_alias": "اسم مستعار",
"label_type": "نوع",
"text": "نص",
"date": "تاريخ",
"time": "وقت",
"precision": "دفة",
"digits": "رقم",
"delete": "حذف",
"color_type": "لون",
"multiplicity": "تعددية",
"number": "عدد",
"boolean": "منطقي",
"url": "عنوان الويب",
"inheritable": "قابل للوراثة"
},
"rename_label": {
"to": "الى"
},
"move_note": {
"to": "الى"
},
"add_relation": {
"to": "الى"
},
"rename_relation": {
"to": "الى"
},
"update_relation_target": {
"to": "الى"
},
"attachments_actions": {
"download": "تنزيل"
},
"calendar": {
"week": "أسبوع",
"month": "شهر",
"year": "سنة",
"list": "قائمة",
"today": "اليوم",
"mon": "الأثنين",
"tue": "الثلاثاء",
"wed": "الأربعاء",
"thu": "الخميس",
"fri": "الجمعة",
"sat": "السبت",
"sun": "الأحد",
"january": "يناير",
"march": "مارس",
"april": "ابريل",
"may": "مايو",
"june": "يونيو",
"july": "يوليو",
"august": "أغسطس",
"september": "سبتمبر",
"october": "اكتوبر",
"november": "نوفمبر",
"december": "ديسمبر"
},
"global_menu": {
"menu": "القائمة",
"options": "خيارات",
"advanced": "متقدمة",
"logout": "تسجيل خروج",
"zoom": "تكبير/تصغير"
},
"zpetne_odkazy": {
"relation": "العلاقة"
},
"note_icon": {
"category": "الفئة:",
"search": "بحث:"
},
"basic_properties": {
"language": "اللغة",
"editable": "قابل للتعديل"
},
"book_properties": {
"list": "قائمة",
"expand": "توسيع",
"calendar": "التقويم",
"table": "جدول",
"board": "لوحة",
"grid": "خطوط شبكة",
"collapse": "طي"
},
"file_properties": {
"download": "تنزيل",
"open": "فتح",
"title": "ملف"
},
"image_properties": {
"download": "تنزيل",
"open": "فتح",
"title": "صورة"
},
"note_info_widget": {
"created": "انشاء",
"type": "نوع",
"modified": "معدل",
"calculate": "حساب"
},
"note_paths": {
"search": "بحث",
"archived": "مؤرشف"
},
"script_executor": {
"query": "استعلام",
"script": "برنامج نصي"
},
"search_definition": {
"ancestor": "السلف",
"limit": "الحد الاقصى",
"action": "أجراء",
"search_button": "بحث",
"debug": "تصحيح الاخطاء"
},
"ancestor": {
"label": "السلف",
"depth_label": "العمق"
},
"limit": {
"limit": "الحد الاقصى"
},
"debug": {
"debug": "تصحيح الاخطاء"
},
"order_by": {
"title": "عنوان",
"desc": "تنازلي"
},
"search_string": {
"search_prefix": "بحث:"
},
"sync": {
"title": "مزامنة"
},
"fonts": {
"fonts": "خطوط",
"size": "حجم",
"serif": "خط ومزخرف",
"monospace": "خط بعرض ثابت"
},
"confirm": {
"confirmation": "تأكيد",
"cancel": "الغاء",
"ok": "نعم"
},
"delete_notes": {
"close": "غلق",
"cancel": "الغاء",
"ok": "نعم"
},
"export": {
"close": "غلق",
"export": "تصدير",
"export_note_title": "تصدير الملاحظة",
"export_status": "حالة التصدير"
},
"help": {
"troubleshooting": "أستكشاف الاخطاء واصلاحها",
"other": "أخرى",
"title": "ورقة المراجعة السريعة",
"noteNavigation": "التنقل بين الملاحظات",
"collapseExpand": "طي/توسيع العقدة",
"notSet": "غير محدد",
"collapseSubTree": "طي الشجرة الفرعية",
"tabShortcuts": "أختصارات التبويب",
"creatingNotes": "انشاء الملاحظات",
"selectNote": "تحديد الملاحظة"
},
"import": {
"options": "خيارات",
"import": "استيراد"
},
"include_note": {
"label_note": "ملاحظة"
},
"info": {
"closeButton": "أغلاق",
"okButton": "نعم"
},
"markdown_import": {
"import_button": "أستيراد"
},
"note_type_chooser": {
"templates": "قوالب"
},
"prompt": {
"title": "ترقية",
"ok": "نعم",
"defaultTitle": "ترقية"
},
"protected_session_password": {
"close_label": "أغلاق"
},
"revisions": {
"delete_button": "حذف",
"download_button": "تنزيل",
"restore_button": "أستعادة",
"preview": "معاينة:"
},
"sort_child_notes": {
"title": "عنوان",
"ascending": "تصاعدي",
"descending": "تنازلي",
"folders": "مجلدات",
"sort": "فرز"
},
"recent_changes": {
"undelete_link": "الغاء الحذف"
},
"edited_notes": {
"deleted": "(حذف)"
},
"note_properties": {
"info": "معلومات"
},
"backend_log": {
"refresh": "تحديث"
},
"max_content_width": {
"max_width_unit": "بكسل"
},
"native_title_bar": {
"enabled": "مفعل",
"disabled": "معطل"
},
"theme": {
"theme_label": "السمة",
"layout": "تخطيط",
"layout-vertical-title": "عمودي",
"layout-horizontal-title": "أفقي"
},
"ui-performance": {
"title": "أداء"
},
"ai_llm": {
"progress": "تقدم",
"openai_tab": "OpenAI",
"actions": "أجراءات",
"retry": "أعد المحاولة",
"reprocessing_index": "جار اعادة البناء...",
"never": "ابدٱ",
"agent": {
"processing": "جار المعالجة...",
"thinking": "جار التفكير...",
"loading": "جار التحميل...",
"generating": "جار الانشاء..."
},
"name": "الذكاء الأصطناعي",
"openai": "OpenAI",
"sources": "مصادر"
},
"code_auto_read_only_size": {
"unit": "حروف"
},
"code-editor-options": {
"title": "محرر"
},
"images": {
"images_section_title": "صور",
"max_image_dimensions_unit": "بكسل"
},
"revisions_snapshot_limit": {
"snapshot_number_limit_unit": "لقطات"
},
"search_engine": {
"bing": "Bing",
"duckduckgo": "DuckDuckGo",
"google": "جوجل",
"save_button": "حفظ"
},
"heading_style": {
"plain": "بسيط"
},
"text_auto_read_only_size": {
"unit": "حروف"
},
"i18n": {
"language": "لغة",
"sunday": "الأحد",
"monday": "الأثنين"
},
"backup": {
"path": "مسار"
},
"etapi": {
"wiki": "ويكي",
"created": "تم الأنشاء",
"actions": "أجراءات"
},
"password": {
"heading": "كلمة السر",
"wiki": "ويكي"
},
"shortcuts": {
"shortcuts": "أختصارات",
"description": "الوصف"
},
"sync_2": {
"timeout_unit": "ميلي ثانية",
"note": "ملاحظة",
"save": "حفظ",
"help": "المساعدة"
},
"api_log": {
"close": "أغلاق"
},
"bookmark_switch": {
"bookmark": "علامة مرجعية"
},
"editability_select": {
"auto": "تلقائي",
"read_only": "قراءة-فقط"
},
"tab_row": {
"close": "اغلاق"
},
"toc": {
"options": "خيارات"
},
"tasks": {
"due": {
"yesterday": "أمس"
}
},
"code_theme": {
"title": "المظهر"
},
"table_view": {
"sort-column-ascending": "تصاعدي",
"sort-column-descending": "تنازلي",
"new-column-relation": "العلاقة"
},
"modal": {
"close": "اغلاق"
},
"call_to_action": {
"dismiss": "تجاهل"
},
"units": {
"percentage": "%"
},
"clone_to": {
"prefix_optional": "بادئة (اختياري)"
} }
} }

View File

@ -0,0 +1,8 @@
{
"about": {
"title": "Tentang Trilium Notes",
"homepage": "Halaman utama:",
"app_version": "Versi Aplikasi:",
"db_version": "Versi DB:"
}
}

View File

@ -67,7 +67,8 @@
"switch_to_mobile_version": "モバイル版に切り替え", "switch_to_mobile_version": "モバイル版に切り替え",
"switch_to_desktop_version": "デスクトップ版に切り替え", "switch_to_desktop_version": "デスクトップ版に切り替え",
"configure_launchbar": "ランチャーバーの設定", "configure_launchbar": "ランチャーバーの設定",
"show_shared_notes_subtree": "共有ノートのサブツリーを表示" "show_shared_notes_subtree": "共有ノートのサブツリーを表示",
"update_available": "バージョン {{latestVersion}} が利用可能です。クリックしてダウンロードしてください。"
}, },
"left_pane_toggle": { "left_pane_toggle": {
"show_panel": "パネルを表示", "show_panel": "パネルを表示",
@ -1290,7 +1291,7 @@
"cut": "カット", "cut": "カット",
"copy": "コピー", "copy": "コピー",
"copy-link": "リンクをコピー", "copy-link": "リンクをコピー",
"paste": "ペースト", "paste": "貼り付け",
"paste-as-plain-text": "プレーンテキストで貼り付け", "paste-as-plain-text": "プレーンテキストで貼り付け",
"search_online": "{{searchEngine}} で \"{{term}}\" を検索" "search_online": "{{searchEngine}} で \"{{term}}\" を検索"
}, },

View File

@ -34,15 +34,15 @@
"@triliumnext/commons": "workspace:*", "@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*", "@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "13.0.1", "copy-webpack-plugin": "13.0.1",
"electron": "38.2.1", "electron": "38.2.2",
"@electron-forge/cli": "7.9.0", "@electron-forge/cli": "7.10.0",
"@electron-forge/maker-deb": "7.9.0", "@electron-forge/maker-deb": "7.10.0",
"@electron-forge/maker-dmg": "7.9.0", "@electron-forge/maker-dmg": "7.10.0",
"@electron-forge/maker-flatpak": "7.9.0", "@electron-forge/maker-flatpak": "7.10.0",
"@electron-forge/maker-rpm": "7.9.0", "@electron-forge/maker-rpm": "7.10.0",
"@electron-forge/maker-squirrel": "7.9.0", "@electron-forge/maker-squirrel": "7.10.0",
"@electron-forge/maker-zip": "7.9.0", "@electron-forge/maker-zip": "7.10.0",
"@electron-forge/plugin-auto-unpack-natives": "7.9.0", "@electron-forge/plugin-auto-unpack-natives": "7.10.0",
"prebuild-install": "7.1.3" "prebuild-install": "7.1.3"
} }
} }

View File

@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*", "@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4", "@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "13.0.1", "copy-webpack-plugin": "13.0.1",
"electron": "38.2.1", "electron": "38.2.2",
"fs-extra": "11.3.2" "fs-extra": "11.3.2"
}, },
"scripts": { "scripts": {

View File

@ -81,7 +81,7 @@
"debounce": "2.2.0", "debounce": "2.2.0",
"debug": "4.4.3", "debug": "4.4.3",
"ejs": "3.1.10", "ejs": "3.1.10",
"electron": "38.2.1", "electron": "38.2.2",
"electron-debug": "4.1.0", "electron-debug": "4.1.0",
"electron-window-state": "5.0.3", "electron-window-state": "5.0.3",
"escape-html": "1.0.3", "escape-html": "1.0.3",
@ -105,7 +105,7 @@
"is-svg": "6.1.0", "is-svg": "6.1.0",
"jimp": "1.6.0", "jimp": "1.6.0",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"marked": "16.3.0", "marked": "16.4.0",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"multer": "2.0.2", "multer": "2.0.2",
"normalize-strings": "1.1.1", "normalize-strings": "1.1.1",

View File

@ -18,6 +18,134 @@
"copy-notes-to-clipboard": "نسخ الملاحظات المحددة الى الحافظة", "copy-notes-to-clipboard": "نسخ الملاحظات المحددة الى الحافظة",
"paste-notes-from-clipboard": "لصق الملاحظا من الحافظة الى الملاحظة الحالية", "paste-notes-from-clipboard": "لصق الملاحظا من الحافظة الى الملاحظة الحالية",
"cut-notes-to-clipboard": "قص الملاحظات المحددة الى الحافظة", "cut-notes-to-clipboard": "قص الملاحظات المحددة الى الحافظة",
"select-all-notes-in-parent": "تحديد جميع الملاحظات من مستوى الملاحظة الحالي" "select-all-notes-in-parent": "تحديد جميع الملاحظات من مستوى الملاحظة الحالي",
"back-in-note-history": "الانتقال الى الملاحظة السابقة في السجل",
"forward-in-note-history": "الانتقال الى الملاحظة التالية في السجل",
"scroll-to-active-note": "تمرير شجرة الملاحظات الى الملاحظة النشطة",
"search-in-subtree": "البحث عن الملاحظات في الشجرة الفرعية للملاحظة النشطة",
"expand-subtree": "توسيع الشجرة الفرعية للملاحظة الحالية",
"create-note-into-inbox": "انشاء ملاحظة في صندوق الوارد (اذا كان معرفا) او في ملاحظة اليوم",
"move-note-up-in-hierarchy": "نقل الملاحظة للاعلى في التسلسل الهرمي",
"move-note-down-in-hierarchy": "نقل الملاحظة للاسفل في التسلسل الهرمي",
"edit-note-title": "الانتقال من شجرة الملاحظات إلى تفاصيل الملاحظة وتحرير العنوان",
"edit-branch-prefix": "عرض مربع حوار \"تعديل بادئة الفرع\"",
"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": "فتح نافذة جديدة فارغة",
"first-tab": "تنشيط التبويب الاول في القائمة",
"second-tab": "تنشيط التبويب الثاني في القائمة",
"third-tab": "تنشيط التبويب الثالث في الثائمة",
"fourth-tab": "تنشيط التبويب الرابع في القائمة",
"fifth-tab": "تنشيط التبويب الخامس في القائمة",
"sixth-tab": "تنشيط التبويب السادس في القائمة",
"seventh-tab": "تنشيط التبويب السابع في القائمة",
"eight-tab": "تنشيط التبويب الثامن في القائمة",
"ninth-tab": "تنشيط التبويب التاسع في القائمة",
"last-tab": "تنشيط التبويب الاخير في القائمة",
"other": "أخرى",
"dialogs": "مربعات الحوار"
},
"setup_sync-from-server": {
"note": "ملاحظة:",
"password": "كلمة السر",
"password-placeholder": "كلمة السر",
"back": "رجوع",
"server-host-placeholder": "https://<hostname>:<port>",
"proxy-server-placeholder": "https://<hostname>:<port>"
},
"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": "بحث:"
},
"hidden-subtree": {
"calendar-title": "تقويم",
"bookmarks-title": "العلامات المرجعية",
"settings-title": "أعدادات",
"options-title": "خيارات",
"appearance-title": "المظهر",
"shortcuts-title": "أختصارات",
"images-title": "صور",
"password-title": "كلمة السر",
"backup-title": "نسخة أحتياطية",
"sync-title": "مزامنة",
"other": "أخرى",
"advanced-title": "متقدم",
"inbox-title": "صندوق الوارد",
"spacer-title": "فاصل",
"spellcheck-title": "التدقيق الاملائي",
"multi-factor-authentication-title": "المصادقة متعددة العوامل"
},
"tray": {
"bookmarks": "العلامات المرجعية"
},
"modals": {
"error_title": "خطأ"
},
"share_theme": {
"search_placeholder": "بحث...",
"subpages": "الصفحات الفرعية:",
"expand": "توسيع"
},
"hidden_subtree_templates": {
"description": "الوصف",
"calendar": "التقويم",
"table": "جدول",
"geolocation": "الموقع الجغرافي",
"board": "لوحة",
"status": "الحالة",
"board_status_done": "تمت"
},
"login": {
"title": "تسجيل الدخول",
"password": "كلمة السر",
"button": "تسجيل الدخول"
},
"set_password": {
"password": "كلمة السر"
},
"setup": {
"next": "التالي",
"title": "تثبيت"
},
"setup_sync-from-desktop": {
"step6-here": "هنا"
},
"setup_sync-in-progress": {
"outstanding-items-default": "غير متوفر"
},
"share_page": {
"parent": "الأصل:"
},
"notes": {
"duplicate-note-suffix": "(مكرر)"
} }
} }

View File

@ -0,0 +1,8 @@
{
"keyboard_actions": {
"back-in-note-history": "Navigasi ke catatan sebelumnya di history",
"forward-in-note-history": "Navigasi ke catatan selanjutnya di history",
"open-jump-to-note-dialog": "Buka dialog \"Menuju ke catatan\"",
"open-command-palette": "Buka palet perintah"
}
}

View File

@ -1,4 +1,5 @@
import { BNote } from "../../services/backend_script_entrypoint"; import { BNote } from "../../services/backend_script_entrypoint";
import cls from "../../services/cls";
import { buildNote } from "../../test/becca_easy_mocking"; import { buildNote } from "../../test/becca_easy_mocking";
import { processContent } from "./clipper"; import { processContent } from "./clipper";
@ -6,7 +7,9 @@ let note!: BNote;
describe("processContent", () => { describe("processContent", () => {
beforeAll(() => { beforeAll(() => {
note = buildNote({}); note = buildNote({
content: "Hi there"
});
note.saveAttachment = () => {}; note.saveAttachment = () => {};
vi.mock("../../services/image.js", () => ({ vi.mock("../../services/image.js", () => ({
default: { default: {
@ -21,29 +24,29 @@ describe("processContent", () => {
}); });
it("processes basic note", () => { it("processes basic note", () => {
const processed = processContent([], note, "<p>Hello world.</p>"); const processed = cls.init(() => processContent([], note, "<p>Hello world.</p>"));
expect(processed).toStrictEqual("<p>Hello world.</p>") expect(processed).toStrictEqual("<p>Hello world.</p>")
}); });
it("processes plain text", () => { it("processes plain text", () => {
const processed = processContent([], note, "Hello world."); const processed = cls.init(() => processContent([], note, "Hello world."));
expect(processed).toStrictEqual("<p>Hello world.</p>") expect(processed).toStrictEqual("<p>Hello world.</p>")
}); });
it("replaces images", () => { it("replaces images", () => {
const processed = processContent( const processed = cls.init(() => processContent(
[{"imageId":"OKZxZA3MonZJkwFcEhId","src":"inline.png","dataUrl":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAQCAYAAADESFVDAAAAF0lEQVQoU2P8DwQMBADjqKLRIGAgKggAzHs/0SoYCGwAAAAASUVORK5CYII="}], [{"imageId":"OKZxZA3MonZJkwFcEhId","src":"inline.png","dataUrl":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAQCAYAAADESFVDAAAAF0lEQVQoU2P8DwQMBADjqKLRIGAgKggAzHs/0SoYCGwAAAAASUVORK5CYII="}],
note, `<img src="OKZxZA3MonZJkwFcEhId">` note, `<img src="OKZxZA3MonZJkwFcEhId">`
); ));
expect(processed).toStrictEqual(`<img src="api/attachments/foo/image/encodedTitle" >`); expect(processed).toStrictEqual(`<img src="api/attachments/foo/image/encodedTitle" >`);
}); });
it("skips over non-data images", () => { it("skips over non-data images", () => {
for (const url of [ "foo", "" ]) { for (const url of [ "foo", "" ]) {
const processed = processContent( const processed = cls.init(() => processContent(
[{"imageId":"OKZxZA3MonZJkwFcEhId","src":"inline.png","dataUrl": url}], [{"imageId":"OKZxZA3MonZJkwFcEhId","src":"inline.png","dataUrl": url}],
note, `<img src="OKZxZA3MonZJkwFcEhId">` note, `<img src="OKZxZA3MonZJkwFcEhId">`
); ));
expect(processed).toStrictEqual(`<img src="OKZxZA3MonZJkwFcEhId" >`); expect(processed).toStrictEqual(`<img src="OKZxZA3MonZJkwFcEhId" >`);
} }
}); });

View File

@ -3,7 +3,7 @@ import { beforeAll, describe, expect, it, vi, beforeEach, afterEach } from "vite
import supertest from "supertest"; import supertest from "supertest";
import config from "../../services/config.js"; import config from "../../services/config.js";
import { refreshAuth } from "../../services/auth.js"; import { refreshAuth } from "../../services/auth.js";
import type { WebSocket } from 'ws'; import { sleepFor } from "@triliumnext/commons";
// Mock the CSRF protection middleware to allow tests to pass // Mock the CSRF protection middleware to allow tests to pass
vi.mock("../csrf_protection.js", () => ({ vi.mock("../csrf_protection.js", () => ({
@ -72,7 +72,11 @@ vi.mock("../../services/options.js", () => ({
getOptionMap: vi.fn(() => new Map()), getOptionMap: vi.fn(() => new Map()),
createOption: vi.fn(), createOption: vi.fn(),
getOption: vi.fn(() => '0'), getOption: vi.fn(() => '0'),
getOptionOrNull: vi.fn(() => null) getOptionOrNull: vi.fn(() => null),
getOptionInt: vi.fn(name => {
if (name === "protectedSessionTimeout") return Number.MAX_SAFE_INTEGER;
return 0;
})
} }
})); }));
@ -499,6 +503,7 @@ describe("LLM API Tests", () => {
const ws = (await import("../../services/ws.js")).default; const ws = (await import("../../services/ws.js")).default;
// Verify thinking message was sent // Verify thinking message was sent
await sleepFor(1_000);
expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({ expect(ws.sendMessageToAllClients).toHaveBeenCalledWith({
type: 'llm-stream', type: 'llm-stream',
chatNoteId: testChatId, chatNoteId: testChatId,

View File

@ -12,7 +12,11 @@ import type { AIService, ChatCompletionOptions, Message } from './ai_interface.j
vi.mock('../options.js', () => ({ vi.mock('../options.js', () => ({
default: { default: {
getOption: vi.fn(), getOption: vi.fn(),
getOptionBool: vi.fn() getOptionBool: vi.fn(),
getOptionInt: vi.fn(name => {
if (name === "protectedSessionTimeout") return Number.MAX_SAFE_INTEGER;
return 0;
})
} }
})); }));
@ -110,26 +114,26 @@ describe('AIServiceManager', () => {
describe('getSelectedProviderAsync', () => { describe('getSelectedProviderAsync', () => {
it('should return the selected provider', async () => { it('should return the selected provider', async () => {
vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce('openai'); vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce('openai');
const result = await manager.getSelectedProviderAsync(); const result = await manager.getSelectedProviderAsync();
expect(result).toBe('openai'); expect(result).toBe('openai');
expect(configHelpers.getSelectedProvider).toHaveBeenCalled(); expect(configHelpers.getSelectedProvider).toHaveBeenCalled();
}); });
it('should return null if no provider is selected', async () => { it('should return null if no provider is selected', async () => {
vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce(null); vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce(null);
const result = await manager.getSelectedProviderAsync(); const result = await manager.getSelectedProviderAsync();
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('should handle errors and return null', async () => { it('should handle errors and return null', async () => {
vi.mocked(configHelpers.getSelectedProvider).mockRejectedValueOnce(new Error('Config error')); vi.mocked(configHelpers.getSelectedProvider).mockRejectedValueOnce(new Error('Config error'));
const result = await manager.getSelectedProviderAsync(); const result = await manager.getSelectedProviderAsync();
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
@ -141,9 +145,9 @@ describe('AIServiceManager', () => {
errors: [], errors: [],
warnings: [] warnings: []
}); });
const result = await manager.validateConfiguration(); const result = await manager.validateConfiguration();
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -153,9 +157,9 @@ describe('AIServiceManager', () => {
errors: ['Missing API key', 'Invalid model'], errors: ['Missing API key', 'Invalid model'],
warnings: [] warnings: []
}); });
const result = await manager.validateConfiguration(); const result = await manager.validateConfiguration();
expect(result).toContain('There are issues with your AI configuration'); expect(result).toContain('There are issues with your AI configuration');
expect(result).toContain('Missing API key'); expect(result).toContain('Missing API key');
expect(result).toContain('Invalid model'); expect(result).toContain('Invalid model');
@ -167,9 +171,9 @@ describe('AIServiceManager', () => {
errors: [], errors: [],
warnings: ['Model not optimal'] warnings: ['Model not optimal']
}); });
const result = await manager.validateConfiguration(); const result = await manager.validateConfiguration();
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
@ -178,21 +182,21 @@ describe('AIServiceManager', () => {
it('should create and return the selected provider service', async () => { it('should create and return the selected provider service', async () => {
vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce('openai'); vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce('openai');
vi.mocked(options.getOption).mockReturnValueOnce('test-api-key'); vi.mocked(options.getOption).mockReturnValueOnce('test-api-key');
const mockService = { const mockService = {
isAvailable: vi.fn().mockReturnValue(true), isAvailable: vi.fn().mockReturnValue(true),
generateChatCompletion: vi.fn() generateChatCompletion: vi.fn()
}; };
vi.mocked(OpenAIService).mockImplementationOnce(() => mockService as any); vi.mocked(OpenAIService).mockImplementationOnce(() => mockService as any);
const result = await manager.getOrCreateAnyService(); const result = await manager.getOrCreateAnyService();
expect(result).toBe(mockService); expect(result).toBe(mockService);
}); });
it('should throw error if no provider is selected', async () => { it('should throw error if no provider is selected', async () => {
vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce(null); vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce(null);
await expect(manager.getOrCreateAnyService()).rejects.toThrow( await expect(manager.getOrCreateAnyService()).rejects.toThrow(
'No AI provider is selected' 'No AI provider is selected'
); );
@ -201,7 +205,7 @@ describe('AIServiceManager', () => {
it('should throw error if selected provider is not available', async () => { it('should throw error if selected provider is not available', async () => {
vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce('openai'); vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce('openai');
vi.mocked(options.getOption).mockReturnValueOnce(''); // No API key vi.mocked(options.getOption).mockReturnValueOnce(''); // No API key
await expect(manager.getOrCreateAnyService()).rejects.toThrow( await expect(manager.getOrCreateAnyService()).rejects.toThrow(
'Selected AI provider (openai) is not available' 'Selected AI provider (openai) is not available'
); );
@ -211,17 +215,17 @@ describe('AIServiceManager', () => {
describe('isAnyServiceAvailable', () => { describe('isAnyServiceAvailable', () => {
it('should return true if any provider is available', () => { it('should return true if any provider is available', () => {
vi.mocked(options.getOption).mockReturnValueOnce('test-api-key'); vi.mocked(options.getOption).mockReturnValueOnce('test-api-key');
const result = manager.isAnyServiceAvailable(); const result = manager.isAnyServiceAvailable();
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false if no providers are available', () => { it('should return false if no providers are available', () => {
vi.mocked(options.getOption).mockReturnValue(''); vi.mocked(options.getOption).mockReturnValue('');
const result = manager.isAnyServiceAvailable(); const result = manager.isAnyServiceAvailable();
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
@ -232,18 +236,18 @@ describe('AIServiceManager', () => {
.mockReturnValueOnce('openai-key') .mockReturnValueOnce('openai-key')
.mockReturnValueOnce('anthropic-key') .mockReturnValueOnce('anthropic-key')
.mockReturnValueOnce(''); // No Ollama URL .mockReturnValueOnce(''); // No Ollama URL
const result = manager.getAvailableProviders(); const result = manager.getAvailableProviders();
expect(result).toEqual(['openai', 'anthropic']); expect(result).toEqual(['openai', 'anthropic']);
}); });
it('should include already created services', () => { it('should include already created services', () => {
// Mock that OpenAI has API key configured // Mock that OpenAI has API key configured
vi.mocked(options.getOption).mockReturnValueOnce('test-api-key'); vi.mocked(options.getOption).mockReturnValueOnce('test-api-key');
const result = manager.getAvailableProviders(); const result = manager.getAvailableProviders();
expect(result).toContain('openai'); expect(result).toContain('openai');
}); });
}); });
@ -255,23 +259,23 @@ describe('AIServiceManager', () => {
it('should generate completion with selected provider', async () => { it('should generate completion with selected provider', async () => {
vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce('openai'); vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce('openai');
// Mock the getAvailableProviders to include openai // Mock the getAvailableProviders to include openai
vi.mocked(options.getOption) vi.mocked(options.getOption)
.mockReturnValueOnce('test-api-key') // for availability check .mockReturnValueOnce('test-api-key') // for availability check
.mockReturnValueOnce('') // for anthropic .mockReturnValueOnce('') // for anthropic
.mockReturnValueOnce('') // for ollama .mockReturnValueOnce('') // for ollama
.mockReturnValueOnce('test-api-key'); // for service creation .mockReturnValueOnce('test-api-key'); // for service creation
const mockResponse = { content: 'Hello response' }; const mockResponse = { content: 'Hello response' };
const mockService = { const mockService = {
isAvailable: vi.fn().mockReturnValue(true), isAvailable: vi.fn().mockReturnValue(true),
generateChatCompletion: vi.fn().mockResolvedValueOnce(mockResponse) generateChatCompletion: vi.fn().mockResolvedValueOnce(mockResponse)
}; };
vi.mocked(OpenAIService).mockImplementationOnce(() => mockService as any); vi.mocked(OpenAIService).mockImplementationOnce(() => mockService as any);
const result = await manager.generateChatCompletion(messages); const result = await manager.generateChatCompletion(messages);
expect(result).toBe(mockResponse); expect(result).toBe(mockResponse);
expect(mockService.generateChatCompletion).toHaveBeenCalledWith(messages, {}); expect(mockService.generateChatCompletion).toHaveBeenCalledWith(messages, {});
}); });
@ -283,28 +287,28 @@ describe('AIServiceManager', () => {
modelId: 'gpt-4', modelId: 'gpt-4',
fullIdentifier: 'openai:gpt-4' fullIdentifier: 'openai:gpt-4'
}); });
// Mock the getAvailableProviders to include openai // Mock the getAvailableProviders to include openai
vi.mocked(options.getOption) vi.mocked(options.getOption)
.mockReturnValueOnce('test-api-key') // for availability check .mockReturnValueOnce('test-api-key') // for availability check
.mockReturnValueOnce('') // for anthropic .mockReturnValueOnce('') // for anthropic
.mockReturnValueOnce('') // for ollama .mockReturnValueOnce('') // for ollama
.mockReturnValueOnce('test-api-key'); // for service creation .mockReturnValueOnce('test-api-key'); // for service creation
const mockResponse = { content: 'Hello response' }; const mockResponse = { content: 'Hello response' };
const mockService = { const mockService = {
isAvailable: vi.fn().mockReturnValue(true), isAvailable: vi.fn().mockReturnValue(true),
generateChatCompletion: vi.fn().mockResolvedValueOnce(mockResponse) generateChatCompletion: vi.fn().mockResolvedValueOnce(mockResponse)
}; };
vi.mocked(OpenAIService).mockImplementationOnce(() => mockService as any); vi.mocked(OpenAIService).mockImplementationOnce(() => mockService as any);
const result = await manager.generateChatCompletion(messages, { const result = await manager.generateChatCompletion(messages, {
model: 'openai:gpt-4' model: 'openai:gpt-4'
}); });
expect(result).toBe(mockResponse); expect(result).toBe(mockResponse);
expect(mockService.generateChatCompletion).toHaveBeenCalledWith( expect(mockService.generateChatCompletion).toHaveBeenCalledWith(
messages, messages,
{ model: 'gpt-4' } { model: 'gpt-4' }
); );
}); });
@ -317,7 +321,7 @@ describe('AIServiceManager', () => {
it('should throw error if no provider selected', async () => { it('should throw error if no provider selected', async () => {
vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce(null); vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce(null);
await expect(manager.generateChatCompletion(messages)).rejects.toThrow( await expect(manager.generateChatCompletion(messages)).rejects.toThrow(
'No AI provider is selected' 'No AI provider is selected'
); );
@ -330,13 +334,13 @@ describe('AIServiceManager', () => {
modelId: 'claude-3', modelId: 'claude-3',
fullIdentifier: 'anthropic:claude-3' fullIdentifier: 'anthropic:claude-3'
}); });
// Mock that openai is available // Mock that openai is available
vi.mocked(options.getOption) vi.mocked(options.getOption)
.mockReturnValueOnce('test-api-key') // for availability check .mockReturnValueOnce('test-api-key') // for availability check
.mockReturnValueOnce('') // for anthropic .mockReturnValueOnce('') // for anthropic
.mockReturnValueOnce(''); // for ollama .mockReturnValueOnce(''); // for ollama
await expect( await expect(
manager.generateChatCompletion(messages, { model: 'anthropic:claude-3' }) manager.generateChatCompletion(messages, { model: 'anthropic:claude-3' })
).rejects.toThrow( ).rejects.toThrow(
@ -348,9 +352,9 @@ describe('AIServiceManager', () => {
describe('getAIEnabledAsync', () => { describe('getAIEnabledAsync', () => {
it('should return AI enabled status', async () => { it('should return AI enabled status', async () => {
vi.mocked(configHelpers.isAIEnabled).mockResolvedValueOnce(true); vi.mocked(configHelpers.isAIEnabled).mockResolvedValueOnce(true);
const result = await manager.getAIEnabledAsync(); const result = await manager.getAIEnabledAsync();
expect(result).toBe(true); expect(result).toBe(true);
expect(configHelpers.isAIEnabled).toHaveBeenCalled(); expect(configHelpers.isAIEnabled).toHaveBeenCalled();
}); });
@ -359,9 +363,9 @@ describe('AIServiceManager', () => {
describe('getAIEnabled', () => { describe('getAIEnabled', () => {
it('should return AI enabled status synchronously', () => { it('should return AI enabled status synchronously', () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(true); vi.mocked(options.getOptionBool).mockReturnValueOnce(true);
const result = manager.getAIEnabled(); const result = manager.getAIEnabled();
expect(result).toBe(true); expect(result).toBe(true);
expect(options.getOptionBool).toHaveBeenCalledWith('aiEnabled'); expect(options.getOptionBool).toHaveBeenCalledWith('aiEnabled');
}); });
@ -370,17 +374,17 @@ describe('AIServiceManager', () => {
describe('initialize', () => { describe('initialize', () => {
it('should initialize if AI is enabled', async () => { it('should initialize if AI is enabled', async () => {
vi.mocked(configHelpers.isAIEnabled).mockResolvedValueOnce(true); vi.mocked(configHelpers.isAIEnabled).mockResolvedValueOnce(true);
await manager.initialize(); await manager.initialize();
expect(configHelpers.isAIEnabled).toHaveBeenCalled(); expect(configHelpers.isAIEnabled).toHaveBeenCalled();
}); });
it('should not initialize if AI is disabled', async () => { it('should not initialize if AI is disabled', async () => {
vi.mocked(configHelpers.isAIEnabled).mockResolvedValueOnce(false); vi.mocked(configHelpers.isAIEnabled).mockResolvedValueOnce(false);
await manager.initialize(); await manager.initialize();
expect(configHelpers.isAIEnabled).toHaveBeenCalled(); expect(configHelpers.isAIEnabled).toHaveBeenCalled();
}); });
}); });
@ -388,36 +392,36 @@ describe('AIServiceManager', () => {
describe('getService', () => { describe('getService', () => {
it('should return service for specified provider', async () => { it('should return service for specified provider', async () => {
vi.mocked(options.getOption).mockReturnValueOnce('test-api-key'); vi.mocked(options.getOption).mockReturnValueOnce('test-api-key');
const mockService = { const mockService = {
isAvailable: vi.fn().mockReturnValue(true), isAvailable: vi.fn().mockReturnValue(true),
generateChatCompletion: vi.fn() generateChatCompletion: vi.fn()
}; };
vi.mocked(OpenAIService).mockImplementationOnce(() => mockService as any); vi.mocked(OpenAIService).mockImplementationOnce(() => mockService as any);
const result = await manager.getService('openai'); const result = await manager.getService('openai');
expect(result).toBe(mockService); expect(result).toBe(mockService);
}); });
it('should return selected provider service if no provider specified', async () => { it('should return selected provider service if no provider specified', async () => {
vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce('anthropic'); vi.mocked(configHelpers.getSelectedProvider).mockResolvedValueOnce('anthropic');
vi.mocked(options.getOption).mockReturnValueOnce('test-api-key'); vi.mocked(options.getOption).mockReturnValueOnce('test-api-key');
const mockService = { const mockService = {
isAvailable: vi.fn().mockReturnValue(true), isAvailable: vi.fn().mockReturnValue(true),
generateChatCompletion: vi.fn() generateChatCompletion: vi.fn()
}; };
vi.mocked(AnthropicService).mockImplementationOnce(() => mockService as any); vi.mocked(AnthropicService).mockImplementationOnce(() => mockService as any);
const result = await manager.getService(); const result = await manager.getService();
expect(result).toBe(mockService); expect(result).toBe(mockService);
}); });
it('should throw error if specified provider not available', async () => { it('should throw error if specified provider not available', async () => {
vi.mocked(options.getOption).mockReturnValueOnce(''); // No API key vi.mocked(options.getOption).mockReturnValueOnce(''); // No API key
await expect(manager.getService('openai')).rejects.toThrow( await expect(manager.getService('openai')).rejects.toThrow(
'Specified provider openai is not available' 'Specified provider openai is not available'
); );
@ -427,17 +431,17 @@ describe('AIServiceManager', () => {
describe('getSelectedProvider', () => { describe('getSelectedProvider', () => {
it('should return selected provider synchronously', () => { it('should return selected provider synchronously', () => {
vi.mocked(options.getOption).mockReturnValueOnce('anthropic'); vi.mocked(options.getOption).mockReturnValueOnce('anthropic');
const result = manager.getSelectedProvider(); const result = manager.getSelectedProvider();
expect(result).toBe('anthropic'); expect(result).toBe('anthropic');
}); });
it('should return default provider if none selected', () => { it('should return default provider if none selected', () => {
vi.mocked(options.getOption).mockReturnValueOnce(''); vi.mocked(options.getOption).mockReturnValueOnce('');
const result = manager.getSelectedProvider(); const result = manager.getSelectedProvider();
expect(result).toBe('openai'); expect(result).toBe('openai');
}); });
}); });
@ -446,18 +450,18 @@ describe('AIServiceManager', () => {
it('should return true if provider service is available', () => { it('should return true if provider service is available', () => {
// Mock that OpenAI has API key configured // Mock that OpenAI has API key configured
vi.mocked(options.getOption).mockReturnValueOnce('test-api-key'); vi.mocked(options.getOption).mockReturnValueOnce('test-api-key');
const result = manager.isProviderAvailable('openai'); const result = manager.isProviderAvailable('openai');
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false if provider service not created', () => { it('should return false if provider service not created', () => {
// Mock that OpenAI has no API key configured // Mock that OpenAI has no API key configured
vi.mocked(options.getOption).mockReturnValueOnce(''); vi.mocked(options.getOption).mockReturnValueOnce('');
const result = manager.isProviderAvailable('openai'); const result = manager.isProviderAvailable('openai');
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
@ -467,13 +471,13 @@ describe('AIServiceManager', () => {
// Since getProviderMetadata only returns metadata for the current active provider, // Since getProviderMetadata only returns metadata for the current active provider,
// and we don't have a current provider set, it should return null // and we don't have a current provider set, it should return null
const result = manager.getProviderMetadata('openai'); const result = manager.getProviderMetadata('openai');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('should return null for non-existing provider', () => { it('should return null for non-existing provider', () => {
const result = manager.getProviderMetadata('openai'); const result = manager.getProviderMetadata('openai');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
@ -485,4 +489,4 @@ describe('AIServiceManager', () => {
expect(manager).toBeDefined(); expect(manager).toBeDefined();
}); });
}); });
}); });

View File

@ -15,7 +15,11 @@ vi.mock('../../log.js', () => ({
vi.mock('../../options.js', () => ({ vi.mock('../../options.js', () => ({
default: { default: {
getOption: vi.fn(), getOption: vi.fn(),
getOptionBool: vi.fn() getOptionBool: vi.fn(),
getOptionInt: vi.fn(name => {
if (name === "protectedSessionTimeout") return Number.MAX_SAFE_INTEGER;
return 0;
})
} }
})); }));
@ -66,14 +70,14 @@ describe('RestChatService', () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
// Get mocked modules // Get mocked modules
mockOptions = (await import('../../options.js')).default; mockOptions = (await import('../../options.js')).default;
mockAiServiceManager = (await import('../ai_service_manager.js')).default; mockAiServiceManager = (await import('../ai_service_manager.js')).default;
mockChatStorageService = (await import('../chat_storage_service.js')).default; mockChatStorageService = (await import('../chat_storage_service.js')).default;
restChatService = (await import('./rest_chat_service.js')).default; restChatService = (await import('./rest_chat_service.js')).default;
// Setup mock request and response // Setup mock request and response
mockReq = { mockReq = {
params: {}, params: {},
@ -81,7 +85,7 @@ describe('RestChatService', () => {
query: {}, query: {},
method: 'POST' method: 'POST'
}; };
mockRes = { mockRes = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(),
@ -240,7 +244,7 @@ describe('RestChatService', () => {
it('should handle GET request with stream parameter', async () => { it('should handle GET request with stream parameter', async () => {
mockReq.method = 'GET'; mockReq.method = 'GET';
mockReq.query = { mockReq.query = {
stream: 'true', stream: 'true',
useAdvancedContext: 'true', useAdvancedContext: 'true',
showThinking: 'false' showThinking: 'false'
@ -419,4 +423,4 @@ describe('RestChatService', () => {
expect(mockChatStorageService.getChat).toHaveBeenCalledWith('chat-123'); expect(mockChatStorageService.getChat).toHaveBeenCalledWith('chat-123');
}); });
}); });
}); });

View File

@ -18,7 +18,11 @@ vi.mock('./configuration_manager.js', () => ({
vi.mock('../../options.js', () => ({ vi.mock('../../options.js', () => ({
default: { default: {
getOption: vi.fn(), getOption: vi.fn(),
getOptionBool: vi.fn() getOptionBool: vi.fn(),
getOptionInt: vi.fn(name => {
if (name === "protectedSessionTimeout") return Number.MAX_SAFE_INTEGER;
return 0;
})
} }
})); }));
@ -42,26 +46,26 @@ describe('configuration_helpers', () => {
describe('getSelectedProvider', () => { describe('getSelectedProvider', () => {
it('should return the selected provider', async () => { it('should return the selected provider', async () => {
vi.mocked(optionService.getOption).mockReturnValueOnce('openai'); vi.mocked(optionService.getOption).mockReturnValueOnce('openai');
const result = await configHelpers.getSelectedProvider(); const result = await configHelpers.getSelectedProvider();
expect(result).toBe('openai'); expect(result).toBe('openai');
expect(optionService.getOption).toHaveBeenCalledWith('aiSelectedProvider'); expect(optionService.getOption).toHaveBeenCalledWith('aiSelectedProvider');
}); });
it('should return null if no provider is selected', async () => { it('should return null if no provider is selected', async () => {
vi.mocked(optionService.getOption).mockReturnValueOnce(''); vi.mocked(optionService.getOption).mockReturnValueOnce('');
const result = await configHelpers.getSelectedProvider(); const result = await configHelpers.getSelectedProvider();
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('should handle invalid provider and return null', async () => { it('should handle invalid provider and return null', async () => {
vi.mocked(optionService.getOption).mockReturnValueOnce('invalid-provider'); vi.mocked(optionService.getOption).mockReturnValueOnce('invalid-provider');
const result = await configHelpers.getSelectedProvider(); const result = await configHelpers.getSelectedProvider();
expect(result).toBe('invalid-provider' as ProviderType); expect(result).toBe('invalid-provider' as ProviderType);
}); });
}); });
@ -69,7 +73,7 @@ describe('configuration_helpers', () => {
describe('parseModelIdentifier', () => { describe('parseModelIdentifier', () => {
it('should parse model identifier directly', () => { it('should parse model identifier directly', () => {
const result = configHelpers.parseModelIdentifier('openai:gpt-4'); const result = configHelpers.parseModelIdentifier('openai:gpt-4');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
provider: 'openai', provider: 'openai',
modelId: 'gpt-4', modelId: 'gpt-4',
@ -79,7 +83,7 @@ describe('configuration_helpers', () => {
it('should handle model without provider', () => { it('should handle model without provider', () => {
const result = configHelpers.parseModelIdentifier('gpt-4'); const result = configHelpers.parseModelIdentifier('gpt-4');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
modelId: 'gpt-4', modelId: 'gpt-4',
fullIdentifier: 'gpt-4' fullIdentifier: 'gpt-4'
@ -88,7 +92,7 @@ describe('configuration_helpers', () => {
it('should handle empty model string', () => { it('should handle empty model string', () => {
const result = configHelpers.parseModelIdentifier(''); const result = configHelpers.parseModelIdentifier('');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
modelId: '', modelId: '',
fullIdentifier: '' fullIdentifier: ''
@ -98,7 +102,7 @@ describe('configuration_helpers', () => {
// Tests for special characters in model names // Tests for special characters in model names
it('should handle model names with periods', () => { it('should handle model names with periods', () => {
const result = configHelpers.parseModelIdentifier('gpt-4.1-turbo-preview'); const result = configHelpers.parseModelIdentifier('gpt-4.1-turbo-preview');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
modelId: 'gpt-4.1-turbo-preview', modelId: 'gpt-4.1-turbo-preview',
fullIdentifier: 'gpt-4.1-turbo-preview' fullIdentifier: 'gpt-4.1-turbo-preview'
@ -107,7 +111,7 @@ describe('configuration_helpers', () => {
it('should handle model names with provider prefix and periods', () => { it('should handle model names with provider prefix and periods', () => {
const result = configHelpers.parseModelIdentifier('openai:gpt-4.1-turbo'); const result = configHelpers.parseModelIdentifier('openai:gpt-4.1-turbo');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
provider: 'openai', provider: 'openai',
modelId: 'gpt-4.1-turbo', modelId: 'gpt-4.1-turbo',
@ -117,7 +121,7 @@ describe('configuration_helpers', () => {
it('should handle model names with multiple colons', () => { it('should handle model names with multiple colons', () => {
const result = configHelpers.parseModelIdentifier('custom:model:v1.2:latest'); const result = configHelpers.parseModelIdentifier('custom:model:v1.2:latest');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
modelId: 'custom:model:v1.2:latest', modelId: 'custom:model:v1.2:latest',
fullIdentifier: 'custom:model:v1.2:latest' fullIdentifier: 'custom:model:v1.2:latest'
@ -126,7 +130,7 @@ describe('configuration_helpers', () => {
it('should handle Ollama model names with colons', () => { it('should handle Ollama model names with colons', () => {
const result = configHelpers.parseModelIdentifier('ollama:llama3.1:70b-instruct-q4_K_M'); const result = configHelpers.parseModelIdentifier('ollama:llama3.1:70b-instruct-q4_K_M');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
provider: 'ollama', provider: 'ollama',
modelId: 'llama3.1:70b-instruct-q4_K_M', modelId: 'llama3.1:70b-instruct-q4_K_M',
@ -136,7 +140,7 @@ describe('configuration_helpers', () => {
it('should handle model names with slashes', () => { it('should handle model names with slashes', () => {
const result = configHelpers.parseModelIdentifier('library/mistral:7b-instruct'); const result = configHelpers.parseModelIdentifier('library/mistral:7b-instruct');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
modelId: 'library/mistral:7b-instruct', modelId: 'library/mistral:7b-instruct',
fullIdentifier: 'library/mistral:7b-instruct' fullIdentifier: 'library/mistral:7b-instruct'
@ -146,7 +150,7 @@ describe('configuration_helpers', () => {
it('should handle complex model names with special characters', () => { it('should handle complex model names with special characters', () => {
const complexName = 'org/model-v1.2.3:tag@version#variant'; const complexName = 'org/model-v1.2.3:tag@version#variant';
const result = configHelpers.parseModelIdentifier(complexName); const result = configHelpers.parseModelIdentifier(complexName);
expect(result).toStrictEqual({ expect(result).toStrictEqual({
modelId: complexName, modelId: complexName,
fullIdentifier: complexName fullIdentifier: complexName
@ -155,7 +159,7 @@ describe('configuration_helpers', () => {
it('should handle model names with @ symbols', () => { it('should handle model names with @ symbols', () => {
const result = configHelpers.parseModelIdentifier('claude-3.5-sonnet@20241022'); const result = configHelpers.parseModelIdentifier('claude-3.5-sonnet@20241022');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
modelId: 'claude-3.5-sonnet@20241022', modelId: 'claude-3.5-sonnet@20241022',
fullIdentifier: 'claude-3.5-sonnet@20241022' fullIdentifier: 'claude-3.5-sonnet@20241022'
@ -165,7 +169,7 @@ describe('configuration_helpers', () => {
it('should not modify or encode special characters', () => { it('should not modify or encode special characters', () => {
const specialChars = 'model!@#$%^&*()_+-=[]{}|;:\'",.<>?/~`'; const specialChars = 'model!@#$%^&*()_+-=[]{}|;:\'",.<>?/~`';
const result = configHelpers.parseModelIdentifier(specialChars); const result = configHelpers.parseModelIdentifier(specialChars);
expect(result).toStrictEqual({ expect(result).toStrictEqual({
modelId: specialChars, modelId: specialChars,
fullIdentifier: specialChars fullIdentifier: specialChars
@ -176,7 +180,7 @@ describe('configuration_helpers', () => {
describe('createModelConfig', () => { describe('createModelConfig', () => {
it('should create model config directly', () => { it('should create model config directly', () => {
const result = configHelpers.createModelConfig('gpt-4', 'openai'); const result = configHelpers.createModelConfig('gpt-4', 'openai');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
provider: 'openai', provider: 'openai',
modelId: 'gpt-4', modelId: 'gpt-4',
@ -186,7 +190,7 @@ describe('configuration_helpers', () => {
it('should handle model with provider prefix', () => { it('should handle model with provider prefix', () => {
const result = configHelpers.createModelConfig('openai:gpt-4'); const result = configHelpers.createModelConfig('openai:gpt-4');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
provider: 'openai', provider: 'openai',
modelId: 'gpt-4', modelId: 'gpt-4',
@ -196,7 +200,7 @@ describe('configuration_helpers', () => {
it('should fallback to openai provider when none specified', () => { it('should fallback to openai provider when none specified', () => {
const result = configHelpers.createModelConfig('gpt-4'); const result = configHelpers.createModelConfig('gpt-4');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
provider: 'openai', provider: 'openai',
modelId: 'gpt-4', modelId: 'gpt-4',
@ -208,27 +212,27 @@ describe('configuration_helpers', () => {
describe('getDefaultModelForProvider', () => { describe('getDefaultModelForProvider', () => {
it('should return default model for provider', async () => { it('should return default model for provider', async () => {
vi.mocked(optionService.getOption).mockReturnValue('gpt-4'); vi.mocked(optionService.getOption).mockReturnValue('gpt-4');
const result = await configHelpers.getDefaultModelForProvider('openai'); const result = await configHelpers.getDefaultModelForProvider('openai');
expect(result).toBe('gpt-4'); expect(result).toBe('gpt-4');
expect(optionService.getOption).toHaveBeenCalledWith('openaiDefaultModel'); expect(optionService.getOption).toHaveBeenCalledWith('openaiDefaultModel');
}); });
it('should return undefined if no default model', async () => { it('should return undefined if no default model', async () => {
vi.mocked(optionService.getOption).mockReturnValue(''); vi.mocked(optionService.getOption).mockReturnValue('');
const result = await configHelpers.getDefaultModelForProvider('anthropic'); const result = await configHelpers.getDefaultModelForProvider('anthropic');
expect(result).toBeUndefined(); expect(result).toBeUndefined();
expect(optionService.getOption).toHaveBeenCalledWith('anthropicDefaultModel'); expect(optionService.getOption).toHaveBeenCalledWith('anthropicDefaultModel');
}); });
it('should handle ollama provider', async () => { it('should handle ollama provider', async () => {
vi.mocked(optionService.getOption).mockReturnValue('llama2'); vi.mocked(optionService.getOption).mockReturnValue('llama2');
const result = await configHelpers.getDefaultModelForProvider('ollama'); const result = await configHelpers.getDefaultModelForProvider('ollama');
expect(result).toBe('llama2'); expect(result).toBe('llama2');
expect(optionService.getOption).toHaveBeenCalledWith('ollamaDefaultModel'); expect(optionService.getOption).toHaveBeenCalledWith('ollamaDefaultModel');
}); });
@ -237,27 +241,27 @@ describe('configuration_helpers', () => {
it('should handle OpenAI model names with periods', async () => { it('should handle OpenAI model names with periods', async () => {
const modelName = 'gpt-4.1-turbo-preview'; const modelName = 'gpt-4.1-turbo-preview';
vi.mocked(optionService.getOption).mockReturnValue(modelName); vi.mocked(optionService.getOption).mockReturnValue(modelName);
const result = await configHelpers.getDefaultModelForProvider('openai'); const result = await configHelpers.getDefaultModelForProvider('openai');
expect(result).toBe(modelName); expect(result).toBe(modelName);
}); });
it('should handle Anthropic model names with periods and @ symbols', async () => { it('should handle Anthropic model names with periods and @ symbols', async () => {
const modelName = 'claude-3.5-sonnet@20241022'; const modelName = 'claude-3.5-sonnet@20241022';
vi.mocked(optionService.getOption).mockReturnValue(modelName); vi.mocked(optionService.getOption).mockReturnValue(modelName);
const result = await configHelpers.getDefaultModelForProvider('anthropic'); const result = await configHelpers.getDefaultModelForProvider('anthropic');
expect(result).toBe(modelName); expect(result).toBe(modelName);
}); });
it('should handle Ollama model names with colons and slashes', async () => { it('should handle Ollama model names with colons and slashes', async () => {
const modelName = 'library/llama3.1:70b-instruct-q4_K_M'; const modelName = 'library/llama3.1:70b-instruct-q4_K_M';
vi.mocked(optionService.getOption).mockReturnValue(modelName); vi.mocked(optionService.getOption).mockReturnValue(modelName);
const result = await configHelpers.getDefaultModelForProvider('ollama'); const result = await configHelpers.getDefaultModelForProvider('ollama');
expect(result).toBe(modelName); expect(result).toBe(modelName);
}); });
}); });
@ -268,9 +272,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('test-key') // openaiApiKey .mockReturnValueOnce('test-key') // openaiApiKey
.mockReturnValueOnce('https://api.openai.com') // openaiBaseUrl .mockReturnValueOnce('https://api.openai.com') // openaiBaseUrl
.mockReturnValueOnce('gpt-4'); // openaiDefaultModel .mockReturnValueOnce('gpt-4'); // openaiDefaultModel
const result = await configHelpers.getProviderSettings('openai'); const result = await configHelpers.getProviderSettings('openai');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
apiKey: 'test-key', apiKey: 'test-key',
baseUrl: 'https://api.openai.com', baseUrl: 'https://api.openai.com',
@ -283,9 +287,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('anthropic-key') // anthropicApiKey .mockReturnValueOnce('anthropic-key') // anthropicApiKey
.mockReturnValueOnce('https://api.anthropic.com') // anthropicBaseUrl .mockReturnValueOnce('https://api.anthropic.com') // anthropicBaseUrl
.mockReturnValueOnce('claude-3'); // anthropicDefaultModel .mockReturnValueOnce('claude-3'); // anthropicDefaultModel
const result = await configHelpers.getProviderSettings('anthropic'); const result = await configHelpers.getProviderSettings('anthropic');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
apiKey: 'anthropic-key', apiKey: 'anthropic-key',
baseUrl: 'https://api.anthropic.com', baseUrl: 'https://api.anthropic.com',
@ -297,9 +301,9 @@ describe('configuration_helpers', () => {
vi.mocked(optionService.getOption) vi.mocked(optionService.getOption)
.mockReturnValueOnce('http://localhost:11434') // ollamaBaseUrl .mockReturnValueOnce('http://localhost:11434') // ollamaBaseUrl
.mockReturnValueOnce('llama2'); // ollamaDefaultModel .mockReturnValueOnce('llama2'); // ollamaDefaultModel
const result = await configHelpers.getProviderSettings('ollama'); const result = await configHelpers.getProviderSettings('ollama');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
defaultModel: 'llama2' defaultModel: 'llama2'
@ -308,7 +312,7 @@ describe('configuration_helpers', () => {
it('should return empty object for unknown provider', async () => { it('should return empty object for unknown provider', async () => {
const result = await configHelpers.getProviderSettings('unknown' as ProviderType); const result = await configHelpers.getProviderSettings('unknown' as ProviderType);
expect(result).toStrictEqual({}); expect(result).toStrictEqual({});
}); });
}); });
@ -316,18 +320,18 @@ describe('configuration_helpers', () => {
describe('isAIEnabled', () => { describe('isAIEnabled', () => {
it('should return true if AI is enabled', async () => { it('should return true if AI is enabled', async () => {
vi.mocked(optionService.getOptionBool).mockReturnValue(true); vi.mocked(optionService.getOptionBool).mockReturnValue(true);
const result = await configHelpers.isAIEnabled(); const result = await configHelpers.isAIEnabled();
expect(result).toBe(true); expect(result).toBe(true);
expect(optionService.getOptionBool).toHaveBeenCalledWith('aiEnabled'); expect(optionService.getOptionBool).toHaveBeenCalledWith('aiEnabled');
}); });
it('should return false if AI is disabled', async () => { it('should return false if AI is disabled', async () => {
vi.mocked(optionService.getOptionBool).mockReturnValue(false); vi.mocked(optionService.getOptionBool).mockReturnValue(false);
const result = await configHelpers.isAIEnabled(); const result = await configHelpers.isAIEnabled();
expect(result).toBe(false); expect(result).toBe(false);
expect(optionService.getOptionBool).toHaveBeenCalledWith('aiEnabled'); expect(optionService.getOptionBool).toHaveBeenCalledWith('aiEnabled');
}); });
@ -339,9 +343,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('test-key') // openaiApiKey .mockReturnValueOnce('test-key') // openaiApiKey
.mockReturnValueOnce('') // openaiBaseUrl .mockReturnValueOnce('') // openaiBaseUrl
.mockReturnValueOnce(''); // openaiDefaultModel .mockReturnValueOnce(''); // openaiDefaultModel
const result = await configHelpers.isProviderConfigured('openai'); const result = await configHelpers.isProviderConfigured('openai');
expect(result).toBe(true); expect(result).toBe(true);
}); });
@ -350,9 +354,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('') // openaiApiKey (empty) .mockReturnValueOnce('') // openaiApiKey (empty)
.mockReturnValueOnce('') // openaiBaseUrl .mockReturnValueOnce('') // openaiBaseUrl
.mockReturnValueOnce(''); // openaiDefaultModel .mockReturnValueOnce(''); // openaiDefaultModel
const result = await configHelpers.isProviderConfigured('openai'); const result = await configHelpers.isProviderConfigured('openai');
expect(result).toBe(false); expect(result).toBe(false);
}); });
@ -361,9 +365,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('anthropic-key') // anthropicApiKey .mockReturnValueOnce('anthropic-key') // anthropicApiKey
.mockReturnValueOnce('') // anthropicBaseUrl .mockReturnValueOnce('') // anthropicBaseUrl
.mockReturnValueOnce(''); // anthropicDefaultModel .mockReturnValueOnce(''); // anthropicDefaultModel
const result = await configHelpers.isProviderConfigured('anthropic'); const result = await configHelpers.isProviderConfigured('anthropic');
expect(result).toBe(true); expect(result).toBe(true);
}); });
@ -371,15 +375,15 @@ describe('configuration_helpers', () => {
vi.mocked(optionService.getOption) vi.mocked(optionService.getOption)
.mockReturnValueOnce('http://localhost:11434') // ollamaBaseUrl .mockReturnValueOnce('http://localhost:11434') // ollamaBaseUrl
.mockReturnValueOnce(''); // ollamaDefaultModel .mockReturnValueOnce(''); // ollamaDefaultModel
const result = await configHelpers.isProviderConfigured('ollama'); const result = await configHelpers.isProviderConfigured('ollama');
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false for unknown provider', async () => { it('should return false for unknown provider', async () => {
const result = await configHelpers.isProviderConfigured('unknown' as ProviderType); const result = await configHelpers.isProviderConfigured('unknown' as ProviderType);
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
@ -391,17 +395,17 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('test-key') // openaiApiKey .mockReturnValueOnce('test-key') // openaiApiKey
.mockReturnValueOnce('') // openaiBaseUrl .mockReturnValueOnce('') // openaiBaseUrl
.mockReturnValueOnce(''); // openaiDefaultModel .mockReturnValueOnce(''); // openaiDefaultModel
const result = await configHelpers.getAvailableSelectedProvider(); const result = await configHelpers.getAvailableSelectedProvider();
expect(result).toBe('openai'); expect(result).toBe('openai');
}); });
it('should return null if no provider selected', async () => { it('should return null if no provider selected', async () => {
vi.mocked(optionService.getOption).mockReturnValueOnce(''); vi.mocked(optionService.getOption).mockReturnValueOnce('');
const result = await configHelpers.getAvailableSelectedProvider(); const result = await configHelpers.getAvailableSelectedProvider();
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -411,9 +415,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('') // openaiApiKey (empty) .mockReturnValueOnce('') // openaiApiKey (empty)
.mockReturnValueOnce('') // openaiBaseUrl .mockReturnValueOnce('') // openaiBaseUrl
.mockReturnValueOnce(''); // openaiDefaultModel .mockReturnValueOnce(''); // openaiDefaultModel
const result = await configHelpers.getAvailableSelectedProvider(); const result = await configHelpers.getAvailableSelectedProvider();
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
@ -427,9 +431,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('test-key') // openaiApiKey .mockReturnValueOnce('test-key') // openaiApiKey
.mockReturnValueOnce('') // openaiBaseUrl .mockReturnValueOnce('') // openaiBaseUrl
.mockReturnValueOnce('gpt-4'); // openaiDefaultModel .mockReturnValueOnce('gpt-4'); // openaiDefaultModel
const result = await configHelpers.validateConfiguration(); const result = await configHelpers.validateConfiguration();
expect(result).toStrictEqual({ expect(result).toStrictEqual({
isValid: true, isValid: true,
errors: [], errors: [],
@ -439,9 +443,9 @@ describe('configuration_helpers', () => {
it('should return warning when AI is disabled', async () => { it('should return warning when AI is disabled', async () => {
vi.mocked(optionService.getOptionBool).mockReturnValue(false); vi.mocked(optionService.getOptionBool).mockReturnValue(false);
const result = await configHelpers.validateConfiguration(); const result = await configHelpers.validateConfiguration();
expect(result).toStrictEqual({ expect(result).toStrictEqual({
isValid: true, isValid: true,
errors: [], errors: [],
@ -452,9 +456,9 @@ describe('configuration_helpers', () => {
it('should return error when no provider selected', async () => { it('should return error when no provider selected', async () => {
vi.mocked(optionService.getOptionBool).mockReturnValue(true); vi.mocked(optionService.getOptionBool).mockReturnValue(true);
vi.mocked(optionService.getOption).mockReturnValue(''); // no aiSelectedProvider vi.mocked(optionService.getOption).mockReturnValue(''); // no aiSelectedProvider
const result = await configHelpers.validateConfiguration(); const result = await configHelpers.validateConfiguration();
expect(result).toStrictEqual({ expect(result).toStrictEqual({
isValid: false, isValid: false,
errors: ['No AI provider selected'], errors: ['No AI provider selected'],
@ -469,9 +473,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('') // openaiApiKey (empty) .mockReturnValueOnce('') // openaiApiKey (empty)
.mockReturnValueOnce('') // openaiBaseUrl .mockReturnValueOnce('') // openaiBaseUrl
.mockReturnValueOnce(''); // openaiDefaultModel .mockReturnValueOnce(''); // openaiDefaultModel
const result = await configHelpers.validateConfiguration(); const result = await configHelpers.validateConfiguration();
expect(result).toStrictEqual({ expect(result).toStrictEqual({
isValid: true, isValid: true,
errors: [], errors: [],
@ -495,9 +499,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('test-key') // openaiApiKey .mockReturnValueOnce('test-key') // openaiApiKey
.mockReturnValueOnce('') // openaiBaseUrl .mockReturnValueOnce('') // openaiBaseUrl
.mockReturnValueOnce(''); // openaiDefaultModel .mockReturnValueOnce(''); // openaiDefaultModel
const result = await configHelpers.getValidModelConfig('openai'); const result = await configHelpers.getValidModelConfig('openai');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
model: modelName, model: modelName,
provider: 'openai' provider: 'openai'
@ -511,9 +515,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('anthropic-key') // anthropicApiKey .mockReturnValueOnce('anthropic-key') // anthropicApiKey
.mockReturnValueOnce('') // anthropicBaseUrl .mockReturnValueOnce('') // anthropicBaseUrl
.mockReturnValueOnce(''); // anthropicDefaultModel .mockReturnValueOnce(''); // anthropicDefaultModel
const result = await configHelpers.getValidModelConfig('anthropic'); const result = await configHelpers.getValidModelConfig('anthropic');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
model: modelName, model: modelName,
provider: 'anthropic' provider: 'anthropic'
@ -526,9 +530,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce(modelName) // ollamaDefaultModel .mockReturnValueOnce(modelName) // ollamaDefaultModel
.mockReturnValueOnce('http://localhost:11434') // ollamaBaseUrl .mockReturnValueOnce('http://localhost:11434') // ollamaBaseUrl
.mockReturnValueOnce(''); // ollamaDefaultModel .mockReturnValueOnce(''); // ollamaDefaultModel
const result = await configHelpers.getValidModelConfig('ollama'); const result = await configHelpers.getValidModelConfig('ollama');
expect(result).toStrictEqual({ expect(result).toStrictEqual({
model: modelName, model: modelName,
provider: 'ollama' provider: 'ollama'
@ -545,9 +549,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('test-key') // openaiApiKey .mockReturnValueOnce('test-key') // openaiApiKey
.mockReturnValueOnce('') // openaiBaseUrl .mockReturnValueOnce('') // openaiBaseUrl
.mockReturnValueOnce(''); // openaiDefaultModel .mockReturnValueOnce(''); // openaiDefaultModel
const result = await configHelpers.getSelectedModelConfig(); const result = await configHelpers.getSelectedModelConfig();
expect(result).toStrictEqual({ expect(result).toStrictEqual({
model: modelName, model: modelName,
provider: 'openai' provider: 'openai'
@ -562,9 +566,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('test-key') // openaiApiKey .mockReturnValueOnce('test-key') // openaiApiKey
.mockReturnValueOnce('') // openaiBaseUrl .mockReturnValueOnce('') // openaiBaseUrl
.mockReturnValueOnce(''); // openaiDefaultModel .mockReturnValueOnce(''); // openaiDefaultModel
const result = await configHelpers.getSelectedModelConfig(); const result = await configHelpers.getSelectedModelConfig();
expect(result).toStrictEqual({ expect(result).toStrictEqual({
model: modelName, model: modelName,
provider: 'openai' provider: 'openai'
@ -578,9 +582,9 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce(modelName) // ollamaDefaultModel .mockReturnValueOnce(modelName) // ollamaDefaultModel
.mockReturnValueOnce('http://localhost:11434') // ollamaBaseUrl .mockReturnValueOnce('http://localhost:11434') // ollamaBaseUrl
.mockReturnValueOnce(''); // ollamaDefaultModel .mockReturnValueOnce(''); // ollamaDefaultModel
const result = await configHelpers.getSelectedModelConfig(); const result = await configHelpers.getSelectedModelConfig();
expect(result).toStrictEqual({ expect(result).toStrictEqual({
model: modelName, model: modelName,
provider: 'ollama' provider: 'ollama'
@ -595,13 +599,13 @@ describe('configuration_helpers', () => {
.mockReturnValueOnce('test-key') // anthropicApiKey .mockReturnValueOnce('test-key') // anthropicApiKey
.mockReturnValueOnce('') // anthropicBaseUrl .mockReturnValueOnce('') // anthropicBaseUrl
.mockReturnValueOnce(''); // anthropicDefaultModel .mockReturnValueOnce(''); // anthropicDefaultModel
const result = await configHelpers.getSelectedModelConfig(); const result = await configHelpers.getSelectedModelConfig();
expect(result).toStrictEqual({ expect(result).toStrictEqual({
model: modelName, model: modelName,
provider: 'anthropic' provider: 'anthropic'
}); });
}); });
}); });
}); });

View File

@ -10,7 +10,11 @@ import { PROVIDER_CONSTANTS } from '../constants/provider_constants.js';
vi.mock('../../options.js', () => ({ vi.mock('../../options.js', () => ({
default: { default: {
getOption: vi.fn(), getOption: vi.fn(),
getOptionBool: vi.fn() getOptionBool: vi.fn(),
getOptionInt: vi.fn(name => {
if (name === "protectedSessionTimeout") return Number.MAX_SAFE_INTEGER;
return 0;
})
} }
})); }));
@ -79,7 +83,7 @@ describe('AnthropicService', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Get the mocked Anthropic instance before creating the service // Get the mocked Anthropic instance before creating the service
const AnthropicMock = vi.mocked(Anthropic); const AnthropicMock = vi.mocked(Anthropic);
mockAnthropicInstance = { mockAnthropicInstance = {
@ -122,9 +126,9 @@ describe('AnthropicService', () => {
}) })
} }
}; };
AnthropicMock.mockImplementation(() => mockAnthropicInstance); AnthropicMock.mockImplementation(() => mockAnthropicInstance);
service = new AnthropicService(); service = new AnthropicService();
}); });
@ -144,26 +148,26 @@ describe('AnthropicService', () => {
it('should return true when AI is enabled and API key exists', () => { it('should return true when AI is enabled and API key exists', () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(true); // AI enabled vi.mocked(options.getOptionBool).mockReturnValueOnce(true); // AI enabled
vi.mocked(options.getOption).mockReturnValueOnce('test-api-key'); // API key vi.mocked(options.getOption).mockReturnValueOnce('test-api-key'); // API key
const result = service.isAvailable(); const result = service.isAvailable();
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false when AI is disabled', () => { it('should return false when AI is disabled', () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled
const result = service.isAvailable(); const result = service.isAvailable();
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false when no API key', () => { it('should return false when no API key', () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(true); // AI enabled vi.mocked(options.getOptionBool).mockReturnValueOnce(true); // AI enabled
vi.mocked(options.getOption).mockReturnValueOnce(''); // No API key vi.mocked(options.getOption).mockReturnValueOnce(''); // No API key
const result = service.isAvailable(); const result = service.isAvailable();
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
@ -190,9 +194,9 @@ describe('AnthropicService', () => {
stream: false stream: false
}; };
vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions);
const result = await service.generateChatCompletion(messages); const result = await service.generateChatCompletion(messages);
expect(result).toEqual({ expect(result).toEqual({
text: 'Hello! How can I help you today?', text: 'Hello! How can I help you today?',
provider: 'Anthropic', provider: 'Anthropic',
@ -214,11 +218,11 @@ describe('AnthropicService', () => {
stream: false stream: false
}; };
vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions);
const createSpy = vi.spyOn(mockAnthropicInstance.messages, 'create'); const createSpy = vi.spyOn(mockAnthropicInstance.messages, 'create');
await service.generateChatCompletion(messages); await service.generateChatCompletion(messages);
const calledParams = createSpy.mock.calls[0][0] as any; const calledParams = createSpy.mock.calls[0][0] as any;
expect(calledParams.messages).toEqual([ expect(calledParams.messages).toEqual([
{ role: 'user', content: 'Hello' } { role: 'user', content: 'Hello' }
@ -235,12 +239,12 @@ describe('AnthropicService', () => {
onChunk: vi.fn() onChunk: vi.fn()
}; };
vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions);
const result = await service.generateChatCompletion(messages); const result = await service.generateChatCompletion(messages);
// Wait for chunks to be processed // Wait for chunks to be processed
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
// Check that the result exists (streaming logic is complex, so we just verify basic structure) // Check that the result exists (streaming logic is complex, so we just verify basic structure)
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result).toHaveProperty('text'); expect(result).toHaveProperty('text');
@ -256,7 +260,7 @@ describe('AnthropicService', () => {
properties: {} properties: {}
} }
}]; }];
const mockOptions = { const mockOptions = {
apiKey: 'test-key', apiKey: 'test-key',
baseUrl: 'https://api.anthropic.com', baseUrl: 'https://api.anthropic.com',
@ -267,7 +271,7 @@ describe('AnthropicService', () => {
tool_choice: { type: 'any' } tool_choice: { type: 'any' }
}; };
vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions);
// Mock response with tool use // Mock response with tool use
mockAnthropicInstance.messages.create.mockResolvedValueOnce({ mockAnthropicInstance.messages.create.mockResolvedValueOnce({
id: 'msg_123', id: 'msg_123',
@ -287,9 +291,9 @@ describe('AnthropicService', () => {
output_tokens: 25 output_tokens: 25
} }
}); });
const result = await service.generateChatCompletion(messages); const result = await service.generateChatCompletion(messages);
expect(result).toEqual({ expect(result).toEqual({
text: '', text: '',
provider: 'Anthropic', provider: 'Anthropic',
@ -312,7 +316,7 @@ describe('AnthropicService', () => {
it('should throw error if service not available', async () => { it('should throw error if service not available', async () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled
await expect(service.generateChatCompletion(messages)).rejects.toThrow( await expect(service.generateChatCompletion(messages)).rejects.toThrow(
'Anthropic service is not available' 'Anthropic service is not available'
); );
@ -326,12 +330,12 @@ describe('AnthropicService', () => {
stream: false stream: false
}; };
vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions);
// Mock API error // Mock API error
mockAnthropicInstance.messages.create.mockRejectedValueOnce( mockAnthropicInstance.messages.create.mockRejectedValueOnce(
new Error('API Error: Invalid API key') new Error('API Error: Invalid API key')
); );
await expect(service.generateChatCompletion(messages)).rejects.toThrow( await expect(service.generateChatCompletion(messages)).rejects.toThrow(
'API Error: Invalid API key' 'API Error: Invalid API key'
); );
@ -347,15 +351,15 @@ describe('AnthropicService', () => {
stream: false stream: false
}; };
vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions);
// Spy on Anthropic constructor // Spy on Anthropic constructor
const AnthropicMock = vi.mocked(Anthropic); const AnthropicMock = vi.mocked(Anthropic);
AnthropicMock.mockClear(); AnthropicMock.mockClear();
// Create new service to trigger client creation // Create new service to trigger client creation
const newService = new AnthropicService(); const newService = new AnthropicService();
await newService.generateChatCompletion(messages); await newService.generateChatCompletion(messages);
expect(AnthropicMock).toHaveBeenCalledWith({ expect(AnthropicMock).toHaveBeenCalledWith({
apiKey: 'test-key', apiKey: 'test-key',
baseURL: 'https://api.anthropic.com', baseURL: 'https://api.anthropic.com',
@ -374,15 +378,15 @@ describe('AnthropicService', () => {
stream: false stream: false
}; };
vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions);
// Spy on Anthropic constructor // Spy on Anthropic constructor
const AnthropicMock = vi.mocked(Anthropic); const AnthropicMock = vi.mocked(Anthropic);
AnthropicMock.mockClear(); AnthropicMock.mockClear();
// Create new service to trigger client creation // Create new service to trigger client creation
const newService = new AnthropicService(); const newService = new AnthropicService();
await newService.generateChatCompletion(messages); await newService.generateChatCompletion(messages);
expect(AnthropicMock).toHaveBeenCalledWith({ expect(AnthropicMock).toHaveBeenCalledWith({
apiKey: 'test-key', apiKey: 'test-key',
baseURL: 'https://api.anthropic.com', baseURL: 'https://api.anthropic.com',
@ -401,7 +405,7 @@ describe('AnthropicService', () => {
stream: false stream: false
}; };
vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions);
// Mock response with mixed content // Mock response with mixed content
mockAnthropicInstance.messages.create.mockResolvedValueOnce({ mockAnthropicInstance.messages.create.mockResolvedValueOnce({
id: 'msg_123', id: 'msg_123',
@ -420,9 +424,9 @@ describe('AnthropicService', () => {
output_tokens: 25 output_tokens: 25
} }
}); });
const result = await service.generateChatCompletion(messages); const result = await service.generateChatCompletion(messages);
expect(result.text).toBe('Here is the result: The calculation is complete.'); expect(result.text).toBe('Here is the result: The calculation is complete.');
expect(result.tool_calls).toHaveLength(1); expect(result.tool_calls).toHaveLength(1);
expect(result.tool_calls![0].function.name).toBe('calculate'); expect(result.tool_calls![0].function.name).toBe('calculate');
@ -431,8 +435,8 @@ describe('AnthropicService', () => {
it('should handle tool results in messages', async () => { it('should handle tool results in messages', async () => {
const messagesWithToolResult: Message[] = [ const messagesWithToolResult: Message[] = [
{ role: 'user', content: 'Calculate 5 + 3' }, { role: 'user', content: 'Calculate 5 + 3' },
{ {
role: 'assistant', role: 'assistant',
content: '', content: '',
tool_calls: [{ tool_calls: [{
id: 'call_123', id: 'call_123',
@ -440,13 +444,13 @@ describe('AnthropicService', () => {
function: { name: 'calculate', arguments: '{"x": 5, "y": 3}' } function: { name: 'calculate', arguments: '{"x": 5, "y": 3}' }
}] }]
}, },
{ {
role: 'tool', role: 'tool',
content: '8', content: '8',
tool_call_id: 'call_123' tool_call_id: 'call_123'
} }
]; ];
const mockOptions = { const mockOptions = {
apiKey: 'test-key', apiKey: 'test-key',
baseUrl: 'https://api.anthropic.com', baseUrl: 'https://api.anthropic.com',
@ -454,11 +458,11 @@ describe('AnthropicService', () => {
stream: false stream: false
}; };
vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getAnthropicOptions).mockReturnValueOnce(mockOptions);
const createSpy = vi.spyOn(mockAnthropicInstance.messages, 'create'); const createSpy = vi.spyOn(mockAnthropicInstance.messages, 'create');
await service.generateChatCompletion(messagesWithToolResult); await service.generateChatCompletion(messagesWithToolResult);
const formattedMessages = (createSpy.mock.calls[0][0] as any).messages; const formattedMessages = (createSpy.mock.calls[0][0] as any).messages;
expect(formattedMessages).toHaveLength(3); expect(formattedMessages).toHaveLength(3);
expect(formattedMessages[2]).toEqual({ expect(formattedMessages[2]).toEqual({
@ -471,4 +475,4 @@ describe('AnthropicService', () => {
}); });
}); });
}); });
}); });

View File

@ -10,7 +10,11 @@ import options from '../../options.js';
vi.mock('../../options.js', () => ({ vi.mock('../../options.js', () => ({
default: { default: {
getOption: vi.fn(), getOption: vi.fn(),
getOptionBool: vi.fn() getOptionBool: vi.fn(),
getOptionInt: vi.fn(name => {
if (name === "protectedSessionTimeout") return Number.MAX_SAFE_INTEGER;
return 0;
})
} }
})); }));
@ -82,7 +86,7 @@ describe('LLM Model Selection with Special Characters', () => {
// Spy on getOpenAIOptions to verify model name is passed correctly // Spy on getOpenAIOptions to verify model name is passed correctly
const getOpenAIOptionsSpy = vi.spyOn(providers, 'getOpenAIOptions'); const getOpenAIOptionsSpy = vi.spyOn(providers, 'getOpenAIOptions');
try { try {
await service.generateChatCompletion([{ role: 'user', content: 'test' }], opts); await service.generateChatCompletion([{ role: 'user', content: 'test' }], opts);
} catch (error) { } catch (error) {
@ -108,7 +112,7 @@ describe('LLM Model Selection with Special Characters', () => {
}; };
const getOpenAIOptionsSpy = vi.spyOn(providers, 'getOpenAIOptions'); const getOpenAIOptionsSpy = vi.spyOn(providers, 'getOpenAIOptions');
try { try {
await service.generateChatCompletion([{ role: 'user', content: 'test' }], opts); await service.generateChatCompletion([{ role: 'user', content: 'test' }], opts);
} catch (error) { } catch (error) {
@ -127,7 +131,7 @@ describe('LLM Model Selection with Special Characters', () => {
}; };
const getOpenAIOptionsSpy = vi.spyOn(providers, 'getOpenAIOptions'); const getOpenAIOptionsSpy = vi.spyOn(providers, 'getOpenAIOptions');
const openaiOptions = providers.getOpenAIOptions(opts); const openaiOptions = providers.getOpenAIOptions(opts);
expect(openaiOptions.model).toBe(modelName); expect(openaiOptions.model).toBe(modelName);
}); });
@ -153,7 +157,7 @@ describe('LLM Model Selection with Special Characters', () => {
}); });
const service = new OpenAIService(); const service = new OpenAIService();
// Access the private openai client through the service // Access the private openai client through the service
const client = (service as any).getClient('test-key'); const client = (service as any).getClient('test-key');
const createSpy = vi.spyOn(client.chat.completions, 'create'); const createSpy = vi.spyOn(client.chat.completions, 'create');
@ -213,7 +217,7 @@ describe('LLM Model Selection with Special Characters', () => {
}); });
const service = new AnthropicService(); const service = new AnthropicService();
// Access the private anthropic client // Access the private anthropic client
const client = (service as any).getClient('test-key'); const client = (service as any).getClient('test-key');
const createSpy = vi.spyOn(client.messages, 'create'); const createSpy = vi.spyOn(client.messages, 'create');
@ -278,7 +282,7 @@ describe('LLM Model Selection with Special Characters', () => {
const ollamaOptions = await providers.getOllamaOptions(opts); const ollamaOptions = await providers.getOllamaOptions(opts);
expect(ollamaOptions.model).toBe(modelName); expect(ollamaOptions.model).toBe(modelName);
// Also test with model specified in options // Also test with model specified in options
const optsWithModel: ChatCompletionOptions = { const optsWithModel: ChatCompletionOptions = {
model: 'another/model:v2.0@beta', model: 'another/model:v2.0@beta',
@ -370,7 +374,7 @@ describe('LLM Model Selection with Special Characters', () => {
describe('Integration with REST API', () => { describe('Integration with REST API', () => {
it('should pass model names correctly through REST chat service', async () => { it('should pass model names correctly through REST chat service', async () => {
const modelName = 'gpt-4.1-turbo-preview@latest'; const modelName = 'gpt-4.1-turbo-preview@latest';
// Mock the configuration helpers // Mock the configuration helpers
vi.doMock('../config/configuration_helpers.js', () => ({ vi.doMock('../config/configuration_helpers.js', () => ({
getSelectedModelConfig: vi.fn().mockResolvedValue({ getSelectedModelConfig: vi.fn().mockResolvedValue({
@ -382,8 +386,8 @@ describe('LLM Model Selection with Special Characters', () => {
const { getSelectedModelConfig } = await import('../config/configuration_helpers.js'); const { getSelectedModelConfig } = await import('../config/configuration_helpers.js');
const config = await getSelectedModelConfig(); const config = await getSelectedModelConfig();
expect(config?.model).toBe(modelName); expect(config?.model).toBe(modelName);
}); });
}); });
}); });

View File

@ -9,7 +9,11 @@ import { Ollama } from 'ollama';
vi.mock('../../options.js', () => ({ vi.mock('../../options.js', () => ({
default: { default: {
getOption: vi.fn(), getOption: vi.fn(),
getOptionBool: vi.fn() getOptionBool: vi.fn(),
getOptionInt: vi.fn(name => {
if (name === "protectedSessionTimeout") return Number.MAX_SAFE_INTEGER;
return 0;
})
} }
})); }));
@ -134,7 +138,7 @@ describe('OllamaService', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Create the mock instance before creating the service // Create the mock instance before creating the service
const OllamaMock = vi.mocked(Ollama); const OllamaMock = vi.mocked(Ollama);
mockOllamaInstance = { mockOllamaInstance = {
@ -191,11 +195,11 @@ describe('OllamaService', () => {
] ]
}) })
}; };
OllamaMock.mockImplementation(() => mockOllamaInstance); OllamaMock.mockImplementation(() => mockOllamaInstance);
service = new OllamaService(); service = new OllamaService();
// Replace the formatter with a mock after construction // Replace the formatter with a mock after construction
(service as any).formatter = { (service as any).formatter = {
formatMessages: vi.fn().mockReturnValue([ formatMessages: vi.fn().mockReturnValue([
@ -231,26 +235,26 @@ describe('OllamaService', () => {
it('should return true when AI is enabled and base URL exists', () => { it('should return true when AI is enabled and base URL exists', () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(true); // AI enabled vi.mocked(options.getOptionBool).mockReturnValueOnce(true); // AI enabled
vi.mocked(options.getOption).mockReturnValueOnce('http://localhost:11434'); // Base URL vi.mocked(options.getOption).mockReturnValueOnce('http://localhost:11434'); // Base URL
const result = service.isAvailable(); const result = service.isAvailable();
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false when AI is disabled', () => { it('should return false when AI is disabled', () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled
const result = service.isAvailable(); const result = service.isAvailable();
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false when no base URL', () => { it('should return false when no base URL', () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(true); // AI enabled vi.mocked(options.getOptionBool).mockReturnValueOnce(true); // AI enabled
vi.mocked(options.getOption).mockReturnValueOnce(''); // No base URL vi.mocked(options.getOption).mockReturnValueOnce(''); // No base URL
const result = service.isAvailable(); const result = service.isAvailable();
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
@ -275,9 +279,9 @@ describe('OllamaService', () => {
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions);
vi.mocked(options.getOption).mockReturnValue('http://localhost:11434'); vi.mocked(options.getOption).mockReturnValue('http://localhost:11434');
const result = await service.generateChatCompletion(messages); const result = await service.generateChatCompletion(messages);
expect(result).toEqual({ expect(result).toEqual({
text: 'Hello! How can I help you today?', text: 'Hello! How can I help you today?',
provider: 'ollama', provider: 'ollama',
@ -296,12 +300,12 @@ describe('OllamaService', () => {
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions);
vi.mocked(options.getOption).mockReturnValue('http://localhost:11434'); vi.mocked(options.getOption).mockReturnValue('http://localhost:11434');
const result = await service.generateChatCompletion(messages); const result = await service.generateChatCompletion(messages);
// Wait for chunks to be processed // Wait for chunks to be processed
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
// For streaming, we expect a different response structure // For streaming, we expect a different response structure
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result).toHaveProperty('text'); expect(result).toHaveProperty('text');
@ -310,7 +314,7 @@ describe('OllamaService', () => {
it('should handle tools when enabled', async () => { it('should handle tools when enabled', async () => {
vi.mocked(options.getOption).mockReturnValue('http://localhost:11434'); vi.mocked(options.getOption).mockReturnValue('http://localhost:11434');
const mockTools = [{ const mockTools = [{
name: 'test_tool', name: 'test_tool',
description: 'Test tool', description: 'Test tool',
@ -320,7 +324,7 @@ describe('OllamaService', () => {
required: [] required: []
} }
}]; }];
const mockOptions = { const mockOptions = {
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
model: 'llama2', model: 'llama2',
@ -329,18 +333,18 @@ describe('OllamaService', () => {
tools: mockTools tools: mockTools
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions);
const chatSpy = vi.spyOn(mockOllamaInstance, 'chat'); const chatSpy = vi.spyOn(mockOllamaInstance, 'chat');
await service.generateChatCompletion(messages); await service.generateChatCompletion(messages);
const calledParams = chatSpy.mock.calls[0][0] as any; const calledParams = chatSpy.mock.calls[0][0] as any;
expect(calledParams.tools).toEqual(mockTools); expect(calledParams.tools).toEqual(mockTools);
}); });
it('should throw error if service not available', async () => { it('should throw error if service not available', async () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled
await expect(service.generateChatCompletion(messages)).rejects.toThrow( await expect(service.generateChatCompletion(messages)).rejects.toThrow(
'Ollama service is not available' 'Ollama service is not available'
); );
@ -350,14 +354,14 @@ describe('OllamaService', () => {
vi.mocked(options.getOption) vi.mocked(options.getOption)
.mockReturnValueOnce('') // Empty base URL for ollamaBaseUrl .mockReturnValueOnce('') // Empty base URL for ollamaBaseUrl
.mockReturnValue(''); // Ensure all subsequent calls return empty .mockReturnValue(''); // Ensure all subsequent calls return empty
const mockOptions = { const mockOptions = {
baseUrl: '', baseUrl: '',
model: 'llama2', model: 'llama2',
stream: false stream: false
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions);
await expect(service.generateChatCompletion(messages)).rejects.toThrow( await expect(service.generateChatCompletion(messages)).rejects.toThrow(
'Ollama service is not available' 'Ollama service is not available'
); );
@ -365,19 +369,19 @@ describe('OllamaService', () => {
it('should handle API errors', async () => { it('should handle API errors', async () => {
vi.mocked(options.getOption).mockReturnValue('http://localhost:11434'); vi.mocked(options.getOption).mockReturnValue('http://localhost:11434');
const mockOptions = { const mockOptions = {
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
model: 'llama2', model: 'llama2',
stream: false stream: false
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions);
// Mock API error // Mock API error
mockOllamaInstance.chat.mockRejectedValueOnce( mockOllamaInstance.chat.mockRejectedValueOnce(
new Error('Connection refused') new Error('Connection refused')
); );
await expect(service.generateChatCompletion(messages)).rejects.toThrow( await expect(service.generateChatCompletion(messages)).rejects.toThrow(
'Connection refused' 'Connection refused'
); );
@ -385,30 +389,30 @@ describe('OllamaService', () => {
it('should create client with custom fetch for debugging', async () => { it('should create client with custom fetch for debugging', async () => {
vi.mocked(options.getOption).mockReturnValue('http://localhost:11434'); vi.mocked(options.getOption).mockReturnValue('http://localhost:11434');
const mockOptions = { const mockOptions = {
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
model: 'llama2', model: 'llama2',
stream: false stream: false
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions);
// Spy on Ollama constructor // Spy on Ollama constructor
const OllamaMock = vi.mocked(Ollama); const OllamaMock = vi.mocked(Ollama);
OllamaMock.mockClear(); OllamaMock.mockClear();
// Create new service to trigger client creation // Create new service to trigger client creation
const newService = new OllamaService(); const newService = new OllamaService();
// Replace the formatter with a mock for the new service // Replace the formatter with a mock for the new service
(newService as any).formatter = { (newService as any).formatter = {
formatMessages: vi.fn().mockReturnValue([ formatMessages: vi.fn().mockReturnValue([
{ role: 'user', content: 'Hello' } { role: 'user', content: 'Hello' }
]) ])
}; };
await newService.generateChatCompletion(messages); await newService.generateChatCompletion(messages);
expect(OllamaMock).toHaveBeenCalledWith({ expect(OllamaMock).toHaveBeenCalledWith({
host: 'http://localhost:11434', host: 'http://localhost:11434',
fetch: expect.any(Function) fetch: expect.any(Function)
@ -417,7 +421,7 @@ describe('OllamaService', () => {
it('should handle tool execution feedback', async () => { it('should handle tool execution feedback', async () => {
vi.mocked(options.getOption).mockReturnValue('http://localhost:11434'); vi.mocked(options.getOption).mockReturnValue('http://localhost:11434');
const mockOptions = { const mockOptions = {
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
model: 'llama2', model: 'llama2',
@ -426,7 +430,7 @@ describe('OllamaService', () => {
tools: [{ name: 'test_tool', description: 'Test', parameters: {} }] tools: [{ name: 'test_tool', description: 'Test', parameters: {} }]
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions);
// Mock response with tool call (arguments should be a string for Ollama) // Mock response with tool call (arguments should be a string for Ollama)
mockOllamaInstance.chat.mockResolvedValueOnce({ mockOllamaInstance.chat.mockResolvedValueOnce({
message: { message: {
@ -442,9 +446,9 @@ describe('OllamaService', () => {
}, },
done: true done: true
}); });
const result = await service.generateChatCompletion(messages); const result = await service.generateChatCompletion(messages);
expect(result.tool_calls).toEqual([{ expect(result.tool_calls).toEqual([{
id: 'call_123', id: 'call_123',
type: 'function', type: 'function',
@ -457,14 +461,14 @@ describe('OllamaService', () => {
it('should handle mixed text and tool content', async () => { it('should handle mixed text and tool content', async () => {
vi.mocked(options.getOption).mockReturnValue('http://localhost:11434'); vi.mocked(options.getOption).mockReturnValue('http://localhost:11434');
const mockOptions = { const mockOptions = {
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
model: 'llama2', model: 'llama2',
stream: false stream: false
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions);
// Mock response with both text and tool calls // Mock response with both text and tool calls
mockOllamaInstance.chat.mockResolvedValueOnce({ mockOllamaInstance.chat.mockResolvedValueOnce({
message: { message: {
@ -480,30 +484,30 @@ describe('OllamaService', () => {
}, },
done: true done: true
}); });
const result = await service.generateChatCompletion(messages); const result = await service.generateChatCompletion(messages);
expect(result.text).toBe('Let me help you with that.'); expect(result.text).toBe('Let me help you with that.');
expect(result.tool_calls).toHaveLength(1); expect(result.tool_calls).toHaveLength(1);
}); });
it('should format messages using the formatter', async () => { it('should format messages using the formatter', async () => {
vi.mocked(options.getOption).mockReturnValue('http://localhost:11434'); vi.mocked(options.getOption).mockReturnValue('http://localhost:11434');
const mockOptions = { const mockOptions = {
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
model: 'llama2', model: 'llama2',
stream: false stream: false
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions);
const formattedMessages = [{ role: 'user', content: 'Hello' }]; const formattedMessages = [{ role: 'user', content: 'Hello' }];
(service as any).formatter.formatMessages.mockReturnValueOnce(formattedMessages); (service as any).formatter.formatMessages.mockReturnValueOnce(formattedMessages);
const chatSpy = vi.spyOn(mockOllamaInstance, 'chat'); const chatSpy = vi.spyOn(mockOllamaInstance, 'chat');
await service.generateChatCompletion(messages); await service.generateChatCompletion(messages);
expect((service as any).formatter.formatMessages).toHaveBeenCalled(); expect((service as any).formatter.formatMessages).toHaveBeenCalled();
expect(chatSpy).toHaveBeenCalledWith( expect(chatSpy).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -514,23 +518,23 @@ describe('OllamaService', () => {
it('should handle network errors gracefully', async () => { it('should handle network errors gracefully', async () => {
vi.mocked(options.getOption).mockReturnValue('http://localhost:11434'); vi.mocked(options.getOption).mockReturnValue('http://localhost:11434');
const mockOptions = { const mockOptions = {
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
model: 'llama2', model: 'llama2',
stream: false stream: false
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions);
// Mock network error // Mock network error
global.fetch = vi.fn().mockRejectedValueOnce( global.fetch = vi.fn().mockRejectedValueOnce(
new Error('Network error') new Error('Network error')
); );
mockOllamaInstance.chat.mockRejectedValueOnce( mockOllamaInstance.chat.mockRejectedValueOnce(
new Error('fetch failed') new Error('fetch failed')
); );
await expect(service.generateChatCompletion(messages)).rejects.toThrow( await expect(service.generateChatCompletion(messages)).rejects.toThrow(
'fetch failed' 'fetch failed'
); );
@ -538,19 +542,19 @@ describe('OllamaService', () => {
it('should validate model availability', async () => { it('should validate model availability', async () => {
vi.mocked(options.getOption).mockReturnValue('http://localhost:11434'); vi.mocked(options.getOption).mockReturnValue('http://localhost:11434');
const mockOptions = { const mockOptions = {
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
model: 'nonexistent-model', model: 'nonexistent-model',
stream: false stream: false
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValueOnce(mockOptions);
// Mock model not found error // Mock model not found error
mockOllamaInstance.chat.mockRejectedValueOnce( mockOllamaInstance.chat.mockRejectedValueOnce(
new Error('model "nonexistent-model" not found') new Error('model "nonexistent-model" not found')
); );
await expect(service.generateChatCompletion(messages)).rejects.toThrow( await expect(service.generateChatCompletion(messages)).rejects.toThrow(
'model "nonexistent-model" not found' 'model "nonexistent-model" not found'
); );
@ -561,23 +565,23 @@ describe('OllamaService', () => {
it('should reuse existing client', async () => { it('should reuse existing client', async () => {
vi.mocked(options.getOptionBool).mockReturnValue(true); vi.mocked(options.getOptionBool).mockReturnValue(true);
vi.mocked(options.getOption).mockReturnValue('http://localhost:11434'); vi.mocked(options.getOption).mockReturnValue('http://localhost:11434');
const mockOptions = { const mockOptions = {
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
model: 'llama2', model: 'llama2',
stream: false stream: false
}; };
vi.mocked(providers.getOllamaOptions).mockResolvedValue(mockOptions); vi.mocked(providers.getOllamaOptions).mockResolvedValue(mockOptions);
const OllamaMock = vi.mocked(Ollama); const OllamaMock = vi.mocked(Ollama);
OllamaMock.mockClear(); OllamaMock.mockClear();
// Make two calls // Make two calls
await service.generateChatCompletion([{ role: 'user', content: 'Hello' }]); await service.generateChatCompletion([{ role: 'user', content: 'Hello' }]);
await service.generateChatCompletion([{ role: 'user', content: 'Hi' }]); await service.generateChatCompletion([{ role: 'user', content: 'Hi' }]);
// Should only create client once // Should only create client once
expect(OllamaMock).toHaveBeenCalledTimes(1); expect(OllamaMock).toHaveBeenCalledTimes(1);
}); });
}); });
}); });

View File

@ -2,13 +2,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { OpenAIService } from './openai_service.js'; import { OpenAIService } from './openai_service.js';
import options from '../../options.js'; import options from '../../options.js';
import * as providers from './providers.js'; import * as providers from './providers.js';
import type { ChatCompletionOptions, Message } from '../ai_interface.js'; import type { Message } from '../ai_interface.js';
// Mock dependencies // Mock dependencies
vi.mock('../../options.js', () => ({ vi.mock('../../options.js', () => ({
default: { default: {
getOption: vi.fn(), getOption: vi.fn(),
getOptionBool: vi.fn() getOptionBool: vi.fn(),
getOptionInt: vi.fn(name => {
if (name === "protectedSessionTimeout") return Number.MAX_SAFE_INTEGER;
return 0;
})
} }
})); }));
@ -53,17 +57,17 @@ describe('OpenAIService', () => {
describe('isAvailable', () => { describe('isAvailable', () => {
it('should return true when base checks pass', () => { it('should return true when base checks pass', () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(true); // AI enabled vi.mocked(options.getOptionBool).mockReturnValueOnce(true); // AI enabled
const result = service.isAvailable(); const result = service.isAvailable();
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false when AI is disabled', () => { it('should return false when AI is disabled', () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled
const result = service.isAvailable(); const result = service.isAvailable();
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
@ -89,7 +93,7 @@ describe('OpenAIService', () => {
enableTools: false enableTools: false
}; };
vi.mocked(providers.getOpenAIOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getOpenAIOptions).mockReturnValueOnce(mockOptions);
// Mock the getClient method to return our mock client // Mock the getClient method to return our mock client
const mockCompletion = { const mockCompletion = {
id: 'chatcmpl-123', id: 'chatcmpl-123',
@ -120,9 +124,9 @@ describe('OpenAIService', () => {
}; };
vi.spyOn(service as any, 'getClient').mockReturnValue(mockClient); vi.spyOn(service as any, 'getClient').mockReturnValue(mockClient);
const result = await service.generateChatCompletion(messages); const result = await service.generateChatCompletion(messages);
expect(result).toEqual({ expect(result).toEqual({
text: 'Hello! How can I help you today?', text: 'Hello! How can I help you today?',
model: 'gpt-3.5-turbo', model: 'gpt-3.5-turbo',
@ -144,7 +148,7 @@ describe('OpenAIService', () => {
stream: true stream: true
}; };
vi.mocked(providers.getOpenAIOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getOpenAIOptions).mockReturnValueOnce(mockOptions);
// Mock the streaming response // Mock the streaming response
const mockStream = { const mockStream = {
[Symbol.asyncIterator]: async function* () { [Symbol.asyncIterator]: async function* () {
@ -162,7 +166,7 @@ describe('OpenAIService', () => {
}; };
} }
}; };
const mockClient = { const mockClient = {
chat: { chat: {
completions: { completions: {
@ -172,9 +176,9 @@ describe('OpenAIService', () => {
}; };
vi.spyOn(service as any, 'getClient').mockReturnValue(mockClient); vi.spyOn(service as any, 'getClient').mockReturnValue(mockClient);
const result = await service.generateChatCompletion(messages); const result = await service.generateChatCompletion(messages);
expect(result).toHaveProperty('stream'); expect(result).toHaveProperty('stream');
expect(result.text).toBe(''); expect(result.text).toBe('');
expect(result.model).toBe('gpt-3.5-turbo'); expect(result.model).toBe('gpt-3.5-turbo');
@ -183,7 +187,7 @@ describe('OpenAIService', () => {
it('should throw error if service not available', async () => { it('should throw error if service not available', async () => {
vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled vi.mocked(options.getOptionBool).mockReturnValueOnce(false); // AI disabled
await expect(service.generateChatCompletion(messages)).rejects.toThrow( await expect(service.generateChatCompletion(messages)).rejects.toThrow(
'OpenAI service is not available' 'OpenAI service is not available'
); );
@ -197,7 +201,7 @@ describe('OpenAIService', () => {
stream: false stream: false
}; };
vi.mocked(providers.getOpenAIOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getOpenAIOptions).mockReturnValueOnce(mockOptions);
const mockClient = { const mockClient = {
chat: { chat: {
completions: { completions: {
@ -207,7 +211,7 @@ describe('OpenAIService', () => {
}; };
vi.spyOn(service as any, 'getClient').mockReturnValue(mockClient); vi.spyOn(service as any, 'getClient').mockReturnValue(mockClient);
await expect(service.generateChatCompletion(messages)).rejects.toThrow( await expect(service.generateChatCompletion(messages)).rejects.toThrow(
'API Error: Invalid API key' 'API Error: Invalid API key'
); );
@ -222,7 +226,7 @@ describe('OpenAIService', () => {
parameters: {} parameters: {}
} }
}]; }];
const mockOptions = { const mockOptions = {
apiKey: 'test-key', apiKey: 'test-key',
baseUrl: 'https://api.openai.com/v1', baseUrl: 'https://api.openai.com/v1',
@ -233,7 +237,7 @@ describe('OpenAIService', () => {
tool_choice: 'auto' tool_choice: 'auto'
}; };
vi.mocked(providers.getOpenAIOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getOpenAIOptions).mockReturnValueOnce(mockOptions);
const mockCompletion = { const mockCompletion = {
id: 'chatcmpl-123', id: 'chatcmpl-123',
object: 'chat.completion', object: 'chat.completion',
@ -263,9 +267,9 @@ describe('OpenAIService', () => {
}; };
vi.spyOn(service as any, 'getClient').mockReturnValue(mockClient); vi.spyOn(service as any, 'getClient').mockReturnValue(mockClient);
await service.generateChatCompletion(messages); await service.generateChatCompletion(messages);
const createCall = mockClient.chat.completions.create.mock.calls[0][0]; const createCall = mockClient.chat.completions.create.mock.calls[0][0];
expect(createCall.tools).toEqual(mockTools); expect(createCall.tools).toEqual(mockTools);
expect(createCall.tool_choice).toBe('auto'); expect(createCall.tool_choice).toBe('auto');
@ -281,7 +285,7 @@ describe('OpenAIService', () => {
tools: [{ type: 'function' as const, function: { name: 'test', description: 'test' } }] tools: [{ type: 'function' as const, function: { name: 'test', description: 'test' } }]
}; };
vi.mocked(providers.getOpenAIOptions).mockReturnValueOnce(mockOptions); vi.mocked(providers.getOpenAIOptions).mockReturnValueOnce(mockOptions);
const mockCompletion = { const mockCompletion = {
id: 'chatcmpl-123', id: 'chatcmpl-123',
object: 'chat.completion', object: 'chat.completion',
@ -319,9 +323,9 @@ describe('OpenAIService', () => {
}; };
vi.spyOn(service as any, 'getClient').mockReturnValue(mockClient); vi.spyOn(service as any, 'getClient').mockReturnValue(mockClient);
const result = await service.generateChatCompletion(messages); const result = await service.generateChatCompletion(messages);
expect(result).toEqual({ expect(result).toEqual({
text: '', text: '',
model: 'gpt-3.5-turbo', model: 'gpt-3.5-turbo',
@ -342,4 +346,4 @@ describe('OpenAIService', () => {
}); });
}); });
}); });
}); });

View File

@ -1,9 +1,6 @@
"use strict"; "use strict";
import log from "./log.js";
import dataEncryptionService from "./encryption/data_encryption.js"; import dataEncryptionService from "./encryption/data_encryption.js";
import options from "./options.js";
import ws from "./ws.js";
let dataKey: Buffer | null = null; let dataKey: Buffer | null = null;
@ -15,11 +12,11 @@ function getDataKey() {
return dataKey; return dataKey;
} }
function resetDataKey() { export function resetDataKey() {
dataKey = null; dataKey = null;
} }
function isProtectedSessionAvailable() { export function isProtectedSessionAvailable() {
return !!dataKey; return !!dataKey;
} }
@ -57,15 +54,8 @@ function touchProtectedSession() {
} }
} }
function checkProtectedSessionExpiration() { export function getLastProtectedSessionOperationDate() {
const protectedSessionTimeout = options.getOptionInt("protectedSessionTimeout"); return lastProtectedSessionOperationDate;
if (isProtectedSessionAvailable() && lastProtectedSessionOperationDate && Date.now() - lastProtectedSessionOperationDate > protectedSessionTimeout * 1000) {
resetDataKey();
log.info("Expiring protected session");
ws.reloadFrontend("leaving protected session");
}
} }
export default { export default {
@ -75,6 +65,5 @@ export default {
encrypt, encrypt,
decrypt, decrypt,
decryptString, decryptString,
touchProtectedSession, touchProtectedSession
checkProtectedSessionExpiration
}; };

View File

@ -4,9 +4,11 @@ import sqlInit from "./sql_init.js";
import config from "./config.js"; import config from "./config.js";
import log from "./log.js"; import log from "./log.js";
import attributeService from "../services/attributes.js"; import attributeService from "../services/attributes.js";
import protectedSessionService from "../services/protected_session.js";
import hiddenSubtreeService from "./hidden_subtree.js"; import hiddenSubtreeService from "./hidden_subtree.js";
import type BNote from "../becca/entities/bnote.js"; import type BNote from "../becca/entities/bnote.js";
import options from "./options.js";
import { getLastProtectedSessionOperationDate, isProtectedSessionAvailable, resetDataKey } from "./protected_session.js";
import ws from "./ws.js";
function getRunAtHours(note: BNote): number[] { function getRunAtHours(note: BNote): number[] {
try { try {
@ -64,5 +66,15 @@ sqlInit.dbReady.then(() => {
); );
} }
setInterval(() => protectedSessionService.checkProtectedSessionExpiration(), 30000); setInterval(() => checkProtectedSessionExpiration(), 1);
}); });
function checkProtectedSessionExpiration() {
const protectedSessionTimeout = options.getOptionInt("protectedSessionTimeout");
const lastProtectedSessionOperationDate = getLastProtectedSessionOperationDate();
if (isProtectedSessionAvailable() && lastProtectedSessionOperationDate && Date.now() - lastProtectedSessionOperationDate > protectedSessionTimeout * 1000) {
resetDataKey();
log.info("Expiring protected session");
ws.reloadFrontend("leaving protected session");
}
}

View File

@ -211,7 +211,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

View File

@ -202,7 +202,8 @@ pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

17
docs/README-ar.md vendored
View File

@ -135,7 +135,7 @@ compatible with the latest zadam/trilium version of
versions of TriliumNext/Trilium have their sync versions incremented which versions of TriliumNext/Trilium have their sync versions incremented which
prevents direct migration. prevents direct migration.
## تحدث معنا ## 💬تحدث معنا
Feel free to join our official conversations. We would love to hear what Feel free to join our official conversations. We would love to hear what
features, suggestions, or issues you may have! features, suggestions, or issues you may have!
@ -157,7 +157,7 @@ Download the binary release for your platform from the [latest release
page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package
and run the `trilium` executable. and run the `trilium` executable.
### Linux ### لينكس
If your distribution is listed in the table below, use your distribution's If your distribution is listed in the table below, use your distribution's
package. package.
@ -179,7 +179,7 @@ interface (which is almost identical to the desktop app).
Currently only the latest versions of Chrome & Firefox are supported (and Currently only the latest versions of Chrome & Firefox are supported (and
tested). tested).
### Mobile ### هاتف المحمول
To use TriliumNext on a mobile device, you can use a mobile web browser to To use TriliumNext on a mobile device, you can use a mobile web browser to
access the mobile interface of a server installation (see below). access the mobile interface of a server installation (see below).
@ -194,7 +194,7 @@ repository](https://github.com/FliegendeWurst/TriliumDroid). Note: It is best to
disable automatic updates on your server installation (see below) when using disable automatic updates on your server installation (see below) when using
TriliumDroid since the sync version must match between Trilium and TriliumDroid. TriliumDroid since the sync version must match between Trilium and TriliumDroid.
### Server ### الخادم
To install TriliumNext on your own server (including via Docker from To install TriliumNext on your own server (including via Docker from
[Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server
@ -203,7 +203,7 @@ installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation).
## 💻 Contribute ## 💻 Contribute
### Translations ### ترجمات
If you are a native speaker, help us translate Trilium by heading over to our If you are a native speaker, help us translate Trilium by heading over to our
[Weblate page](https://hosted.weblate.org/engage/trilium/). [Weblate page](https://hosted.weblate.org/engage/trilium/).
@ -213,7 +213,7 @@ Here's the language coverage we have so far:
[![Translation [![Translation
status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/) status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### Code ### كود
Download the repository, install dependencies using `pnpm` and then run the Download the repository, install dependencies using `pnpm` and then run the
server (available at http://localhost:8080): server (available at http://localhost:8080):
@ -224,7 +224,7 @@ pnpm install
pnpm run server:start pnpm run server:start
``` ```
### Documentation ### التوثيق
Download the repository, install dependencies using `pnpm` and then run the Download the repository, install dependencies using `pnpm` and then run the
environment required to edit the documentation: environment required to edit the documentation:
@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-ca.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-cs.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-de.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-el.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-es.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-fa.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-fi.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-fr.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-hr.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-hu.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

315
docs/README-id.md vendored Normal file
View File

@ -0,0 +1,315 @@
# Trilium Notes
![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran)
![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran)\
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/trilium)
![Unduhan GitHub (semua aset, semua
rilis)](https://img.shields.io/github/downloads/triliumnext/trilium/total)\
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp)
[![Status
terjemahan](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
[English](./README.md) | [Chinese (Simplified)](./docs/README-ZH_CN.md) |
[Chinese (Traditional)](./docs/README-ZH_TW.md) | [Russian](./docs/README-ru.md)
| [Japanese](./docs/README-ja.md) | [Italian](./docs/README-it.md) |
[Spanish](./docs/README-es.md)
Trilium Notes adalah aplikasi pencatatan hierarkis lintas platform yang gratis
dan sumber terbuka dengan fokus untuk mengembangkan pengetahuan pribadi yang
luas.
See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for
quick overview:
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./docs/app.png" alt="Trilium Screenshot" width="1000"></a>
## 📚 Dokumentasi
**Kunjungi dokumentasi lengkap kami di
[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
Dokumentasi kami tersedia dalam berbagai format:
- **Dokumentasi Online**: Telusuri dokumentasi lengkap di
[docs.triliumnotes.org](https://docs.triliumnotes.org/)
- **Bantuan Dalam Aplikasi**: Tekan `F1` di dalam Trilium untuk mengakses
dokumentasi yang sama langsung di aplikasi
- **GitHub**: Navigasi melalui [Panduan
Pengguna](./docs/User%20Guide/User%20Guide/) di repositori ini
### Tautan Cepat
- [Panduan Memulai](https://docs.triliumnotes.org/)
- [Petunjuk
Instalasi](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
- [Pengaturan
Docker](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [Upgrading
TriliumNext](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [Basic Concepts and
Features](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [Patterns of Personal Knowledge
Base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
## 🎁 Features
* Notes can be arranged into arbitrarily deep tree. Single note can be placed
into multiple places in the tree (see
[cloning](https://triliumnext.github.io/Docs/Wiki/cloning-notes))
* Rich WYSIWYG note editor including e.g. tables, images and
[math](https://triliumnext.github.io/Docs/Wiki/text-notes) with markdown
[autoformat](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)
* Support for editing [notes with source
code](https://triliumnext.github.io/Docs/Wiki/code-notes), including syntax
highlighting
* Fast and easy [navigation between
notes](https://triliumnext.github.io/Docs/Wiki/note-navigation), full text
search and [note
hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)
* Seamless [note
versioning](https://triliumnext.github.io/Docs/Wiki/note-revisions)
* Note [attributes](https://triliumnext.github.io/Docs/Wiki/attributes) can be
used for note organization, querying and advanced
[scripting](https://triliumnext.github.io/Docs/Wiki/scripts)
* UI available in English, German, Spanish, French, Romanian, and Chinese
(simplified and traditional)
* Direct [OpenID and TOTP
integration](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md)
for more secure login
* [Synchronization](https://triliumnext.github.io/Docs/Wiki/synchronization)
with self-hosted sync server
* there's a [3rd party service for hosting synchronisation
server](https://trilium.cc/paid-hosting)
* [Sharing](https://triliumnext.github.io/Docs/Wiki/sharing) (publishing) notes
to public internet
* Strong [note
encryption](https://triliumnext.github.io/Docs/Wiki/protected-notes) with
per-note granularity
* Sketching diagrams, based on [Excalidraw](https://excalidraw.com/) (note type
"canvas")
* [Relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map) and
[link maps](https://triliumnext.github.io/Docs/Wiki/link-map) for visualizing
notes and their relations
* Mind maps, based on [Mind Elixir](https://docs.mind-elixir.com/)
* [Geo maps](./docs/User%20Guide/User%20Guide/Note%20Types/Geo%20Map.md) with
location pins and GPX tracks
* [Scripting](https://triliumnext.github.io/Docs/Wiki/scripts) - see [Advanced
showcases](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)
* [REST API](https://triliumnext.github.io/Docs/Wiki/etapi) for automation
* Scales well in both usability and performance upwards of 100 000 notes
* Touch optimized [mobile
frontend](https://triliumnext.github.io/Docs/Wiki/mobile-frontend) for
smartphones and tablets
* Built-in [dark theme](https://triliumnext.github.io/Docs/Wiki/themes), support
for user themes
* [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) and
[Markdown import & export](https://triliumnext.github.io/Docs/Wiki/markdown)
* [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy
saving of web content
* Customizable UI (sidebar buttons, user-defined widgets, ...)
* [Metrics](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md), along
with a [Grafana
Dashboard](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json)
✨ Check out the following third-party resources/communities for more TriliumNext
related goodies:
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party
themes, scripts, plugins and more.
- [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more.
## ❓Why TriliumNext?
The original Trilium developer ([Zadam](https://github.com/zadam)) has
graciously given the Trilium repository to the community project which resides
at https://github.com/TriliumNext
### ⬆Migrating from Zadam/Trilium?
There are no special migration steps to migrate from a zadam/Trilium instance to
a TriliumNext/Trilium instance. Simply [install
TriliumNext/Trilium](#-installation) as usual and it will use your existing
database.
Versions up to and including
[v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are
compatible with the latest zadam/trilium version of
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later
versions of TriliumNext/Trilium have their sync versions incremented which
prevents direct migration.
## 💬 Discuss with us
Feel free to join our official conversations. We would love to hear what
features, suggestions, or issues you may have!
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous
discussions.)
- The `General` Matrix room is also bridged to
[XMPP](xmpp:discuss@trilium.thisgreat.party?join)
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (For
asynchronous discussions.)
- [Github Issues](https://github.com/TriliumNext/Trilium/issues) (For bug
reports and feature requests.)
## 🏗 Installation
### Windows / MacOS
Download the binary release for your platform from the [latest release
page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package
and run the `trilium` executable.
### Linux
If your distribution is listed in the table below, use your distribution's
package.
[![Packaging
status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
You may also download the binary release for your platform from the [latest
release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the
package and run the `trilium` executable.
TriliumNext is also provided as a Flatpak, but not yet published on FlatHub.
### Browser (any OS)
If you use a server installation (see below), you can directly access the web
interface (which is almost identical to the desktop app).
Currently only the latest versions of Chrome & Firefox are supported (and
tested).
### Mobile
To use TriliumNext on a mobile device, you can use a mobile web browser to
access the mobile interface of a server installation (see below).
See issue https://github.com/TriliumNext/Trilium/issues/4962 for more
information on mobile app support.
If you prefer a native Android app, you can use
[TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid).
Report bugs and missing features at [their
repository](https://github.com/FliegendeWurst/TriliumDroid). Note: It is best to
disable automatic updates on your server installation (see below) when using
TriliumDroid since the sync version must match between Trilium and TriliumDroid.
### Server
To install TriliumNext on your own server (including via Docker from
[Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server
installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation).
## 💻 Contribute
### Translations
If you are a native speaker, help us translate Trilium by heading over to our
[Weblate page](https://hosted.weblate.org/engage/trilium/).
Here's the language coverage we have so far:
[![Translation
status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### Code
Download the repository, install dependencies using `pnpm` and then run the
server (available at http://localhost:8080):
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm run server:start
```
### Documentation
Download the repository, install dependencies using `pnpm` and then run the
environment required to edit the documentation:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm edit-docs:edit-docs
```
### Building the Executable
Download the repository, install dependencies using `pnpm` and then build the
desktop app for Windows:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
```
For more details, see the [development
docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
### Developer Documentation
Please view the [documentation
guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
for details. If you have more questions, feel free to reach out via the links
described in the "Discuss with us" section above.
## 👏 Shoutouts
* [zadam](https://github.com/zadam) for the original concept and implementation
of the application.
* [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight
widget.
* [Dosu](https://dosu.dev/) for providing us with the automated responses to
GitHub issues and discussions.
* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
Trilium would not be possible without the technologies behind it:
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - the visual editor behind
text notes. We are grateful for being offered a set of the premium features.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with
support for huge amount of languages.
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite
whiteboard used in Canvas notes.
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the
mind map functionality.
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical
maps.
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive
table used in collections.
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library
without real competition.
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library.
Used in [relation
maps](https://triliumnext.github.io/Docs/Wiki/relation-map.html) and [link
maps](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)
## 🤝 Support
Trilium is built and maintained with [hundreds of hours of
work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your
support keeps it open-source, improves features, and covers costs such as
hosting.
Consider supporting the main developer
([eliandoran](https://github.com/eliandoran)) of the application via:
- [GitHub Sponsors](https://github.com/sponsors/eliandoran)
- [PayPal](https://paypal.me/eliandoran)
- [Buy Me a Coffee](https://buymeacoffee.com/eliandoran)
## 🔑 License
Copyright 2017-2025 zadam, Elian Doran, and other contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.

3
docs/README-it.md vendored
View File

@ -260,7 +260,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

2
docs/README-ja.md vendored
View File

@ -215,7 +215,7 @@ pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
## 👏 シャウトアウト ## 👏 シャウトアウト
* [zadam](https://github.com/zadam) アプリケーションのオリジナルのコンセプトと実装に対して感謝します。 * [zadam](https://github.com/zadam) アプリケーションのオリジナルのコンセプトと実装に対して感謝します。
* [Larsa](https://github.com/LarsaSara) アプリケーションアイコンをデザイン。 * [Sarah Hussein](https://github.com/Sarah-Hussein) アプリケーションアイコンをデザイン。
* [nriver](https://github.com/nriver) 国際化への取り組み。 * [nriver](https://github.com/nriver) 国際化への取り組み。
* [Thomas Frei](https://github.com/thfrei) Canvasへのオリジナルな取り組み。 * [Thomas Frei](https://github.com/thfrei) Canvasへのオリジナルな取り組み。
* [antoniotejada](https://github.com/nriver) オリジナルの構文ハイライトウィジェット。 * [antoniotejada](https://github.com/nriver) オリジナルの構文ハイライトウィジェット。

3
docs/README-ko.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-md.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-nl.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-pl.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-pt.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-ro.md vendored
View File

@ -263,7 +263,8 @@ legăturile descrise în secțiunea „Discutați cu noi” de mai sus.
* [zadam](https://github.com/zadam) pentru conceptul și implementarea originală * [zadam](https://github.com/zadam) pentru conceptul și implementarea originală
a aplicației. a aplicației.
* [Larsa](https://github.com/LarsaSara) pentru pictograma aplicației. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) pentru sistemul de internaționalizare. * [nriver](https://github.com/nriver) pentru sistemul de internaționalizare.
* [Thomas Frei](https://github.com/thfrei) pentru munca sa originală pentru * [Thomas Frei](https://github.com/thfrei) pentru munca sa originală pentru
notițele de tip schiță. notițele de tip schiță.

5
docs/README-ru.md vendored
View File

@ -261,7 +261,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight
@ -307,7 +308,7 @@ Consider supporting the main developer
## 🔑 Лицензия ## 🔑 Лицензия
Copyright 2017-2025 zadam, Elian Doran, and other contributors Copyright 2017-2025 zadam, Elian Doran и другие авторы
Эта программа является бесплатным программным обеспечением: вы можете Эта программа является бесплатным программным обеспечением: вы можете
распространять и/или изменять ее в соответствии с условиями GNU Affero General распространять и/или изменять ее в соответствии с условиями GNU Affero General

3
docs/README-sl.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-sr.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-tr.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-uk.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

3
docs/README-vi.md vendored
View File

@ -259,7 +259,8 @@ described in the "Discuss with us" section above.
* [zadam](https://github.com/zadam) for the original concept and implementation * [zadam](https://github.com/zadam) for the original concept and implementation
of the application. of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon. * [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization. * [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight * [antoniotejada](https://github.com/nriver) for the original syntax highlight

View File

@ -50,12 +50,12 @@
"eslint": "9.37.0", "eslint": "9.37.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-playwright": "2.2.2", "eslint-plugin-playwright": "2.2.2",
"eslint-plugin-react-hooks": "6.1.1", "eslint-plugin-react-hooks": "7.0.0",
"happy-dom": "~19.0.0", "happy-dom": "~19.0.0",
"jiti": "2.6.1", "jiti": "2.6.1",
"jsonc-eslint-parser": "2.4.1", "jsonc-eslint-parser": "2.4.1",
"react-refresh": "0.18.0", "react-refresh": "0.18.0",
"rollup-plugin-webpack-stats": "2.1.5", "rollup-plugin-webpack-stats": "2.1.6",
"tslib": "2.8.1", "tslib": "2.8.1",
"tsx": "4.20.6", "tsx": "4.20.6",
"typescript": "~5.9.0", "typescript": "~5.9.0",

View File

@ -15,7 +15,7 @@
"ckeditor5-premium-features": "47.0.0" "ckeditor5-premium-features": "47.0.0"
}, },
"devDependencies": { "devDependencies": {
"@smithy/middleware-retry": "4.4.0", "@smithy/middleware-retry": "4.4.1",
"@types/jquery": "3.5.33" "@types/jquery": "3.5.33"
} }
} }

View File

@ -16,7 +16,7 @@
"@codemirror/lang-xml": "6.1.0", "@codemirror/lang-xml": "6.1.0",
"@codemirror/legacy-modes": "6.5.2", "@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.5.11", "@codemirror/search": "6.5.11",
"@codemirror/view": "6.38.4", "@codemirror/view": "6.38.5",
"@fsegurai/codemirror-theme-abcdef": "6.2.2", "@fsegurai/codemirror-theme-abcdef": "6.2.2",
"@fsegurai/codemirror-theme-abyss": "6.2.2", "@fsegurai/codemirror-theme-abyss": "6.2.2",
"@fsegurai/codemirror-theme-android-studio": "6.2.2", "@fsegurai/codemirror-theme-android-studio": "6.2.2",

View File

@ -54,3 +54,11 @@ export function trimIndentation(strings: TemplateStringsArray, ...values: any[])
} }
return output.join("\n"); return output.join("\n");
} }
export function flushPromises() {
return new Promise(setImmediate);
}
export function sleepFor(duration: number) {
return new Promise(resolve => setTimeout(resolve, duration));
}

664
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff