Merge branch 'dev' into fix/og-image-date-update
1
.github/workflows/auto-assign-milestone.yml
vendored
@ -7,6 +7,7 @@ on:
|
||||
jobs:
|
||||
assign_milestone:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
330
.github/workflows/autoqa-migration.yml
vendored
Normal file
@ -0,0 +1,330 @@
|
||||
name: AutoQA Migration (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
old_windows_installer:
|
||||
description: 'Windows OLD installer URL or path (.exe)'
|
||||
required: true
|
||||
type: string
|
||||
new_windows_installer:
|
||||
description: 'Windows NEW installer URL or path (.exe)'
|
||||
required: true
|
||||
type: string
|
||||
old_ubuntu_installer:
|
||||
description: 'Ubuntu OLD installer URL or path (.deb)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
new_ubuntu_installer:
|
||||
description: 'Ubuntu NEW installer URL or path (.deb)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
old_macos_installer:
|
||||
description: 'macOS OLD installer URL or path (.dmg)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
new_macos_installer:
|
||||
description: 'macOS NEW installer URL or path (.dmg)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
migration_test_case:
|
||||
description: 'Specific migration test case key (leave empty to run all)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
max_turns:
|
||||
description: 'Maximum turns per test phase'
|
||||
required: false
|
||||
type: number
|
||||
default: 65
|
||||
|
||||
jobs:
|
||||
migration-windows:
|
||||
runs-on: windows-11-nvidia-gpu
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.13
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Clean existing Jan installations
|
||||
shell: powershell
|
||||
run: |
|
||||
.\autoqa\scripts\windows_cleanup.ps1 -IsNightly $false
|
||||
|
||||
- name: Download OLD and NEW installers
|
||||
shell: powershell
|
||||
run: |
|
||||
# Download OLD installer using existing script
|
||||
.\autoqa\scripts\windows_download.ps1 `
|
||||
-WorkflowInputUrl "${{ inputs.old_windows_installer }}" `
|
||||
-WorkflowInputIsNightly "false" `
|
||||
-RepoVariableUrl "" `
|
||||
-RepoVariableIsNightly "" `
|
||||
-DefaultUrl "" `
|
||||
-DefaultIsNightly ""
|
||||
|
||||
$oldSrc = Join-Path $env:TEMP 'jan-installer.exe'
|
||||
$oldOut = Join-Path $env:TEMP 'jan-old.exe'
|
||||
Copy-Item -Path $oldSrc -Destination $oldOut -Force
|
||||
|
||||
# Download NEW installer using existing script
|
||||
.\autoqa\scripts\windows_download.ps1 `
|
||||
-WorkflowInputUrl "${{ inputs.new_windows_installer }}" `
|
||||
-WorkflowInputIsNightly "false" `
|
||||
-RepoVariableUrl "" `
|
||||
-RepoVariableIsNightly "" `
|
||||
-DefaultUrl "" `
|
||||
-DefaultIsNightly ""
|
||||
|
||||
$newSrc = Join-Path $env:TEMP 'jan-installer.exe'
|
||||
$newOut = Join-Path $env:TEMP 'jan-new.exe'
|
||||
Copy-Item -Path $newSrc -Destination $newOut -Force
|
||||
|
||||
Write-Host "OLD installer: $oldOut"
|
||||
Write-Host "NEW installer: $newOut"
|
||||
echo "OLD_VERSION=$oldOut" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "NEW_VERSION=$newOut" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
|
||||
- name: Install Python dependencies
|
||||
working-directory: autoqa
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run migration tests (Windows)
|
||||
working-directory: autoqa
|
||||
shell: powershell
|
||||
env:
|
||||
RP_TOKEN: ${{ secrets.RP_TOKEN }}
|
||||
ENABLE_REPORTPORTAL: 'true'
|
||||
RP_ENDPOINT: 'https://reportportal.menlo.ai'
|
||||
RP_PROJECT: 'default_personal'
|
||||
run: |
|
||||
$case = "${{ inputs.migration_test_case }}"
|
||||
$caseArg = ""
|
||||
if ($case -and $case.Trim() -ne "") { $caseArg = "--migration-test-case `"$case`"" }
|
||||
python main.py --enable-migration-test --old-version "$env:OLD_VERSION" --new-version "$env:NEW_VERSION" --max-turns ${{ inputs.max_turns }} $caseArg
|
||||
|
||||
- name: Upload screen recordings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: migration-recordings-${{ github.run_number }}-windows
|
||||
path: autoqa/recordings/
|
||||
|
||||
- name: Upload trajectories
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: migration-trajectories-${{ github.run_number }}-windows
|
||||
path: autoqa/trajectories/
|
||||
|
||||
- name: Cleanup after tests
|
||||
if: always()
|
||||
shell: powershell
|
||||
run: |
|
||||
.\autoqa\scripts\windows_post_cleanup.ps1 -IsNightly $false
|
||||
|
||||
migration-ubuntu:
|
||||
if: inputs.old_ubuntu_installer != '' && inputs.new_ubuntu_installer != ''
|
||||
runs-on: ubuntu-22-04-nvidia-gpu
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.13
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
x11-utils \
|
||||
python3-tk \
|
||||
python3-dev \
|
||||
wmctrl \
|
||||
xdotool \
|
||||
libnss3-dev \
|
||||
libgconf-2-4 \
|
||||
libxss1 \
|
||||
libasound2 \
|
||||
libxtst6 \
|
||||
libgtk-3-0 \
|
||||
libgbm-dev \
|
||||
libxshmfence1 \
|
||||
libxrandr2 \
|
||||
libpangocairo-1.0-0 \
|
||||
libatk1.0-0 \
|
||||
libcairo-gobject2 \
|
||||
libgdk-pixbuf2.0-0 \
|
||||
gnome-screenshot \
|
||||
xvfb
|
||||
|
||||
- name: Setup script permissions
|
||||
run: |
|
||||
chmod +x autoqa/scripts/setup_permissions.sh || true
|
||||
./autoqa/scripts/setup_permissions.sh || true
|
||||
|
||||
- name: Clean existing Jan installations
|
||||
run: |
|
||||
./autoqa/scripts/ubuntu_cleanup.sh
|
||||
|
||||
- name: Download OLD and NEW installers
|
||||
run: |
|
||||
set -e
|
||||
# Download OLD installer using existing script
|
||||
./autoqa/scripts/ubuntu_download.sh \
|
||||
"${{ inputs.old_ubuntu_installer }}" \
|
||||
"false" \
|
||||
"" \
|
||||
"" \
|
||||
"" \
|
||||
""
|
||||
cp /tmp/jan-installer.deb /tmp/jan-old.deb
|
||||
|
||||
# Download NEW installer using existing script
|
||||
./autoqa/scripts/ubuntu_download.sh \
|
||||
"${{ inputs.new_ubuntu_installer }}" \
|
||||
"false" \
|
||||
"" \
|
||||
"" \
|
||||
"" \
|
||||
""
|
||||
cp /tmp/jan-installer.deb /tmp/jan-new.deb
|
||||
|
||||
echo "OLD_VERSION=/tmp/jan-old.deb" >> $GITHUB_ENV
|
||||
echo "NEW_VERSION=/tmp/jan-new.deb" >> $GITHUB_ENV
|
||||
|
||||
- name: Install Python dependencies
|
||||
working-directory: autoqa
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run migration tests (Ubuntu)
|
||||
working-directory: autoqa
|
||||
run: |
|
||||
case="${{ inputs.migration_test_case }}"
|
||||
caseArg=""
|
||||
if [ -n "${case}" ]; then caseArg="--migration-test-case \"${case}\""; fi
|
||||
xvfb-run -a python main.py --enable-migration-test --old-version "${OLD_VERSION}" --new-version "${NEW_VERSION}" --max-turns ${{ inputs.max_turns }} ${caseArg}
|
||||
|
||||
- name: Upload screen recordings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: migration-recordings-${{ github.run_number }}-ubuntu
|
||||
path: autoqa/recordings/
|
||||
|
||||
- name: Upload trajectories
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: migration-trajectories-${{ github.run_number }}-ubuntu
|
||||
path: autoqa/trajectories/
|
||||
|
||||
- name: Cleanup after tests
|
||||
if: always()
|
||||
run: |
|
||||
./autoqa/scripts/ubuntu_post_cleanup.sh "false"
|
||||
|
||||
migration-macos:
|
||||
if: inputs.old_macos_installer != '' && inputs.new_macos_installer != ''
|
||||
runs-on: macos-selfhosted-15-arm64-cua
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.13
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Setup script permissions
|
||||
run: |
|
||||
chmod +x autoqa/scripts/setup_permissions.sh || true
|
||||
./autoqa/scripts/setup_permissions.sh || true
|
||||
|
||||
- name: Clean existing Jan installations
|
||||
run: |
|
||||
./autoqa/scripts/macos_cleanup.sh
|
||||
|
||||
- name: Download OLD and NEW installers
|
||||
run: |
|
||||
set -e
|
||||
# Download OLD installer using existing script
|
||||
./autoqa/scripts/macos_download.sh \
|
||||
"${{ inputs.old_macos_installer }}" \
|
||||
"false" \
|
||||
"" \
|
||||
"" \
|
||||
"" \
|
||||
""
|
||||
cp /tmp/jan-installer.dmg /tmp/jan-old.dmg
|
||||
|
||||
# Download NEW installer using existing script
|
||||
./autoqa/scripts/macos_download.sh \
|
||||
"${{ inputs.new_macos_installer }}" \
|
||||
"false" \
|
||||
"" \
|
||||
"" \
|
||||
"" \
|
||||
""
|
||||
cp /tmp/jan-installer.dmg /tmp/jan-new.dmg
|
||||
|
||||
echo "OLD_VERSION=/tmp/jan-old.dmg" >> $GITHUB_ENV
|
||||
echo "NEW_VERSION=/tmp/jan-new.dmg" >> $GITHUB_ENV
|
||||
|
||||
- name: Install Python dependencies
|
||||
working-directory: autoqa
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run migration tests (macOS)
|
||||
working-directory: autoqa
|
||||
run: |
|
||||
case="${{ inputs.migration_test_case }}"
|
||||
caseArg=""
|
||||
if [ -n "${case}" ]; then caseArg="--migration-test-case \"${case}\""; fi
|
||||
python main.py --enable-migration-test --old-version "${OLD_VERSION}" --new-version "${NEW_VERSION}" --max-turns ${{ inputs.max_turns }} ${caseArg}
|
||||
|
||||
- name: Upload screen recordings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: migration-recordings-${{ github.run_number }}-macos
|
||||
path: autoqa/recordings/
|
||||
|
||||
- name: Upload trajectories
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: migration-trajectories-${{ github.run_number }}-macos
|
||||
path: autoqa/trajectories/
|
||||
|
||||
- name: Cleanup after tests
|
||||
if: always()
|
||||
run: |
|
||||
./autoqa/scripts/macos_post_cleanup.sh
|
||||
|
||||
|
||||
121
.github/workflows/autoqa-reliability.yml
vendored
Normal file
@ -0,0 +1,121 @@
|
||||
name: AutoQA Reliability (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_type:
|
||||
description: 'App source type (url)'
|
||||
required: true
|
||||
type: choice
|
||||
options: [url]
|
||||
default: url
|
||||
jan_app_windows_source:
|
||||
description: 'Windows installer URL path (used when source_type=url or to select artifact)'
|
||||
required: true
|
||||
type: string
|
||||
default: 'https://catalog.jan.ai/windows/Jan_0.6.8_x64-setup.exe'
|
||||
jan_app_ubuntu_source:
|
||||
description: 'Ubuntu .deb URL path'
|
||||
required: true
|
||||
type: string
|
||||
default: 'https://delta.jan.ai/nightly/Jan-nightly_0.6.4-728_amd64.deb'
|
||||
jan_app_macos_source:
|
||||
description: 'macOS .dmg URL path'
|
||||
required: true
|
||||
type: string
|
||||
default: 'https://delta.jan.ai/nightly/Jan-nightly_0.6.4-728_universal.dmg'
|
||||
is_nightly:
|
||||
description: 'Is the app a nightly build?'
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
reliability_phase:
|
||||
description: 'Reliability phase'
|
||||
required: true
|
||||
type: choice
|
||||
options: [development, deployment]
|
||||
default: development
|
||||
reliability_runs:
|
||||
description: 'Custom runs (0 uses phase default)'
|
||||
required: true
|
||||
type: number
|
||||
default: 0
|
||||
reliability_test_path:
|
||||
description: 'Test file path (relative to autoqa working directory)'
|
||||
required: true
|
||||
type: string
|
||||
default: 'tests/base/settings/app-data.txt'
|
||||
|
||||
jobs:
|
||||
reliability-windows:
|
||||
runs-on: windows-11-nvidia-gpu
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
DEFAULT_JAN_APP_URL: 'https://catalog.jan.ai/windows/Jan_0.6.8_x64-setup.exe'
|
||||
DEFAULT_IS_NIGHTLY: 'false'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.13
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Clean existing Jan installations
|
||||
shell: powershell
|
||||
run: |
|
||||
.\autoqa\scripts\windows_cleanup.ps1 -IsNightly "${{ inputs.is_nightly }}"
|
||||
|
||||
- name: Download/Prepare Jan app
|
||||
shell: powershell
|
||||
run: |
|
||||
.\autoqa\scripts\windows_download.ps1 `
|
||||
-WorkflowInputUrl "${{ inputs.jan_app_windows_source }}" `
|
||||
-WorkflowInputIsNightly "${{ inputs.is_nightly }}" `
|
||||
-RepoVariableUrl "${{ vars.JAN_APP_URL }}" `
|
||||
-RepoVariableIsNightly "${{ vars.IS_NIGHTLY }}" `
|
||||
-DefaultUrl "$env:DEFAULT_JAN_APP_URL" `
|
||||
-DefaultIsNightly "$env:DEFAULT_IS_NIGHTLY"
|
||||
|
||||
- name: Install Jan app
|
||||
shell: powershell
|
||||
run: |
|
||||
.\autoqa\scripts\windows_install.ps1 -IsNightly "$env:IS_NIGHTLY"
|
||||
|
||||
- name: Install Python dependencies
|
||||
working-directory: autoqa
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run reliability tests
|
||||
working-directory: autoqa
|
||||
shell: powershell
|
||||
run: |
|
||||
$runs = "${{ inputs.reliability_runs }}"
|
||||
$runsArg = ""
|
||||
if ([int]$runs -gt 0) { $runsArg = "--reliability-runs $runs" }
|
||||
python main.py --enable-reliability-test --reliability-phase "${{ inputs.reliability_phase }}" --reliability-test-path "${{ inputs.reliability_test_path }}" $runsArg
|
||||
|
||||
- name: Upload screen recordings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: reliability-recordings-${{ github.run_number }}-${{ runner.os }}
|
||||
path: autoqa/recordings/
|
||||
|
||||
- name: Upload trajectories
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: reliability-trajectories-${{ github.run_number }}-${{ runner.os }}
|
||||
path: autoqa/trajectories/
|
||||
|
||||
- name: Cleanup after tests
|
||||
if: always()
|
||||
shell: powershell
|
||||
run: |
|
||||
.\autoqa\scripts\windows_post_cleanup.ps1 -IsNightly "${{ inputs.is_nightly }}"
|
||||
2
.github/workflows/jan-astro-docs.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
deploy:
|
||||
name: Deploy to CloudFlare Pages
|
||||
env:
|
||||
CLOUDFLARE_PROJECT_NAME: astro-docs
|
||||
CLOUDFLARE_PROJECT_NAME: astro-docs # docs.jan.ai
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
262
CONTRIBUTING.md
@ -1,32 +1,266 @@
|
||||
# Contributing to jan
|
||||
# Contributing to Jan
|
||||
|
||||
First off, thank you for considering contributing to jan. It's people like you that make jan such an amazing project.
|
||||
First off, thank you for considering contributing to Jan. It's people like you that make Jan such an amazing project.
|
||||
|
||||
Jan is an AI assistant that can run 100% offline on your device. Think ChatGPT, but private, local, and under your complete control. If you're thinking about contributing, you're already awesome - let's make AI accessible to everyone, one commit at a time.
|
||||
|
||||
## Quick Links to Component Guides
|
||||
|
||||
- **[Web App](./web-app/CONTRIBUTING.md)** - React UI and logic
|
||||
- **[Core SDK](./core/CONTRIBUTING.md)** - TypeScript SDK and extension system
|
||||
- **[Extensions](./extensions/CONTRIBUTING.md)** - Supportive modules for the frontend
|
||||
- **[Tauri Backend](./src-tauri/CONTRIBUTING.md)** - Rust native integration
|
||||
- **[Tauri Plugins](./src-tauri/plugins/CONTRIBUTING.md)** - Hardware and system plugins
|
||||
|
||||
## How Jan Actually Works
|
||||
|
||||
Jan is a desktop app that runs local AI models. Here's how the components actually connect:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Web App (Frontend) │
|
||||
│ (web-app/) │
|
||||
│ • React UI │
|
||||
│ • Chat Interface │
|
||||
│ • Settings Pages │
|
||||
│ • Model Hub │
|
||||
└────────────┬─────────────────────────────┬───────────────┘
|
||||
│ │
|
||||
│ imports │ imports
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ Core SDK │ │ Extensions │
|
||||
│ (core/) │ │ (extensions/) │
|
||||
│ │ │ │
|
||||
│ • TypeScript APIs │◄─────│ • Assistant Mgmt │
|
||||
│ • Extension System │ uses │ • Conversations │
|
||||
│ • Event Bus │ │ • Downloads │
|
||||
│ • Type Definitions │ │ • LlamaCPP │
|
||||
└──────────┬───────────┘ └───────────┬──────────┘
|
||||
│ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ Web App │ │
|
||||
│ └──────────┬───────────┘ │
|
||||
│ │ │
|
||||
└──────────────┼───────────────┘
|
||||
│
|
||||
▼
|
||||
Tauri IPC
|
||||
(invoke commands)
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Tauri Backend (Rust) │
|
||||
│ (src-tauri/) │
|
||||
│ │
|
||||
│ • Window Management • File System Access │
|
||||
│ • Process Control • System Integration │
|
||||
│ • IPC Command Handler • Security & Permissions │
|
||||
└───────────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Tauri Plugins (Rust) │
|
||||
│ (src-tauri/plugins/) │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Hardware Plugin │ │ LlamaCPP Plugin │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • CPU/GPU Info │ │ • Process Mgmt │ │
|
||||
│ │ • Memory Stats │ │ • Model Loading │ │
|
||||
│ │ • System Info │ │ • Inference │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### The Communication Flow
|
||||
|
||||
1. **JavaScript Layer Relationships**:
|
||||
- Web App imports Core SDK and Extensions as JavaScript modules
|
||||
- Extensions use Core SDK for shared functionality
|
||||
- All run in the browser/webview context
|
||||
|
||||
2. **All Three → Backend**: Through Tauri IPC
|
||||
- **Web App** → Backend: `await invoke('app_command', data)`
|
||||
- **Core SDK** → Backend: `await invoke('core_command', data)`
|
||||
- **Extensions** → Backend: `await invoke('ext_command', data)`
|
||||
- Each component can independently call backend commands
|
||||
|
||||
3. **Backend → Plugins**: Native Rust integration
|
||||
- Backend loads plugins as Rust libraries
|
||||
- Direct function calls, no IPC overhead
|
||||
|
||||
4. **Response Flow**:
|
||||
- Plugin → Backend → IPC → Requester (Web App/Core/Extension) → UI updates
|
||||
|
||||
### Real-World Example: Loading a Model
|
||||
|
||||
Here's what actually happens when you click "Download Llama 3":
|
||||
|
||||
1. **Web App** (`web-app/`) - User clicks download button
|
||||
2. **Extension** (`extensions/download-extension`) - Handles the download logic
|
||||
3. **Tauri Backend** (`src-tauri/`) - Actually downloads the file to disk
|
||||
4. **Extension** (`extensions/llamacpp-extension`) - Prepares model for loading
|
||||
5. **Tauri Plugin** (`src-tauri/plugins/llamacpp`) - Starts llama.cpp process
|
||||
6. **Hardware Plugin** (`src-tauri/plugins/hardware`) - Detects GPU, optimizes settings
|
||||
7. **Model ready!** - User can start chatting
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
jan/
|
||||
├── web-app/ # React frontend (what users see)
|
||||
├── src-tauri/ # Rust backend (system integration)
|
||||
│ ├── src/core/ # Core Tauri commands
|
||||
│ └── plugins/ # Tauri plugins (hardware, llamacpp)
|
||||
├── core/ # TypeScript SDK (API layer)
|
||||
├── extensions/ # JavaScript extensions
|
||||
│ ├── assistant-extension/
|
||||
│ ├── conversational-extension/
|
||||
│ ├── download-extension/
|
||||
│ └── llamacpp-extension/
|
||||
├── docs/ # Documentation website
|
||||
├── website/ # Marketing website
|
||||
├── autoqa/ # Automated testing
|
||||
├── scripts/ # Build utilities
|
||||
│
|
||||
├── package.json # Root workspace configuration
|
||||
├── Makefile # Build automation commands
|
||||
├── mise.toml # Mise tool configuration
|
||||
├── LICENSE # Apache 2.0 license
|
||||
└── README.md # Project overview
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
|
||||
### The Scenic Route (Build from Source)
|
||||
|
||||
**Prerequisites:**
|
||||
- Node.js ≥ 20.0.0
|
||||
- Yarn ≥ 1.22.0
|
||||
- Rust (for Tauri)
|
||||
- Make ≥ 3.81
|
||||
|
||||
**Option 1: The Easy Way (Make)**
|
||||
```bash
|
||||
git clone https://github.com/menloresearch/jan
|
||||
cd jan
|
||||
make dev
|
||||
```
|
||||
|
||||
**Option 2: The Easier Way (Mise)**
|
||||
```bash
|
||||
git clone https://github.com/menloresearch/jan
|
||||
cd jan
|
||||
|
||||
# Install mise
|
||||
curl https://mise.run | sh
|
||||
|
||||
# Let mise handle everything
|
||||
mise install # installs Node.js, Rust, and other tools
|
||||
mise dev # runs the full development setup
|
||||
```
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/menloresearch/jan/issues).
|
||||
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/menloresearch/jan/issues/new).
|
||||
- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/menloresearch/jan/issues)
|
||||
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/menloresearch/jan/issues/new)
|
||||
- Include your system specs and error logs - it helps a ton
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
- Open a new issue with a clear title and description.
|
||||
- Open a new issue with a clear title and description
|
||||
- Explain why this enhancement would be useful
|
||||
- Include mockups or examples if you can
|
||||
|
||||
### Your First Code Contribution
|
||||
|
||||
- Fork the repo.
|
||||
- Create a new branch (`git checkout -b feature-name`).
|
||||
- Commit your changes (`git commit -am 'Add some feature'`).
|
||||
- Push to the branch (`git push origin feature-name`).
|
||||
- Open a new Pull Request.
|
||||
**Choose Your Adventure:**
|
||||
- **Frontend UI and logic** → `web-app/`
|
||||
- **Shared API declarations** → `core/`
|
||||
- **Backend system integration** → `src-tauri/`
|
||||
- **Business logic features** → `extensions/`
|
||||
- **Dedicated backend handler** → `src-tauri/plugins/`
|
||||
|
||||
## Styleguides
|
||||
**The Process:**
|
||||
1. Fork the repo
|
||||
2. Create a new branch (`git checkout -b feature-name`)
|
||||
3. Make your changes (and write tests!)
|
||||
4. Commit your changes (`git commit -am 'Add some feature'`)
|
||||
5. Push to the branch (`git push origin feature-name`)
|
||||
6. Open a new Pull Request against `dev` branch
|
||||
|
||||
### Git Commit Messages
|
||||
## Testing
|
||||
|
||||
- Use the present tense ("Add feature" not "Added feature").
|
||||
```bash
|
||||
yarn test # All tests
|
||||
cd src-tauri && cargo test # Rust tests
|
||||
cd autoqa && python main.py # End-to-end tests
|
||||
```
|
||||
|
||||
## Code Standards
|
||||
|
||||
### TypeScript/JavaScript
|
||||
- TypeScript required (we're not animals)
|
||||
- ESLint + Prettier
|
||||
- Functional React components
|
||||
- Proper typing (no `any` - seriously!)
|
||||
|
||||
### Rust
|
||||
- `cargo fmt` + `cargo clippy`
|
||||
- `Result<T, E>` for error handling
|
||||
- Document public APIs
|
||||
|
||||
## Git Conventions
|
||||
|
||||
### Branches
|
||||
- `main` - stable releases
|
||||
- `dev` - development (target this for PRs)
|
||||
- `feature/*` - new features
|
||||
- `fix/*` - bug fixes
|
||||
|
||||
### Commit Messages
|
||||
- Use the present tense ("Add feature" not "Added feature")
|
||||
- Be descriptive but concise
|
||||
- Reference issues when applicable
|
||||
|
||||
Examples:
|
||||
```
|
||||
feat: add support for Qwen models
|
||||
fix: resolve memory leak in model loading
|
||||
docs: update installation instructions
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If things go sideways:
|
||||
|
||||
1. **Check our [troubleshooting docs](https://jan.ai/docs/troubleshooting)**
|
||||
2. **Clear everything and start fresh:** `make clean` then `make dev`
|
||||
3. **Copy your error logs and system specs**
|
||||
4. **Ask for help in our [Discord](https://discord.gg/FTk2MvZwJH)** `#🆘|jan-help` channel
|
||||
|
||||
Common issues:
|
||||
- **Build failures**: Check Node.js and Rust versions
|
||||
- **Extension not loading**: Verify it's properly registered
|
||||
- **Model not working**: Check hardware requirements and GPU drivers
|
||||
|
||||
## Getting Help
|
||||
|
||||
- [Documentation](https://jan.ai/docs) - The manual you should read
|
||||
- [Discord Community](https://discord.gg/jan) - Where the community lives
|
||||
- [GitHub Issues](https://github.com/janhq/jan/issues) - Report bugs here
|
||||
- [GitHub Discussions](https://github.com/janhq/jan/discussions) - Ask questions
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - Because sharing is caring. See [LICENSE](./LICENSE) for the legal stuff.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
Thank you for contributing to jan!
|
||||
We're building something pretty cool here - an AI assistant that respects your privacy and runs entirely on your machine. Every contribution, no matter how small, helps make AI more accessible to everyone.
|
||||
|
||||
Thanks for being part of the journey. Let's build the future of local AI together! 🚀
|
||||
|
||||
2
Makefile
@ -47,6 +47,8 @@ test: lint
|
||||
yarn copy:assets:tauri
|
||||
yarn build:icon
|
||||
cargo test --manifest-path src-tauri/Cargo.toml --no-default-features --features test-tauri -- --test-threads=1
|
||||
cargo test --manifest-path src-tauri/plugins/tauri-plugin-hardware/Cargo.toml
|
||||
cargo test --manifest-path src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml
|
||||
|
||||
# Builds and publishes the app
|
||||
build-and-publish: install-and-build
|
||||
|
||||
71
core/CONTRIBUTING.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Contributing to Jan Core
|
||||
|
||||
[← Back to Main Contributing Guide](../CONTRIBUTING.md)
|
||||
|
||||
TypeScript SDK providing extension system, APIs, and type definitions for all Jan components.
|
||||
|
||||
## Key Directories
|
||||
|
||||
- **`/src/browser`** - Core APIs (events, extensions, file system)
|
||||
- **`/src/browser/extensions`** - Built-in extensions (assistant, inference, conversational)
|
||||
- **`/src/types`** - TypeScript type definitions
|
||||
- **`/src/test`** - Testing utilities
|
||||
|
||||
## Development
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Platform Agnostic** - Works everywhere (browser, Node.js)
|
||||
2. **Extension-Based** - New features = new extensions
|
||||
3. **Type Everything** - TypeScript required
|
||||
4. **Event-Driven** - Components communicate via events
|
||||
|
||||
### Building & Testing
|
||||
|
||||
```bash
|
||||
# Build the SDK
|
||||
yarn build
|
||||
|
||||
# Run tests
|
||||
yarn test
|
||||
|
||||
# Watch mode
|
||||
yarn test:watch
|
||||
```
|
||||
|
||||
### Event System
|
||||
|
||||
```typescript
|
||||
// Emit events
|
||||
events.emit('model:loaded', { modelId: 'llama-3' })
|
||||
|
||||
// Listen for events
|
||||
events.on('model:loaded', (data) => {
|
||||
console.log('Model loaded:', data.modelId)
|
||||
})
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```typescript
|
||||
describe('MyFeature', () => {
|
||||
it('should do something', () => {
|
||||
const result = doSomething()
|
||||
expect(result).toBe('expected')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Keep it simple
|
||||
- Use TypeScript fully (no `any`)
|
||||
- Write tests for critical features
|
||||
- Follow existing patterns
|
||||
- Export new modules in index files
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **TypeScript** - Type safety
|
||||
- **Rolldown** - Bundling
|
||||
- **Vitest** - Testing
|
||||
@ -6,6 +6,8 @@ import { EngineManager } from './EngineManager'
|
||||
export interface chatCompletionRequestMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool'
|
||||
content: string | null | Content[] // Content can be a string OR an array of content parts
|
||||
reasoning?: string | null // Some models return reasoning in completed responses
|
||||
reasoning_content?: string | null // Some models return reasoning in completed responses
|
||||
name?: string
|
||||
tool_calls?: any[] // Simplified tool_call_id?: string
|
||||
}
|
||||
@ -192,6 +194,10 @@ export interface chatOptions {
|
||||
export interface ImportOptions {
|
||||
modelPath: string
|
||||
mmprojPath?: string
|
||||
modelSha256?: string
|
||||
modelSize?: number
|
||||
mmprojSha256?: string
|
||||
mmprojSize?: number
|
||||
}
|
||||
|
||||
export interface importResult {
|
||||
@ -270,4 +276,10 @@ export abstract class AIEngine extends BaseExtension {
|
||||
* Optional method to get the underlying chat client
|
||||
*/
|
||||
getChatClient?(sessionId: string): any
|
||||
|
||||
/**
|
||||
* Check if a tool is supported by the model
|
||||
* @param modelId
|
||||
*/
|
||||
abstract isToolSupported(modelId: string): Promise<boolean>
|
||||
}
|
||||
|
||||
@ -58,6 +58,7 @@ export enum AppEvent {
|
||||
onAppUpdateDownloadUpdate = 'onAppUpdateDownloadUpdate',
|
||||
onAppUpdateDownloadError = 'onAppUpdateDownloadError',
|
||||
onAppUpdateDownloadSuccess = 'onAppUpdateDownloadSuccess',
|
||||
onModelImported = 'onModelImported',
|
||||
|
||||
onUserSubmitQuickAsk = 'onUserSubmitQuickAsk',
|
||||
onSelectedText = 'onSelectedText',
|
||||
@ -72,6 +73,9 @@ export enum DownloadEvent {
|
||||
onFileDownloadSuccess = 'onFileDownloadSuccess',
|
||||
onFileDownloadStopped = 'onFileDownloadStopped',
|
||||
onFileDownloadStarted = 'onFileDownloadStarted',
|
||||
onModelValidationStarted = 'onModelValidationStarted',
|
||||
onModelValidationFailed = 'onModelValidationFailed',
|
||||
onFileDownloadAndVerificationSuccess = 'onFileDownloadAndVerificationSuccess',
|
||||
}
|
||||
export enum ExtensionRoute {
|
||||
baseExtensions = 'baseExtensions',
|
||||
|
||||
BIN
docs/public/assets/images/changelog/mcp-linear.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
docs/public/assets/images/changelog/mcplinear2.gif
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
@ -11,11 +11,6 @@
|
||||
"type": "page",
|
||||
"title": "Documentation"
|
||||
},
|
||||
"cortex": {
|
||||
"type": "page",
|
||||
"title": "Cortex",
|
||||
"display": "hidden"
|
||||
},
|
||||
"platforms": {
|
||||
"type": "page",
|
||||
"title": "Platforms",
|
||||
|
||||
77
docs/src/pages/changelog/2025-08-14-general-improvs.mdx
Normal file
@ -0,0 +1,77 @@
|
||||
---
|
||||
title: "Jan v0.6.8: Engine fixes, new MCP tutorials, and cleaner docs"
|
||||
version: 0.6.8
|
||||
description: "Llama.cpp stability upgrades, Linear/Todoist MCP tutorials, new model pages (Lucy, Jan‑v1), and docs reorganization"
|
||||
date: 2025-08-14
|
||||
ogImage: "/assets/images/changelog/mcplinear2.gif"
|
||||
---
|
||||
|
||||
import ChangelogHeader from "@/components/Changelog/ChangelogHeader"
|
||||
import { Callout } from 'nextra/components'
|
||||
|
||||
<ChangelogHeader title="Jan v0.6.8: Engine fixes, new MCP tutorials, and cleaner docs" date="2025-08-14" ogImage="/assets/images/changelog/mcplinear2.gif" />
|
||||
|
||||
## Highlights 🎉
|
||||
|
||||
v0.6.8 focuses on stability and real workflows: major llama.cpp hardening, two new MCP productivity tutorials, new model pages, and a cleaner docs structure.
|
||||
|
||||
|
||||
### 🚀 New tutorials & docs
|
||||
|
||||
- Linear MCP tutorial: create/update issues, projects, comments, cycles — directly from chat
|
||||
- Todoist MCP tutorial: add, list, update, complete, and delete tasks via natural language
|
||||
- New model pages:
|
||||
- Lucy (1.7B) — optimized for web_search tool calling
|
||||
- Jan‑v1 (4B) — strong SimpleQA (91.1%), solid tool use
|
||||
- Docs updates:
|
||||
- Reorganized landing and Products sections; streamlined QuickStart
|
||||
- Ongoing Docs v2 (Astro) migration with handbook, blog, and changelog sections added and then removed
|
||||
|
||||
### 🧱 Llama.cpp engine: stability & correctness
|
||||
|
||||
- Structured error handling for llama.cpp extension
|
||||
- Better argument handling, improved model path resolution, clearer error messages
|
||||
- Device parsing tests; conditional Vulkan support; support for missing CUDA backends
|
||||
- AVX2 instruction support check (Mac Intel) for MCP
|
||||
- Server hang on model load — fixed
|
||||
- Session management & port allocation moved to backend for robustness
|
||||
- Recommended labels in settings; per‑model Jinja template customization
|
||||
- Tensor buffer type override support
|
||||
- “Continuous batching” description corrected
|
||||
|
||||
### ✨ UX polish
|
||||
|
||||
- Thread sorting fixed; assistant dropdown click reliability improved
|
||||
- Responsive left panel text color; provider logo blur cleanup
|
||||
- Show toast on download errors; context size error dialog restored
|
||||
- Prevent accidental message submit for IME users
|
||||
- Onboarding loop fixed; GPU detection brought back
|
||||
- Connected MCP servers status stays in sync after JSON edits
|
||||
|
||||
### 🔍 Hub & providers
|
||||
|
||||
- Hugging Face token respected for repo search and private README visualization
|
||||
- Deep links and model details fixed
|
||||
- Factory reset unblocked; special chars in `modelId` handled
|
||||
- Feature toggle for auto‑updater respected
|
||||
|
||||
### 🧪 CI & housekeeping
|
||||
|
||||
- Nightly/PR workflow tweaks; clearer API server logs
|
||||
- Cleaned unused hardware APIs
|
||||
- Release workflows updated; docs release paths consolidated
|
||||
|
||||
### 🤖 Reasoning model fixes
|
||||
|
||||
- gpt‑oss “thinking block” rendering fixed
|
||||
- Reasoning text no longer included in chat completion requests
|
||||
|
||||
## Thanks to new contributors
|
||||
|
||||
· @cmppoon · @shmutalov · @B0sh
|
||||
|
||||
---
|
||||
|
||||
Update your Jan or [download the latest](https://jan.ai/).
|
||||
|
||||
For the complete list of changes, see the [GitHub release notes](https://github.com/menloresearch/jan/releases/tag/v0.6.8).
|
||||
BIN
docs/src/pages/docs/_assets/chat_jan_v1.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
docs/src/pages/docs/_assets/creative_bench_jan_v1.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
docs/src/pages/docs/_assets/download_janv1.png
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
docs/src/pages/docs/_assets/enable_mcp.png
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
docs/src/pages/docs/_assets/jan_v1_demo.gif
Normal file
|
After Width: | Height: | Size: 7.7 MiB |
BIN
docs/src/pages/docs/_assets/jan_v1_serper.png
Normal file
|
After Width: | Height: | Size: 625 KiB |
BIN
docs/src/pages/docs/_assets/jan_v1_serper1.png
Normal file
|
After Width: | Height: | Size: 930 KiB |
BIN
docs/src/pages/docs/_assets/linear1.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
docs/src/pages/docs/_assets/linear2.png
Normal file
|
After Width: | Height: | Size: 695 KiB |
BIN
docs/src/pages/docs/_assets/linear3.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
docs/src/pages/docs/_assets/linear4.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
docs/src/pages/docs/_assets/linear5.png
Normal file
|
After Width: | Height: | Size: 926 KiB |
BIN
docs/src/pages/docs/_assets/linear6.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
docs/src/pages/docs/_assets/linear7.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
docs/src/pages/docs/_assets/linear8.png
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
docs/src/pages/docs/_assets/lucy_demo.gif
Normal file
|
After Width: | Height: | Size: 23 MiB |
BIN
docs/src/pages/docs/_assets/mcplinear2.gif
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
docs/src/pages/docs/_assets/mcptodoist_extreme.gif
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
docs/src/pages/docs/_assets/serper_janparams.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
docs/src/pages/docs/_assets/serper_page.png
Normal file
|
After Width: | Height: | Size: 1021 KiB |
BIN
docs/src/pages/docs/_assets/serper_playground.png
Normal file
|
After Width: | Height: | Size: 600 KiB |
BIN
docs/src/pages/docs/_assets/simpleqa_jan_v1.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
docs/src/pages/docs/_assets/simpleqa_lucy.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
docs/src/pages/docs/_assets/todoist1.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
docs/src/pages/docs/_assets/todoist2.png
Normal file
|
After Width: | Height: | Size: 383 KiB |
BIN
docs/src/pages/docs/_assets/todoist3.png
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
docs/src/pages/docs/_assets/todoist4.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
docs/src/pages/docs/_assets/todoist5.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
docs/src/pages/docs/_assets/toggle_tools.png
Normal file
|
After Width: | Height: | Size: 586 KiB |
BIN
docs/src/pages/docs/_assets/turn_on_mcp.png
Normal file
|
After Width: | Height: | Size: 209 KiB |
@ -4,20 +4,16 @@
|
||||
"title": "Switcher"
|
||||
},
|
||||
"index": "Overview",
|
||||
"how-to-separator": {
|
||||
"title": "HOW TO",
|
||||
"getting-started-separator": {
|
||||
"title": "GETTING STARTED",
|
||||
"type": "separator"
|
||||
},
|
||||
"quickstart": "QuickStart",
|
||||
"desktop": "Install 👋 Jan",
|
||||
"threads": "Start Chatting",
|
||||
"jan-models": "Use Jan Models",
|
||||
"jan-models": "Models",
|
||||
"assistants": "Create Assistants",
|
||||
|
||||
"tutorials-separators": {
|
||||
"title": "TUTORIALS",
|
||||
"type": "separator"
|
||||
},
|
||||
"remote-models": "Connect to Remote Models",
|
||||
"remote-models": "Cloud Providers",
|
||||
"mcp-examples": "Tutorials",
|
||||
|
||||
"explanation-separator": {
|
||||
"title": "EXPLANATION",
|
||||
@ -38,7 +34,6 @@
|
||||
},
|
||||
"manage-models": "Manage Models",
|
||||
"mcp": "Model Context Protocol",
|
||||
"mcp-examples": "MCP Examples",
|
||||
|
||||
"localserver": {
|
||||
"title": "LOCAL SERVER",
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
---
|
||||
title: Jan
|
||||
description: Jan is an open-source ChatGPT-alternative and self-hosted AI platform - build and run AI on your own desktop or server.
|
||||
description: Build, run, and own your AI. From laptop to superintelligence.
|
||||
keywords:
|
||||
[
|
||||
Jan,
|
||||
Jan AI,
|
||||
ChatGPT alternative,
|
||||
OpenAI platform alternative,
|
||||
local API,
|
||||
open superintelligence,
|
||||
AI ecosystem,
|
||||
local AI,
|
||||
private AI,
|
||||
conversational AI,
|
||||
no-subscription fee,
|
||||
self-hosted AI,
|
||||
llama.cpp,
|
||||
Model Context Protocol,
|
||||
MCP,
|
||||
GGUF models,
|
||||
large language model,
|
||||
LLM,
|
||||
]
|
||||
@ -24,123 +26,152 @@ import FAQBox from '@/components/FaqBox'
|
||||
|
||||

|
||||
|
||||
## Jan's Goal
|
||||
|
||||
Jan is a ChatGPT alternative that runs 100% offline on your desktop and (*soon*) on mobile. Our goal is to
|
||||
make it easy for anyone, with or without coding skills, to download and use AI models with full control and
|
||||
[privacy](https://www.reuters.com/legal/legalindustry/privacy-paradox-with-ai-2023-10-31/).
|
||||
> Jan's goal is to build superintelligence that you can self-host and use locally.
|
||||
|
||||
Jan is powered by [Llama.cpp](https://github.com/ggerganov/llama.cpp), a local AI engine that provides an OpenAI-compatible
|
||||
API that can run in the background by default at `https://localhost:1337` (or your custom port). This enables you to power all sorts of
|
||||
applications with AI capabilities from your laptop/PC. For example, you can connect local tools like [Continue](https://jan.ai/docs/server-examples/continue-dev)
|
||||
and [Cline](https://cline.bot/) to Jan and power them using your favorite models.
|
||||
## What is Jan?
|
||||
|
||||
Jan doesn't limit you to locally hosted models, meaning, you can create an API key from your favorite model provider,
|
||||
add it to Jan via the configuration's page and start talking to your favorite models.
|
||||
Jan is an open-source AI ecosystem that runs on your hardware. We're building towards open superintelligence - a complete AI platform you actually own.
|
||||
|
||||
### Features
|
||||
### The Ecosystem
|
||||
|
||||
- Download popular open-source LLMs (Llama3, Gemma3, Qwen3, and more) from the HuggingFace [Model Hub](./docs/manage-models.mdx)
|
||||
or import any GGUF files (the model format used by llama.cpp) available locally
|
||||
- Connect to [cloud services](/docs/remote-models/openai) (OpenAI, Anthropic, Mistral, Groq, etc.)
|
||||
- [Chat](./docs/threads.mdx) with AI models & [customize their parameters](/docs/model-parameters.mdx) via our
|
||||
intuitive interface
|
||||
- Use our [local API server](https://jan.ai/api-reference) with an OpenAI-equivalent API to power other apps.
|
||||
**Models**: We build specialized models for real tasks, not general-purpose assistants:
|
||||
- **Jan-Nano (32k/128k)**: 4B parameters designed for deep research with MCP. The 128k version processes entire papers, codebases, or legal documents in one go
|
||||
- **Lucy**: 1.7B model that runs agentic web search on your phone. Small enough for CPU, smart enough for complex searches
|
||||
- **Jan-v1**: 4B model for agentic reasoning and tool use, achieving 91.1% on SimpleQA
|
||||
|
||||
### Philosophy
|
||||
We also integrate the best open-source models - from OpenAI's gpt-oss to community GGUF models on Hugging Face. The goal: make powerful AI accessible to everyone, not just those with server farms.
|
||||
|
||||
Jan is built to be [user-owned](about#-user-owned), this means that Jan is:
|
||||
- Truly open source via the [Apache 2.0 license](https://github.com/menloresearch/jan/blob/dev/LICENSE)
|
||||
- [Data is stored locally, following one of the many local-first principles](https://www.inkandswitch.com/local-first)
|
||||
- Internet is optional, Jan can run 100% offline
|
||||
- Free choice of AI models, both local and cloud-based
|
||||
- We do not collect or sell user data. See our [Privacy Policy](./privacy).
|
||||
**Applications**: Jan Desktop runs on your computer today. Web, mobile, and server versions coming in late 2025. Everything syncs, everything works together.
|
||||
|
||||
**Tools**: Connect to the real world through [Model Context Protocol (MCP)](./mcp). Design with Canva, analyze data in Jupyter notebooks, control browsers, execute code in E2B sandboxes. Your AI can actually do things, not just talk about them.
|
||||
|
||||
<Callout>
|
||||
You can read more about our [philosophy](/about#philosophy) here.
|
||||
API keys are optional. No account needed. Just download and run. Bring your own API keys to connect your favorite cloud models.
|
||||
</Callout>
|
||||
|
||||
### Inspirations
|
||||
### Core Features
|
||||
|
||||
Jan is inspired by the concepts of [Calm Computing](https://en.wikipedia.org/wiki/Calm_technology), and the Disappearing Computer.
|
||||
- **Run Models Locally**: Download any GGUF model from Hugging Face, use OpenAI's gpt-oss models, or connect to cloud providers
|
||||
- **OpenAI-Compatible API**: Local server at `localhost:1337` works with tools like [Continue](./server-examples/continue-dev) and [Cline](https://cline.bot/)
|
||||
- **Extend with MCP Tools**: Browser automation, web search, data analysis, design tools - all through natural language
|
||||
- **Your Choice of Infrastructure**: Run on your laptop, self-host on your servers (soon), or use cloud when you need it
|
||||
|
||||
### Growing MCP Integrations
|
||||
|
||||
Jan connects to real tools through MCP:
|
||||
- **Creative Work**: Generate designs with Canva
|
||||
- **Data Analysis**: Execute Python in Jupyter notebooks
|
||||
- **Web Automation**: Control browsers with Browserbase and Browser Use
|
||||
- **Code Execution**: Run code safely in E2B sandboxes
|
||||
- **Search & Research**: Access current information via Exa, Perplexity, and Octagon
|
||||
- **More coming**: The MCP ecosystem is expanding rapidly
|
||||
|
||||
## Philosophy
|
||||
|
||||
Jan is built to be user-owned:
|
||||
- **Open Source**: Apache 2.0 license - truly free
|
||||
- **Local First**: Your data stays on your device. Internet is optional
|
||||
- **Privacy Focused**: We don't collect or sell user data. See our [Privacy Policy](./privacy)
|
||||
- **No Lock-in**: Export your data anytime. Use any model. Switch between local and cloud
|
||||
|
||||
<Callout type="info">
|
||||
We're building AI that respects your choices. Not another wrapper around someone else's API.
|
||||
</Callout>
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. [Download Jan](./quickstart) for your operating system
|
||||
2. Choose a model - download locally or add cloud API keys
|
||||
3. Start chatting or connect tools via MCP
|
||||
4. Build with our [API](https://jan.ai/api-reference)
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Jan is built on the shoulders of many open-source projects like:
|
||||
|
||||
- [Llama.cpp](https://github.com/ggerganov/llama.cpp/blob/master/LICENSE)
|
||||
- [Scalar](https://github.com/scalar/scalar)
|
||||
Jan is built on the shoulders of giants:
|
||||
- [Llama.cpp](https://github.com/ggerganov/llama.cpp) for inference
|
||||
- [Model Context Protocol](https://modelcontextprotocol.io) for tool integration
|
||||
- The open-source community that makes this possible
|
||||
|
||||
## FAQs
|
||||
|
||||
<FAQBox title="What is Jan?">
|
||||
Jan is a customizable AI assistant that can run offline on your computer - a privacy-focused alternative to tools like
|
||||
ChatGPT, Anthropic's Claude, and Google Gemini, with optional cloud AI support.
|
||||
Jan is an open-source AI ecosystem building towards superintelligence you can self-host. Today it's a desktop app that runs AI models locally. Tomorrow it's a complete platform across all your devices.
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="How do I get started with Jan?">
|
||||
Download Jan on your computer, download a model or add API key for a cloud-based one, and start chatting. For
|
||||
detailed setup instructions, see our [Quick Start](/docs/quickstart) guide.
|
||||
<FAQBox title="How is this different from other AI platforms?">
|
||||
Other platforms are models behind APIs you rent. Jan is a complete AI ecosystem you own. Run any model, use real tools through MCP, keep your data private, and never pay subscriptions for local use.
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="What models can I use?">
|
||||
**Jan Models:**
|
||||
- Jan-Nano (32k/128k) - Deep research with MCP integration
|
||||
- Lucy - Mobile-optimized agentic search (1.7B)
|
||||
- Jan-v1 - Agentic reasoning and tool use (4B)
|
||||
|
||||
**Open Source:**
|
||||
- OpenAI's gpt-oss models (120b and 20b)
|
||||
- Any GGUF model from Hugging Face
|
||||
|
||||
**Cloud (with your API keys):**
|
||||
- OpenAI, Anthropic, Mistral, Groq, and more
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="What are MCP tools?">
|
||||
MCP (Model Context Protocol) lets AI interact with real applications. Instead of just generating text, your AI can create designs in Canva, analyze data in Jupyter, browse the web, and execute code - all through conversation.
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="Is Jan compatible with my system?">
|
||||
Jan supports all major operating systems,
|
||||
- [Mac](/docs/desktop/mac#compatibility)
|
||||
- [Windows](/docs/desktop/windows#compatibility)
|
||||
- [Linux](/docs/desktop/linux)
|
||||
**Supported OS**:
|
||||
- [Windows 10+](/docs/desktop/windows#compatibility)
|
||||
- [macOS 12+](/docs/desktop/mac#compatibility)
|
||||
- [Linux (Ubuntu 20.04+)](/docs/desktop/linux)
|
||||
|
||||
Hardware compatibility includes:
|
||||
- NVIDIA GPUs (CUDA)
|
||||
- AMD GPUs (Vulkan)
|
||||
- Intel Arc GPUs (Vulkan)
|
||||
- Any GPU with Vulkan support
|
||||
**Hardware**:
|
||||
- Minimum: 8GB RAM, 10GB storage
|
||||
- Recommended: 16GB RAM, GPU (NVIDIA/AMD/Intel), 50GB storage
|
||||
- Works with: NVIDIA (CUDA), AMD (Vulkan), Intel Arc, Apple Silicon
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="How does Jan protect my privacy?">
|
||||
Jan prioritizes privacy by:
|
||||
- Running 100% offline with locally-stored data
|
||||
- Using open-source models that keep your conversations private
|
||||
- Storing all files and chat history on your device in the [Jan Data Folder](/docs/data-folder)
|
||||
- Never collecting or selling your data
|
||||
<FAQBox title="Is Jan really free?">
|
||||
**Local use**: Always free, no catches
|
||||
**Cloud models**: You pay providers directly (we add no markup)
|
||||
**Jan cloud**: Optional paid services coming 2025
|
||||
|
||||
The core platform will always be free and open source.
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="How does Jan protect privacy?">
|
||||
- Runs 100% offline once models are downloaded
|
||||
- All data stored locally in [Jan Data Folder](/docs/data-folder)
|
||||
- No telemetry without explicit consent
|
||||
- Open source code you can audit
|
||||
|
||||
<Callout type="warning">
|
||||
When using third-party cloud AI services through Jan, their data policies apply. Check their privacy terms.
|
||||
When using cloud providers through Jan, their privacy policies apply.
|
||||
</Callout>
|
||||
|
||||
You can optionally share anonymous usage statistics to help improve Jan, but your conversations are never
|
||||
shared. See our complete [Privacy Policy](./docs/privacy).
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="What models can I use with Jan?">
|
||||
- Download optimized models from the [Jan Hub](/docs/manage-models)
|
||||
- Import GGUF models from Hugging Face or your local files
|
||||
- Connect to cloud providers like OpenAI, Anthropic, Mistral and Groq (requires your own API keys)
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="Is Jan really free? What's the catch?">
|
||||
Jan is completely free and open-source with no subscription fees for local models and features. When using cloud-based
|
||||
models (like GPT-4o or Claude Sonnet 3.7), you'll only pay the standard rates to those providers—we add no markup.
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="Can I use Jan offline?">
|
||||
Yes! Once you've downloaded a local model, Jan works completely offline with no internet connection needed.
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="How can I contribute or get community help?">
|
||||
- Join our [Discord community](https://discord.gg/qSwXFx6Krr) to connect with other users
|
||||
- Contribute through [GitHub](https://github.com/menloresearch/jan) (no permission needed!)
|
||||
- Get troubleshooting help in our [Discord](https://discord.com/invite/FTk2MvZwJH) channel [#🆘|jan-help](https://discord.com/channels/1107178041848909847/1192090449725358130)
|
||||
- Check our [Troubleshooting](./docs/troubleshooting) guide for common issues
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="Can I self-host Jan?">
|
||||
Yes! We fully support the self-hosted movement. Either download Jan directly or fork it on
|
||||
[GitHub repository](https://github.com/menloresearch/jan) and build it from source.
|
||||
Yes. Download directly or build from [source](https://github.com/menloresearch/jan). Jan Server for production deployments coming late 2025.
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="What does Jan stand for?">
|
||||
Jan stands for "Just a Name". We are, admittedly, bad at marketing 😂.
|
||||
<FAQBox title="When will mobile/web versions launch?">
|
||||
- **Jan Web**: Beta late 2025
|
||||
- **Jan Mobile**: Late 2025
|
||||
- **Jan Server**: Late 2025
|
||||
|
||||
All versions will sync seamlessly.
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="How can I contribute?">
|
||||
- Code: [GitHub](https://github.com/menloresearch/jan)
|
||||
- Community: [Discord](https://discord.gg/FTk2MvZwJH)
|
||||
- Testing: Help evaluate models and report bugs
|
||||
- Documentation: Improve guides and tutorials
|
||||
</FAQBox>
|
||||
|
||||
<FAQBox title="Are you hiring?">
|
||||
Yes! We love hiring from our community. Check out our open positions at [Careers](https://menlo.bamboohr.com/careers).
|
||||
</FAQBox>
|
||||
Yes! We love hiring from our community. Check [Careers](https://menlo.bamboohr.com/careers).
|
||||
</FAQBox>
|
||||
129
docs/src/pages/docs/jan-models/jan-v1.mdx
Normal file
@ -0,0 +1,129 @@
|
||||
---
|
||||
title: Jan-v1
|
||||
description: 4B parameter model with strong performance on reasoning benchmarks
|
||||
keywords:
|
||||
[
|
||||
Jan,
|
||||
Jan-v1,
|
||||
Jan Models,
|
||||
reasoning,
|
||||
SimpleQA,
|
||||
tool calling,
|
||||
GGUF,
|
||||
4B model,
|
||||
]
|
||||
---
|
||||
|
||||
import { Callout } from 'nextra/components'
|
||||
|
||||
# Jan-v1
|
||||
|
||||
## Overview
|
||||
|
||||
Jan-v1 is a 4B parameter model based on Qwen3-4B-thinking, designed for reasoning and problem-solving tasks. The model achieves 91.1% accuracy on SimpleQA through model scaling and fine-tuning approaches.
|
||||
|
||||
## Performance
|
||||
|
||||
### SimpleQA Benchmark
|
||||
|
||||
Jan-v1 demonstrates strong factual question-answering capabilities:
|
||||
|
||||

|
||||
|
||||
At 91.1% accuracy, Jan-v1 outperforms several larger models on SimpleQA, including Perplexity's 70B model. This performance represents effective scaling and fine-tuning for a 4B parameter model.
|
||||
|
||||
### Chat and Creativity Benchmarks
|
||||
|
||||
Jan-v1 has been evaluated on conversational and creative tasks:
|
||||
|
||||

|
||||
|
||||
These benchmarks (EQBench, CreativeWriting, and IFBench) measure the model's ability to handle conversational nuance, creative expression, and instruction following.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Memory**:
|
||||
- Minimum: 8GB RAM (with Q4 quantization)
|
||||
- Recommended: 16GB RAM (with Q8 quantization)
|
||||
- **Hardware**: CPU or GPU
|
||||
- **API Support**: OpenAI-compatible at localhost:1337
|
||||
|
||||
## Using Jan-v1
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Download Jan Desktop
|
||||
2. Select Jan-v1 from the model list
|
||||
3. Start chatting - no additional configuration needed
|
||||
|
||||
### Demo
|
||||
|
||||

|
||||
|
||||
### Deployment Options
|
||||
|
||||
**Using vLLM:**
|
||||
```bash
|
||||
vllm serve janhq/Jan-v1-4B \
|
||||
--host 0.0.0.0 \
|
||||
--port 1234 \
|
||||
--enable-auto-tool-choice \
|
||||
--tool-call-parser hermes
|
||||
```
|
||||
|
||||
**Using llama.cpp:**
|
||||
```bash
|
||||
llama-server --model jan-v1.gguf \
|
||||
--host 0.0.0.0 \
|
||||
--port 1234 \
|
||||
--jinja \
|
||||
--no-context-shift
|
||||
```
|
||||
|
||||
### Recommended Parameters
|
||||
|
||||
```yaml
|
||||
temperature: 0.6
|
||||
top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
max_tokens: 2048
|
||||
```
|
||||
|
||||
## What Jan-v1 Does Well
|
||||
|
||||
- **Question Answering**: 91.1% accuracy on SimpleQA
|
||||
- **Reasoning Tasks**: Built on thinking-optimized base model
|
||||
- **Tool Calling**: Supports function calling through hermes parser
|
||||
- **Instruction Following**: Reliable response to user instructions
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Model Size**: 4B parameters limits complex reasoning compared to larger models
|
||||
- **Specialized Tasks**: Optimized for Q&A and reasoning, not specialized domains
|
||||
- **Context Window**: Standard context limitations apply
|
||||
|
||||
## Available Formats
|
||||
|
||||
### GGUF Quantizations
|
||||
|
||||
- **Q4_K_M**: 2.5 GB - Good balance of size and quality
|
||||
- **Q5_K_M**: 2.89 GB - Better quality, slightly larger
|
||||
- **Q6_K**: 3.31 GB - Near-full quality
|
||||
- **Q8_0**: 4.28 GB - Highest quality quantization
|
||||
|
||||
## Models Available
|
||||
|
||||
- [Jan-v1 on Hugging Face](https://huggingface.co/janhq/Jan-v1-4B)
|
||||
- [Jan-v1 GGUF on Hugging Face](https://huggingface.co/janhq/Jan-v1-4B-GGUF)
|
||||
|
||||
## Technical Notes
|
||||
|
||||
<Callout type="info">
|
||||
The model includes a system prompt in the chat template by default to match benchmark performance. A vanilla template without system prompt is available in `chat_template_raw.jinja`.
|
||||
</Callout>
|
||||
|
||||
## Community
|
||||
|
||||
- **Discussions**: [HuggingFace Community](https://huggingface.co/janhq/Jan-v1-4B/discussions)
|
||||
- **Support**: Available through Jan App at [jan.ai](https://jan.ai)
|
||||
122
docs/src/pages/docs/jan-models/lucy.mdx
Normal file
@ -0,0 +1,122 @@
|
||||
---
|
||||
title: Lucy
|
||||
description: Compact 1.7B model optimized for web search with tool calling
|
||||
keywords:
|
||||
[
|
||||
Jan,
|
||||
Lucy,
|
||||
Jan Models,
|
||||
web search,
|
||||
tool calling,
|
||||
Serper API,
|
||||
GGUF,
|
||||
1.7B model,
|
||||
]
|
||||
---
|
||||
|
||||
import { Callout } from 'nextra/components'
|
||||
|
||||
# Lucy
|
||||
|
||||
## Overview
|
||||
|
||||
Lucy is a 1.7B parameter model built on Qwen3-1.7B, optimized for web search through tool calling. The model has been trained to work effectively with search APIs like Serper, enabling web search capabilities in resource-constrained environments.
|
||||
|
||||
## Performance
|
||||
|
||||
### SimpleQA Benchmark
|
||||
|
||||
Lucy achieves competitive performance on SimpleQA despite its small size:
|
||||
|
||||

|
||||
|
||||
The benchmark shows Lucy (1.7B) compared against models ranging from 4B to 600B+ parameters. While larger models generally perform better, Lucy demonstrates that effective web search integration can partially compensate for smaller model size.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Memory**:
|
||||
- Minimum: 4GB RAM (with Q4 quantization)
|
||||
- Recommended: 8GB RAM (with Q8 quantization)
|
||||
- **Search API**: Serper API key required for web search functionality
|
||||
- **Hardware**: Runs on CPU or GPU
|
||||
|
||||
<Callout type="info">
|
||||
To use Lucy's web search capabilities, you'll need a Serper API key. Get one at [serper.dev](https://serper.dev).
|
||||
</Callout>
|
||||
|
||||
## Using Lucy
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Download Jan Desktop
|
||||
2. Download Lucy from the Hub
|
||||
3. Configure Serper MCP with your API key
|
||||
4. Start using web search through natural language
|
||||
|
||||
### Demo
|
||||
|
||||

|
||||
|
||||
### Deployment Options
|
||||
|
||||
**Using vLLM:**
|
||||
```bash
|
||||
vllm serve Menlo/Lucy-128k \
|
||||
--host 0.0.0.0 \
|
||||
--port 1234 \
|
||||
--enable-auto-tool-choice \
|
||||
--tool-call-parser hermes \
|
||||
--rope-scaling '{"rope_type":"yarn","factor":3.2,"original_max_position_embeddings":40960}' \
|
||||
--max-model-len 131072
|
||||
```
|
||||
|
||||
**Using llama.cpp:**
|
||||
```bash
|
||||
llama-server model.gguf \
|
||||
--host 0.0.0.0 \
|
||||
--port 1234 \
|
||||
--rope-scaling yarn \
|
||||
--rope-scale 3.2 \
|
||||
--yarn-orig-ctx 40960
|
||||
```
|
||||
|
||||
### Recommended Parameters
|
||||
|
||||
```yaml
|
||||
Temperature: 0.7
|
||||
Top-p: 0.9
|
||||
Top-k: 20
|
||||
Min-p: 0.0
|
||||
```
|
||||
|
||||
## What Lucy Does Well
|
||||
|
||||
- **Web Search Integration**: Optimized to call search tools and process results
|
||||
- **Small Footprint**: 1.7B parameters means lower memory requirements
|
||||
- **Tool Calling**: Reliable function calling for search APIs
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Requires Internet**: Web search functionality needs active connection
|
||||
- **API Costs**: Serper API has usage limits and costs
|
||||
- **Context Processing**: While supporting 128k context, performance may vary with very long inputs
|
||||
- **General Knowledge**: Limited by 1.7B parameter size for tasks beyond search
|
||||
|
||||
## Models Available
|
||||
|
||||
- [Lucy on Hugging Face](https://huggingface.co/Menlo/Lucy-128k)
|
||||
- [Lucy GGUF on Hugging Face](https://huggingface.co/Menlo/Lucy-128k-gguf)
|
||||
|
||||
## Citation
|
||||
|
||||
```bibtex
|
||||
@misc{dao2025lucyedgerunningagenticweb,
|
||||
title={Lucy: edgerunning agentic web search on mobile with machine generated task vectors},
|
||||
author={Alan Dao and Dinh Bach Vu and Alex Nguyen and Norapat Buppodom},
|
||||
year={2025},
|
||||
eprint={2508.00360},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.CL},
|
||||
url={https://arxiv.org/abs/2508.00360},
|
||||
}
|
||||
```
|
||||
20
docs/src/pages/docs/mcp-examples/_meta.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"browser": {
|
||||
"title": "Browser Automation"
|
||||
},
|
||||
"data-analysis": {
|
||||
"title": "Data Analysis"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search & Research"
|
||||
},
|
||||
"design": {
|
||||
"title": "Design Tools"
|
||||
},
|
||||
"deepresearch": {
|
||||
"title": "Deep Research"
|
||||
},
|
||||
"productivity": {
|
||||
"title": "Productivity"
|
||||
}
|
||||
}
|
||||
6
docs/src/pages/docs/mcp-examples/browser/_meta.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"browserbase": {
|
||||
"title": "Browserbase",
|
||||
"href": "/docs/mcp-examples/browser/browserbase"
|
||||
}
|
||||
}
|
||||
10
docs/src/pages/docs/mcp-examples/data-analysis/_meta.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"e2b": {
|
||||
"title": "E2B Code Sandbox",
|
||||
"href": "/docs/mcp-examples/data-analysis/e2b"
|
||||
},
|
||||
"jupyter": {
|
||||
"title": "Jupyter Notebooks",
|
||||
"href": "/docs/mcp-examples/data-analysis/jupyter"
|
||||
}
|
||||
}
|
||||
6
docs/src/pages/docs/mcp-examples/deepresearch/_meta.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"octagon": {
|
||||
"title": "Octagon Deep Research",
|
||||
"href": "/docs/mcp-examples/deepresearch/octagon"
|
||||
}
|
||||
}
|
||||
6
docs/src/pages/docs/mcp-examples/design/_meta.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"canva": {
|
||||
"title": "Canva",
|
||||
"href": "/docs/mcp-examples/design/canva"
|
||||
}
|
||||
}
|
||||
10
docs/src/pages/docs/mcp-examples/productivity/_meta.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"todoist": {
|
||||
"title": "Todoist",
|
||||
"href": "/docs/mcp-examples/productivity/todoist"
|
||||
},
|
||||
"linear": {
|
||||
"title": "Linear",
|
||||
"href": "/docs/mcp-examples/productivity/linear"
|
||||
}
|
||||
}
|
||||
268
docs/src/pages/docs/mcp-examples/productivity/linear.mdx
Normal file
@ -0,0 +1,268 @@
|
||||
---
|
||||
title: Linear MCP
|
||||
description: Manage software projects and issue tracking through natural language with Linear integration.
|
||||
keywords:
|
||||
[
|
||||
Jan,
|
||||
MCP,
|
||||
Model Context Protocol,
|
||||
Linear,
|
||||
project management,
|
||||
issue tracking,
|
||||
agile,
|
||||
software development,
|
||||
tool calling,
|
||||
]
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components'
|
||||
|
||||
# Linear MCP
|
||||
|
||||
[Linear MCP](https://linear.app) provides comprehensive project management capabilities through natural conversation. Transform your software development workflow by managing issues, projects, and team collaboration directly through AI.
|
||||
|
||||
## Available Tools
|
||||
|
||||
Linear MCP offers extensive project management capabilities:
|
||||
|
||||
### Issue Management
|
||||
- `list_issues`: View all issues in your workspace
|
||||
- `get_issue`: Get details of a specific issue
|
||||
- `create_issue`: Create new issues with full details
|
||||
- `update_issue`: Modify existing issues
|
||||
- `list_my_issues`: See your assigned issues
|
||||
- `list_issue_statuses`: View available workflow states
|
||||
- `list_issue_labels`: See and manage labels
|
||||
- `create_issue_label`: Create new labels
|
||||
|
||||
### Project & Team
|
||||
- `list_projects`: View all projects
|
||||
- `get_project`: Get project details
|
||||
- `create_project`: Start new projects
|
||||
- `update_project`: Modify project settings
|
||||
- `list_teams`: See all teams
|
||||
- `get_team`: Get team information
|
||||
- `list_users`: View team members
|
||||
|
||||
### Documentation & Collaboration
|
||||
- `list_documents`: Browse documentation
|
||||
- `get_document`: Read specific documents
|
||||
- `search_documentation`: Find information
|
||||
- `list_comments`: View issue comments
|
||||
- `create_comment`: Add comments to issues
|
||||
- `list_cycles`: View sprint cycles
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Jan with experimental features enabled
|
||||
- Linear account (free for up to 250 issues)
|
||||
- Model with strong tool calling support
|
||||
- Active internet connection
|
||||
|
||||
<Callout type="info">
|
||||
Linear offers a generous free tier perfect for small teams and personal projects. Unlimited users, 250 active issues, and full API access included.
|
||||
</Callout>
|
||||
|
||||
## Setup
|
||||
|
||||
### Create Linear Account
|
||||
|
||||
1. Sign up at [linear.app](https://linear.app)
|
||||
2. Complete the onboarding process
|
||||
|
||||

|
||||
|
||||
Once logged in, you'll see your workspace:
|
||||
|
||||

|
||||
|
||||
### Enable MCP in Jan
|
||||
|
||||
<Callout type="warning">
|
||||
Enable **Experimental Features** in **Settings > General** if you don't see the MCP Servers option.
|
||||
</Callout>
|
||||
|
||||
1. Go to **Settings > MCP Servers**
|
||||
2. Toggle **Allow All MCP Tool Permission** ON
|
||||
|
||||
### Configure Linear MCP
|
||||
|
||||
Click the `+` button to add Linear MCP:
|
||||
|
||||
**Configuration:**
|
||||
- **Server Name**: `linear`
|
||||
- **Command**: `npx`
|
||||
- **Arguments**: `-y mcp-remote https://mcp.linear.app/sse`
|
||||
|
||||

|
||||
|
||||
### Authenticate with Linear
|
||||
|
||||
When you first use Linear tools, a browser tab will open for authentication:
|
||||
|
||||

|
||||
|
||||
Complete the OAuth flow to grant Jan access to your Linear workspace.
|
||||
|
||||
## Usage
|
||||
|
||||
### Select a Model with Tool Calling
|
||||
|
||||
For this example, we'll use kimi-k2 from Groq:
|
||||
|
||||
1. Add the model in Groq settings: `moonshotai/kimi-k2-instruct`
|
||||
|
||||

|
||||
|
||||
2. Enable tools for the model:
|
||||
|
||||

|
||||
|
||||
### Verify Available Tools
|
||||
|
||||
You should see all Linear tools in the chat interface:
|
||||
|
||||

|
||||
|
||||
### Epic Project Management
|
||||
|
||||
Watch AI transform mundane tasks into epic narratives:
|
||||
|
||||

|
||||
|
||||
## Creative Examples
|
||||
|
||||
### 🎭 Shakespearean Sprint Planning
|
||||
```
|
||||
Create Linear tickets in the '👋Jan' team for my AGI project as battles in a Shakespearean war epic. Each sprint is a military campaign, bugs are enemy spies, and merge conflicts are sword fights between rival houses. Invent unique epic titles and dramatic descriptions with battle cries and victory speeches. Characterize bugs as enemy villains and developers as heroic warriors in this noble quest for AGI glory. Make tasks like model training, testing, and deployment sound like grand military campaigns with honor and valor.
|
||||
```
|
||||
|
||||
### 🚀 Space Mission Development
|
||||
```
|
||||
Transform our mobile app redesign into a NASA space mission. Create issues where each feature is a mission objective, bugs are space debris to clear, and releases are launch windows. Add dramatic mission briefings, countdown sequences, and astronaut logs. Priority levels become mission criticality ratings.
|
||||
```
|
||||
|
||||
### 🏴☠️ Pirate Ship Operations
|
||||
```
|
||||
Set up our e-commerce platform project as a pirate fleet adventure. Features are islands to conquer, bugs are sea monsters, deployments are naval battles. Create colorful pirate-themed tickets with treasure maps, crew assignments, and tales of high seas adventure.
|
||||
```
|
||||
|
||||
### 🎮 Video Game Quest Log
|
||||
```
|
||||
Structure our API refactoring project like an RPG quest system. Create issues as quests with XP rewards, boss battles for major features, side quests for minor tasks. Include loot drops (completed features), skill trees (learning requirements), and epic boss fight descriptions for challenging bugs.
|
||||
```
|
||||
|
||||
### 🍳 Gordon Ramsay's Kitchen
|
||||
```
|
||||
Manage our restaurant app project as if Gordon Ramsay is the head chef. Create brutally honest tickets criticizing code quality, demanding perfection in UX like a Michelin star dish. Bugs are "bloody disasters" and successful features are "finally, some good code." Include Kitchen Nightmares-style rescue plans.
|
||||
```
|
||||
|
||||
## Practical Workflows
|
||||
|
||||
### Sprint Planning
|
||||
```
|
||||
Review all open issues in the Backend team, identify the top 10 by priority, and create a new sprint cycle called "Q1 Performance Sprint" with appropriate issues assigned.
|
||||
```
|
||||
|
||||
### Bug Triage
|
||||
```
|
||||
List all bugs labeled "critical" or "high-priority", analyze their descriptions, and suggest which ones should be fixed first based on user impact. Update their status to "In Progress" for the top 3.
|
||||
```
|
||||
|
||||
### Documentation Audit
|
||||
```
|
||||
Search our documentation for anything related to API authentication. Create issues for any gaps or outdated sections you find, labeled as "documentation" with detailed improvement suggestions.
|
||||
```
|
||||
|
||||
### Team Workload Balance
|
||||
```
|
||||
Show me all active issues grouped by assignee. Identify anyone with more than 5 high-priority items and suggest redistributions to balance the workload.
|
||||
```
|
||||
|
||||
### Release Planning
|
||||
```
|
||||
Create a project called "v2.0 Release" with milestones for: feature freeze, beta testing, documentation, and launch. Generate appropriate issues for each phase with realistic time estimates.
|
||||
```
|
||||
|
||||
## Advanced Integration Patterns
|
||||
|
||||
### Cross-Project Dependencies
|
||||
```
|
||||
Find all issues labeled "blocked" across all projects. For each one, identify what they're waiting on and create linked issues for the blocking items if they don't exist.
|
||||
```
|
||||
|
||||
### Automated Status Updates
|
||||
```
|
||||
Look at all issues assigned to me that haven't been updated in 3 days. Add a comment with a status update based on their current state and any blockers.
|
||||
```
|
||||
|
||||
### Smart Labeling
|
||||
```
|
||||
Analyze all unlabeled issues in our workspace. Based on their titles and descriptions, suggest appropriate labels and apply them. Create any missing label categories we need.
|
||||
```
|
||||
|
||||
### Sprint Retrospectives
|
||||
```
|
||||
Generate a retrospective report for our last completed cycle. List what was completed, what was pushed to next sprint, and create discussion issues for any patterns you notice.
|
||||
```
|
||||
|
||||
## Tips for Maximum Productivity
|
||||
|
||||
- **Batch Operations**: Create multiple related issues in one request
|
||||
- **Smart Templates**: Ask AI to remember your issue templates
|
||||
- **Natural Queries**: "Show me what John is working on this week"
|
||||
- **Context Awareness**: Reference previous issues in new requests
|
||||
- **Automated Workflows**: Set up recurring management tasks
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Authentication Issues:**
|
||||
- Clear browser cookies for Linear
|
||||
- Re-authenticate through the OAuth flow
|
||||
- Check Linear workspace permissions
|
||||
- Verify API access is enabled
|
||||
|
||||
**Tool Calling Errors:**
|
||||
- Ensure model supports multiple tool calls
|
||||
- Try breaking complex requests into steps
|
||||
- Verify all required fields are provided
|
||||
- Check Linear service status
|
||||
|
||||
**Missing Data:**
|
||||
- Refresh authentication token
|
||||
- Verify workspace access permissions
|
||||
- Check if issues are in archived projects
|
||||
- Ensure proper team selection
|
||||
|
||||
**Performance Issues:**
|
||||
- Linear API has rate limits (see dashboard)
|
||||
- Break bulk operations into batches
|
||||
- Cache frequently accessed data
|
||||
- Use specific filters to reduce data
|
||||
|
||||
<Callout type="tip">
|
||||
Linear's keyboard shortcuts work great alongside MCP! Use CMD+K for quick navigation while AI handles the heavy lifting.
|
||||
</Callout>
|
||||
|
||||
## Integration Ideas
|
||||
|
||||
Combine Linear with other MCP tools:
|
||||
|
||||
- **Serper + Linear**: Research technical solutions, then create implementation tickets
|
||||
- **Jupyter + Linear**: Analyze project metrics, generate data-driven sprint plans
|
||||
- **Todoist + Linear**: Sync personal tasks with work issues
|
||||
- **E2B + Linear**: Run code tests, automatically create bug reports
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
Linear MCP uses OAuth for authentication, meaning:
|
||||
- Your credentials are never shared with Jan
|
||||
- Access can be revoked anytime from Linear settings
|
||||
- Data stays within Linear's infrastructure
|
||||
- Only requested permissions are granted
|
||||
|
||||
## Next Steps
|
||||
|
||||
Linear MCP transforms project management from clicking through interfaces into natural conversation. Whether you're planning sprints, triaging bugs, or crafting epic development sagas, AI becomes your project management companion.
|
||||
|
||||
Start with simple issue creation, then explore complex workflows like automated sprint planning and workload balancing. The combination of Linear's powerful platform with AI's creative capabilities makes project management both efficient and entertaining!
|
||||
259
docs/src/pages/docs/mcp-examples/productivity/todoist.mdx
Normal file
@ -0,0 +1,259 @@
|
||||
---
|
||||
title: Todoist MCP
|
||||
description: Manage your tasks and todo lists through natural language with Todoist integration.
|
||||
keywords:
|
||||
[
|
||||
Jan,
|
||||
MCP,
|
||||
Model Context Protocol,
|
||||
Todoist,
|
||||
task management,
|
||||
productivity,
|
||||
todo list,
|
||||
tool calling,
|
||||
]
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components'
|
||||
|
||||
# Todoist MCP
|
||||
|
||||
[Todoist MCP Server](https://github.com/abhiz123/todoist-mcp-server) enables AI models to manage your Todoist tasks through natural conversation. Instead of switching between apps, you can create, update, and complete tasks by simply chatting with your AI assistant.
|
||||
|
||||
## Available Tools
|
||||
|
||||
- `todoist_create_task`: Add new tasks to your todo list
|
||||
- `todoist_get_tasks`: Retrieve and view your current tasks
|
||||
- `todoist_update_task`: Modify existing tasks
|
||||
- `todoist_complete_task`: Mark tasks as done
|
||||
- `todoist_delete_task`: Remove tasks from your list
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Jan with experimental features enabled
|
||||
- Todoist account (free or premium)
|
||||
- Model with strong tool calling support
|
||||
- Node.js installed
|
||||
|
||||
<Callout type="info">
|
||||
Todoist offers a generous free tier perfect for personal task management. Premium features add labels, reminders, and more projects.
|
||||
</Callout>
|
||||
|
||||
## Setup
|
||||
|
||||
### Create Todoist Account
|
||||
|
||||
1. Sign up at [todoist.com](https://todoist.com) or log in if you have an account
|
||||
2. Complete the onboarding process
|
||||
|
||||

|
||||
|
||||
Once logged in, you'll see your main dashboard:
|
||||
|
||||

|
||||
|
||||
### Get Your API Token
|
||||
|
||||
1. Click **Settings** (gear icon)
|
||||
2. Navigate to **Integrations**
|
||||
3. Click on the **Developer** tab
|
||||
4. Copy your API token (it's already generated for you)
|
||||
|
||||

|
||||
|
||||
### Enable MCP in Jan
|
||||
|
||||
<Callout type="warning">
|
||||
If you don't see the MCP Servers option, enable **Experimental Features** in **Settings > General** first.
|
||||
</Callout>
|
||||
|
||||
1. Go to **Settings > MCP Servers**
|
||||
2. Toggle **Allow All MCP Tool Permission** ON
|
||||
|
||||
### Configure Todoist MCP
|
||||
|
||||
Click the `+` button to add a new MCP server:
|
||||
|
||||
**Configuration:**
|
||||
- **Server Name**: `todoist`
|
||||
- **Command**: `npx`
|
||||
- **Arguments**: `-y @abhiz123/todoist-mcp-server`
|
||||
- **Environment Variables**:
|
||||
- Key: `TODOIST_API_TOKEN`, Value: `your_api_token_here`
|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
### Select a Model with Tool Calling
|
||||
|
||||
Open a new chat and select a model that excels at tool calling. Make sure tools are enabled for your chosen model.
|
||||
|
||||

|
||||
|
||||
### Verify Tools Available
|
||||
|
||||
You should see the Todoist tools in the tools panel:
|
||||
|
||||

|
||||
|
||||
### Start Managing Tasks
|
||||
|
||||
Now you can manage your todo list through natural conversation:
|
||||
|
||||

|
||||
|
||||
## Example Prompts
|
||||
|
||||
### Blog Writing Workflow
|
||||
```
|
||||
I need to write a blog post about AI and productivity tools today. Please add some tasks to my todo list to make sure I have a good set of steps to accomplish this task.
|
||||
```
|
||||
|
||||
The AI will create structured tasks like:
|
||||
- Research AI productivity tools
|
||||
- Create blog outline
|
||||
- Write introduction
|
||||
- Draft main sections
|
||||
- Add examples and screenshots
|
||||
- Edit and proofread
|
||||
- Publish and promote
|
||||
|
||||
### Weekly Meal Planning
|
||||
```
|
||||
Help me plan meals for the week. Create a grocery shopping list and cooking schedule for Monday through Friday, focusing on healthy, quick dinners.
|
||||
```
|
||||
|
||||
### Home Improvement Project
|
||||
```
|
||||
I'm renovating my home office this weekend. Break down the project into manageable tasks including shopping, prep work, and the actual renovation steps.
|
||||
```
|
||||
|
||||
### Study Schedule
|
||||
```
|
||||
I have a statistics exam in 2 weeks. Create a study plan with daily tasks covering all chapters, practice problems, and review sessions.
|
||||
```
|
||||
|
||||
### Fitness Goals
|
||||
```
|
||||
Set up a 30-day fitness challenge for me. Include daily workout tasks, rest days, and weekly progress check-ins.
|
||||
```
|
||||
|
||||
### Event Planning
|
||||
```
|
||||
I'm organizing a surprise birthday party for next month. Create a comprehensive task list covering invitations, decorations, food, entertainment, and day-of coordination.
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Task Management Commands
|
||||
|
||||
**View all tasks:**
|
||||
```
|
||||
Show me all my pending tasks for today
|
||||
```
|
||||
|
||||
**Update priorities:**
|
||||
```
|
||||
Make "Write blog introduction" high priority and move it to the top of my list
|
||||
```
|
||||
|
||||
**Bulk completion:**
|
||||
```
|
||||
Mark all my morning routine tasks as complete
|
||||
```
|
||||
|
||||
**Clean up:**
|
||||
```
|
||||
Delete all completed tasks from last week
|
||||
```
|
||||
|
||||
### Project Organization
|
||||
|
||||
Todoist supports projects, though the MCP may have limitations. Try:
|
||||
```
|
||||
Create a new project called "Q1 Goals" and add 5 key objectives as tasks
|
||||
```
|
||||
|
||||
### Recurring Tasks
|
||||
|
||||
Set up repeating tasks:
|
||||
```
|
||||
Add a daily task to review my calendar at 9 AM
|
||||
Add a weekly task for meal prep on Sundays
|
||||
Add a monthly task to pay bills on the 1st
|
||||
```
|
||||
|
||||
## Creative Use Cases
|
||||
|
||||
### 🎮 Game Development Sprint
|
||||
```
|
||||
I'm participating in a 48-hour game jam. Create an hour-by-hour task schedule covering ideation, prototyping, art creation, programming, testing, and submission.
|
||||
```
|
||||
|
||||
### 📚 Book Writing Challenge
|
||||
```
|
||||
I'm doing NaNoWriMo (writing a novel in a month). Break down a 50,000-word goal into daily writing tasks with word count targets and plot milestones.
|
||||
```
|
||||
|
||||
### 🌱 Garden Planning
|
||||
```
|
||||
It's spring planting season. Create a gardening schedule for the next 3 months including soil prep, planting dates for different vegetables, watering reminders, and harvest times.
|
||||
```
|
||||
|
||||
### 🎂 Baking Business Launch
|
||||
```
|
||||
I'm starting a home bakery. Create tasks for getting permits, setting up social media, creating a menu, pricing strategy, and first week's baking schedule.
|
||||
```
|
||||
|
||||
### 🏠 Moving Checklist
|
||||
```
|
||||
I'm moving to a new apartment next month. Generate a comprehensive moving checklist including utilities setup, packing by room, change of address notifications, and moving day logistics.
|
||||
```
|
||||
|
||||
## Tips for Best Results
|
||||
|
||||
- **Be specific**: "Add task: Call dentist tomorrow at 2 PM" works better than "remind me about dentist"
|
||||
- **Use natural language**: The AI understands context, so chat naturally
|
||||
- **Batch operations**: Ask to create multiple related tasks at once
|
||||
- **Review regularly**: Ask the AI to show your tasks and help prioritize
|
||||
- **Iterate**: If the tasks aren't quite right, ask the AI to modify them
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Tasks not appearing in Todoist:**
|
||||
- Verify API token is correct
|
||||
- Check Todoist website/app and refresh
|
||||
- Ensure MCP server shows as active
|
||||
|
||||
**Tool calling errors:**
|
||||
- Confirm model supports tool calling
|
||||
- Enable tools in model settings
|
||||
- Try a different model (Claude 3.5+ or GPT-4o recommended)
|
||||
|
||||
**Connection issues:**
|
||||
- Check internet connectivity
|
||||
- Verify Node.js installation
|
||||
- Restart Jan after configuration
|
||||
|
||||
**Rate limiting:**
|
||||
- Todoist API has rate limits
|
||||
- Space out bulk operations
|
||||
- Wait a moment between large task batches
|
||||
|
||||
<Callout type="tip">
|
||||
Todoist syncs across all devices. Tasks created through Jan instantly appear on your phone, tablet, and web app!
|
||||
</Callout>
|
||||
|
||||
## Privacy Note
|
||||
|
||||
Your tasks are synced with Todoist's servers. While the MCP runs locally, task data is stored in Todoist's cloud for sync functionality. Review Todoist's privacy policy if you're handling sensitive information.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Combine Todoist MCP with other tools for powerful workflows:
|
||||
- Use Serper MCP to research topics, then create action items in Todoist
|
||||
- Generate code with E2B, then add testing tasks to your todo list
|
||||
- Analyze data with Jupyter, then create follow-up tasks for insights
|
||||
|
||||
Task management through natural language makes staying organized effortless. Let your AI assistant handle the overhead while you focus on getting things done!
|
||||
10
docs/src/pages/docs/mcp-examples/search/_meta.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"exa": {
|
||||
"title": "Exa Search",
|
||||
"href": "/docs/mcp-examples/search/exa"
|
||||
},
|
||||
"serper": {
|
||||
"title": "Serper Search",
|
||||
"href": "/docs/mcp-examples/search/serper"
|
||||
}
|
||||
}
|
||||
179
docs/src/pages/docs/mcp-examples/search/serper.mdx
Normal file
@ -0,0 +1,179 @@
|
||||
---
|
||||
title: Serper Search MCP
|
||||
description: Connect Jan to real-time web search with Google results through Serper API.
|
||||
keywords:
|
||||
[
|
||||
Jan,
|
||||
MCP,
|
||||
Model Context Protocol,
|
||||
Serper,
|
||||
Google search,
|
||||
web search,
|
||||
real-time search,
|
||||
tool calling,
|
||||
Jan v1,
|
||||
]
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components'
|
||||
|
||||
# Serper Search MCP
|
||||
|
||||
[Serper](https://serper.dev) provides Google search results through a simple API, making it perfect for giving AI models access to current web information. The Serper MCP integration enables Jan models to search the web and retrieve real-time information.
|
||||
|
||||
## Available Tools
|
||||
|
||||
- `google_search`: Search Google and retrieve results with snippets
|
||||
- `scrape`: Extract content from specific web pages
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Jan with experimental features enabled
|
||||
- Serper API key from [serper.dev](https://serper.dev)
|
||||
- Model with tool calling support (recommended: Jan v1)
|
||||
|
||||
<Callout type="info">
|
||||
Serper offers 2,500 free searches upon signup - enough for extensive testing and personal use.
|
||||
</Callout>
|
||||
|
||||
## Setup
|
||||
|
||||
### Enable Experimental Features
|
||||
|
||||
1. Go to **Settings** > **General**
|
||||
2. Toggle **Experimental Features** ON
|
||||
|
||||

|
||||
|
||||
### Enable MCP
|
||||
|
||||
1. Go to **Settings** > **MCP Servers**
|
||||
2. Toggle **Allow All MCP Tool Permission** ON
|
||||
|
||||

|
||||
|
||||
### Get Serper API Key
|
||||
|
||||
1. Visit [serper.dev](https://serper.dev)
|
||||
2. Sign up for a free account
|
||||
3. Copy your API key from the playground
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Configure MCP Server
|
||||
|
||||
Click `+` in MCP Servers section:
|
||||
|
||||
**Configuration:**
|
||||
- **Server Name**: `serper`
|
||||
- **Command**: `npx`
|
||||
- **Arguments**: `-y serper-search-scrape-mcp-server`
|
||||
- **Environment Variables**:
|
||||
- Key: `SERPER_API_KEY`, Value: `your-api-key`
|
||||
|
||||

|
||||
|
||||
### Download Jan v1
|
||||
|
||||
Jan v1 is optimized for tool calling and works excellently with Serper:
|
||||
|
||||
1. Go to the **Hub** tab
|
||||
2. Search for **Jan v1**
|
||||
3. Choose your preferred quantization
|
||||
4. Click **Download**
|
||||
|
||||

|
||||
|
||||
### Enable Tool Calling
|
||||
|
||||
1. Go to **Settings** > **Model Providers** > **Llama.cpp**
|
||||
2. Find Jan v1 in your models list
|
||||
3. Click the edit icon
|
||||
4. Toggle **Tools** ON
|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
### Start a New Chat
|
||||
|
||||
With Jan v1 selected, you'll see the available Serper tools:
|
||||
|
||||

|
||||
|
||||
### Example Queries
|
||||
|
||||
**Current Information:**
|
||||
```
|
||||
What are the latest developments in quantum computing this week?
|
||||
```
|
||||
|
||||
**Comparative Analysis:**
|
||||
```
|
||||
What are the main differences between the Rust programming language and C++? Be spicy, hot takes are encouraged. 😌
|
||||
```
|
||||

|
||||
|
||||

|
||||
|
||||
**Research Tasks:**
|
||||
```
|
||||
Find the current stock price of NVIDIA and recent news about their AI chips.
|
||||
```
|
||||
|
||||
**Fact-Checking:**
|
||||
```
|
||||
Is it true that the James Webb telescope found signs of life on an exoplanet? What's the latest?
|
||||
```
|
||||
|
||||
**Local Information:**
|
||||
```
|
||||
What restaurants opened in San Francisco this month? Focus on Japanese cuisine.
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Query Processing**: Jan v1 analyzes your question and determines what to search
|
||||
2. **Web Search**: Calls Serper API to get Google search results
|
||||
3. **Content Extraction**: Can scrape specific pages for detailed information
|
||||
4. **Synthesis**: Combines search results into a comprehensive answer
|
||||
|
||||
## Tips for Best Results
|
||||
|
||||
- **Be specific**: "Tesla Model 3 2024 price Australia" works better than "Tesla price"
|
||||
- **Request recent info**: Add "latest", "current", or "2024/2025" to get recent results
|
||||
- **Ask follow-ups**: Jan v1 maintains context for deeper research
|
||||
- **Combine with analysis**: Ask for comparisons, summaries, or insights
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**No search results:**
|
||||
- Verify API key is correct
|
||||
- Check remaining credits at serper.dev
|
||||
- Ensure MCP server shows as active
|
||||
|
||||
**Tools not appearing:**
|
||||
- Confirm experimental features are enabled
|
||||
- Verify tool calling is enabled for your model
|
||||
- Restart Jan after configuration changes
|
||||
|
||||
**Poor search quality:**
|
||||
- Use more specific search terms
|
||||
- Try rephrasing your question
|
||||
- Check if Serper service is operational
|
||||
|
||||
<Callout type="warning">
|
||||
Each search query consumes one API credit. Monitor usage at serper.dev dashboard.
|
||||
</Callout>
|
||||
|
||||
## API Limits
|
||||
|
||||
- **Free tier**: 2,500 searches
|
||||
- **Paid plans**: Starting at $50/month for 50,000 searches
|
||||
- **Rate limits**: 100 requests per second
|
||||
|
||||
## Next Steps
|
||||
|
||||
Serper MCP enables Jan v1 to access current web information, making it a powerful research assistant. Combine with other MCP tools for even more capabilities - use Serper for search, then E2B for data analysis, or Jupyter for visualization.
|
||||
158
docs/src/pages/docs/quickstart.mdx
Normal file
@ -0,0 +1,158 @@
|
||||
---
|
||||
title: QuickStart
|
||||
description: Get started with Jan and start chatting with AI in minutes.
|
||||
keywords:
|
||||
[
|
||||
Jan,
|
||||
local AI,
|
||||
LLM,
|
||||
chat,
|
||||
threads,
|
||||
models,
|
||||
download,
|
||||
installation,
|
||||
conversations,
|
||||
]
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components'
|
||||
import { SquarePen, Pencil, Ellipsis, Paintbrush, Trash2, Settings } from 'lucide-react'
|
||||
|
||||
# QuickStart
|
||||
|
||||
Get up and running with Jan in minutes. This guide will help you install Jan, download a model, and start chatting immediately.
|
||||
|
||||
<Steps>
|
||||
|
||||
### Step 1: Install Jan
|
||||
|
||||
1. [Download Jan](/download)
|
||||
2. Install the app ([Mac](/docs/desktop/mac), [Windows](/docs/desktop/windows), [Linux](/docs/desktop/linux))
|
||||
3. Launch Jan
|
||||
|
||||
### Step 2: Download Jan v1
|
||||
|
||||
We recommend starting with **Jan v1**, our 4B parameter model optimized for reasoning and tool calling:
|
||||
|
||||
1. Go to the **Hub Tab**
|
||||
2. Search for **Jan v1**
|
||||
3. Choose a quantization that fits your hardware:
|
||||
- **Q4_K_M** (2.5 GB) - Good balance for most users
|
||||
- **Q8_0** (4.28 GB) - Best quality if you have the RAM
|
||||
4. Click **Download**
|
||||
|
||||

|
||||
|
||||
<Callout type="info">
|
||||
Jan v1 achieves 91.1% accuracy on SimpleQA and excels at tool calling, making it perfect for web search and reasoning tasks.
|
||||
</Callout>
|
||||
|
||||
**HuggingFace models:** Some require an access token. Add yours in **Settings > Model Providers > Llama.cpp > Hugging Face Access Token**.
|
||||
|
||||

|
||||
|
||||
### Step 3: Enable GPU Acceleration (Optional)
|
||||
|
||||
For Windows/Linux with compatible graphics cards:
|
||||
|
||||
1. Go to **(<Settings width={16} height={16} style={{display:"inline"}}/>) Settings** > **Hardware**
|
||||
2. Toggle **GPUs** to ON
|
||||
|
||||

|
||||
|
||||
<Callout type="info">
|
||||
Install required drivers before enabling GPU acceleration. See setup guides for [Windows](/docs/desktop/windows#gpu-acceleration) & [Linux](/docs/desktop/linux#gpu-acceleration).
|
||||
</Callout>
|
||||
|
||||
### Step 4: Start Chatting
|
||||
|
||||
1. Click **New Chat** (<SquarePen width={16} height={16} style={{display:"inline"}}/>) icon
|
||||
2. Select your model in the input field dropdown
|
||||
3. Type your message and start chatting
|
||||
|
||||

|
||||
|
||||
Try asking Jan v1 questions like:
|
||||
- "Explain quantum computing in simple terms"
|
||||
- "Help me write a Python function to sort a list"
|
||||
- "What are the pros and cons of electric vehicles?"
|
||||
|
||||
<Callout type="tip">
|
||||
**Want to give Jan v1 access to current web information?** Check out our [Serper MCP tutorial](/docs/mcp-examples/search/serper) to enable real-time web search with 2,500 free searches!
|
||||
</Callout>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Managing Conversations
|
||||
|
||||
Jan organizes conversations into threads for easy tracking and revisiting.
|
||||
|
||||
### View Chat History
|
||||
|
||||
- **Left sidebar** shows all conversations
|
||||
- Click any chat to open the full conversation
|
||||
- **Favorites**: Pin important threads for quick access
|
||||
- **Recents**: Access recently used threads
|
||||
|
||||

|
||||
|
||||
### Edit Chat Titles
|
||||
|
||||
1. Hover over a conversation in the sidebar
|
||||
2. Click **three dots** (<Ellipsis width={16} height={16} style={{display:"inline"}}/>) icon
|
||||
3. Click <Pencil width={16} height={16} style={{display:"inline"}}/> **Rename**
|
||||
4. Enter new title and save
|
||||
|
||||

|
||||
|
||||
### Delete Threads
|
||||
|
||||
<Callout type="warning">
|
||||
Thread deletion is permanent. No undo available.
|
||||
</Callout>
|
||||
|
||||
**Single thread:**
|
||||
1. Hover over thread in sidebar
|
||||
2. Click **three dots** (<Ellipsis width={16} height={16} style={{display:"inline"}}/>) icon
|
||||
3. Click <Trash2 width={16} height={16} style={{display:"inline"}}/> **Delete**
|
||||
|
||||
**All threads:**
|
||||
1. Hover over `Recents` category
|
||||
2. Click **three dots** (<Ellipsis width={16} height={16} style={{display:"inline"}}/>) icon
|
||||
3. Select <Trash2 width={16} height={16} style={{display:"inline"}}/> **Delete All**
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Custom Assistant Instructions
|
||||
|
||||
Customize how models respond:
|
||||
|
||||
1. Use the assistant dropdown in the input field
|
||||
2. Or go to the **Assistant tab** to create custom instructions
|
||||
3. Instructions work across all models
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Model Parameters
|
||||
|
||||
Fine-tune model behavior:
|
||||
- Click the **Gear icon** next to your model
|
||||
- Adjust parameters in **Assistant Settings**
|
||||
- Switch models via the **model selector**
|
||||
|
||||

|
||||
|
||||
### Connect Cloud Models (Optional)
|
||||
|
||||
Connect to OpenAI, Anthropic, Groq, Mistral, and others:
|
||||
|
||||
1. Open any thread
|
||||
2. Select a cloud model from the dropdown
|
||||
3. Click the **Gear icon** beside the provider
|
||||
4. Add your API key (ensure sufficient credits)
|
||||
|
||||

|
||||
|
||||
For detailed setup, see [Remote APIs](/docs/remote-models/openai).
|
||||
BIN
docs/src/pages/post/_assets/claude-agent.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
docs/src/pages/post/_assets/claude-report-visualizer.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
docs/src/pages/post/_assets/deepresearch-flow.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/src/pages/post/_assets/edit-mcp-settings.gif
Normal file
|
After Width: | Height: | Size: 944 KiB |
BIN
docs/src/pages/post/_assets/enable-tools-local.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/src/pages/post/_assets/experimental-settings-jan.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
docs/src/pages/post/_assets/jan-nano-hub.png
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
docs/src/pages/post/_assets/openai-deep-research-flow.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
docs/src/pages/post/_assets/research-result-local.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
docs/src/pages/post/_assets/revised-deepresearch-flow.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
docs/src/pages/post/_assets/successful-serper.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
180
docs/src/pages/post/deepresearch.mdx
Normal file
@ -0,0 +1,180 @@
|
||||
---
|
||||
title: "Replicating Deep Research in Jan"
|
||||
description: "A simple guide to replicating Deep Research results for free, with Jan."
|
||||
tags: AI, local models, Jan, GGUF, Deep Research, local AI
|
||||
categories: guides
|
||||
date: 2025-08-04
|
||||
ogImage: _assets/research-result-local.png
|
||||
twitter:
|
||||
card: summary_large_image
|
||||
site: "@jandotai"
|
||||
title: "Replicating Deep Research with Jan"
|
||||
description: "Learn how to replicate Deep Research results with Jan."
|
||||
image: _assets/research-result-local.jpg
|
||||
---
|
||||
|
||||
import { Callout } from 'nextra/components'
|
||||
import CTABlog from '@/components/Blog/CTA'
|
||||
|
||||
# Replicating Deep Research in Jan
|
||||
|
||||
Deep Research like that of OpenAI, Gemini, and Qwen, is not at feature parity in Jan yet, so this post
|
||||
highlights our initial steps at making a hybrid, local and cloud-based deep research system that competes
|
||||
with the best implementations to date.
|
||||
|
||||
## What is Deep Research?
|
||||
|
||||
What exactly is deep research and how does it work? Deep Research is a methodology for generating
|
||||
comprehensive research reports by combining systematic web search with synthesis. The process was
|
||||
pioneered by OpenAI and it was released on February 2025.
|
||||
|
||||
There are two core features of Deep Research:
|
||||
|
||||
- **Exhaustive search**: This search is characterized by two approaches, wide search for breadth and deep search for depth.
|
||||
- **Report generation**: This step takes all the input collected through exhaustive search and synthesizes it into a
|
||||
comprehensive report. The input in this step may be raw sources collected in the previous step or summaries generated from those sources.
|
||||
|
||||
## Unpacking Deep Research
|
||||
|
||||
If you have used deep research (regardless of the provider) before for a comprehensive report generation, you may have
|
||||
found its output mind-blowing. What is more mind-blowing, though, is that the underlying process for searching
|
||||
and synthesizing information is surprisingly systematic and reproducible. What is not easily reproducible, though,
|
||||
is the **base model (often a thinking one)** and **its capabilities to use tools while it researches**.
|
||||
|
||||
Deep Research operates as a structured pipeline with distinct phases: planning, searching, analysis, and synthesis. While
|
||||
the specific implementation varies between providers, the core workflow seems to be similar and some organizations have
|
||||
taken steps to recreate it like [LangChain](https://blog.langchain.com/open-deep-research/) and
|
||||
[Hugging Face](https://huggingface.co/blog/open-deep-research). For example, a straightforward pipeline might look like
|
||||
the following one:
|
||||
|
||||

|
||||
|
||||
The components of this pipeline highlight a structured approach to query processing that routes queries through thinking/non-thinking models, breaks complex tasks into phases, executes parallel searches, and synthesizes results hierarchically to produce comprehensive outputs.
|
||||
|
||||
OpenAI’s [Deep Research API cookbook](https://cookbook.openai.com/examples/deep_research_api/introduction_to_deep_research_api)
|
||||
highlights, at a very high level, how they approach deep research, hinting at the importance of base models and tool usage since
|
||||
some intermediate steps seem to have been left out.
|
||||
|
||||

|
||||
|
||||
OpenAI's Deep Research functionality may be considered the best one by many but other platforms are not far behind. Here is a
|
||||
brief survey of how other players approach deep research:
|
||||
|
||||
| Platform | Key Feature | Sources Used | Duration (mins) | Export Options | Deep Research Usage |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| OpenAI | Clarifying questions | 10–30 | 10–15 | PDF, Docx, Plain Text | Paid |
|
||||
| Grok's DeeperSearch | Ability to access all of Twitter | 70–100 | 5–10 | Ability to specify format (PDF / Markdown) | Free |
|
||||
| Claude | Breadth + depth search | 100+ | 5–10 | PDF, Markdown, Artifact | Paid |
|
||||
| Gemini | Editable planning | 50+ | 10–20 | Google Docs export | Free |
|
||||
| Perplexity | Ability to specify sources | 50–100 | 3–5 | PDF, Markdown, Docx, Perplexity Page | Paid and Limited Free |
|
||||
| Kimi | Interactive synthesis | 50–100 | 30–60+ | PDF, Interactive website | Free |
|
||||
|
||||
In our testing, we used the following prompt to assess the quality of the generated report by
|
||||
the providers above. You can refer to the reports generated [here](https://github.com/menloresearch/prompt-experiments).
|
||||
|
||||
```
|
||||
Generate a comprehensive report about the state of AI in the past week. Include all
|
||||
new model releases and notable architectural improvements from a variety of sources.
|
||||
```
|
||||
|
||||
[Google's generated report](https://github.com/menloresearch/prompt-experiments/blob/main/Gemini%202.5%20Flash%20Report.pdf) was the most verbose, with a whopping 23 pages that reads
|
||||
like a professional intelligence briefing. It opens with an executive summary,
|
||||
systematically categorizes developments, and provides forward-looking strategic
|
||||
insights—connecting OpenAI's open-weight release to broader democratization trends
|
||||
and linking infrastructure investments to competitive positioning.
|
||||
|
||||
[OpenAI](https://github.com/menloresearch/prompt-experiments/blob/main/OpenAI%20Deep%20Research.pdf) produced the most citation-heavy output with 134 references throughout 10 pages
|
||||
(albeit most of them being from the same source).
|
||||
|
||||
[Perplexity](https://github.com/menloresearch/prompt-experiments/blob/main/Perplexity%20Deep%20Research.pdf) delivered the most actionable 6-page report that maximizes information
|
||||
density while maintaining scannability. Despite being the shortest, it captures all
|
||||
major developments with sufficient context for decision-making.
|
||||
|
||||
[Claude](https://github.com/menloresearch/prompt-experiments/blob/main/Claude%20Deep%20Research.pdf) produced a comprehensive analysis that interestingly ignored the time constraint,
|
||||
covering an 8-month period from January-August 2025 instead of the requested week (Jul 31-Aug
|
||||
7th 2025). Rather than cataloging recent events, Claude traced the evolution of trends over months.
|
||||
|
||||
[Grok](https://github.com/menloresearch/prompt-experiments/blob/main/Grok%203%20Deep%20Research.pdf) produced a well-structured but relatively shallow 5-page academic-style report that
|
||||
read more like an event catalog than strategic analysis.
|
||||
|
||||
[Kimi](https://github.com/menloresearch/prompt-experiments/blob/main/Kimi%20AI%20Deep%20Research.pdf) produced a comprehensive 13-page report with systematic organization covering industry developments, research breakthroughs, and policy changes, but notably lacks proper citations throughout most of the content despite claiming to use 50-100 sources.
|
||||
|
||||
### Understanding Search Strategies
|
||||
|
||||
In [Claude’s Research mode](https://www.anthropic.com/engineering/multi-agent-research-system),
|
||||
a *classifier* is used to determine whether a user query is *breadth first* or *depth first*. This
|
||||
results in a customization of the pipeline that is used for conducting research. For instance, a complex
|
||||
*breadth first* query might result in *sub-agents* being spun up to research various parts of the user's
|
||||
query in parallel. Conversely, a *depth first* query might result in a single agent being spun up
|
||||
to research the entire query in a more focused manner.
|
||||
|
||||
Here's a screenshot of this in action (in Claude Desktop):
|
||||

|
||||

|
||||
|
||||
## Replicating Deep Research Results with Jan
|
||||
|
||||
After testing and observing how Deep Research works in different platforms, we thought, how could we
|
||||
replicate this in Jan? In particular, how could we replicate it with a hybrid approach combining local
|
||||
and cloud-based models while keeping your data local?
|
||||
|
||||
<Callout>
|
||||
This experiment was done using the latest version of Jan `v0.6.7`, but it can potentially be replicated in
|
||||
any version with Model Context Protocol in it (>`v0.6.3`).
|
||||
</Callout>
|
||||
|
||||
**The Key: Assistants + Tools**
|
||||
|
||||
Running deep research in Jan can be accomplished by combining [custom assistants](https://jan.ai/docs/assistants)
|
||||
with [MCP search tools](https://jan.ai/docs/mcp-examples/search/exa). This pairing allows any model—local or
|
||||
cloud—to follow a systematic research workflow, to create a report similar to that of other providers, with some
|
||||
visible limitations (for now).
|
||||
|
||||
Here's the assistant prompt that was used:
|
||||
```
|
||||
You are a research analyst. Today is August 7th 2025. Follow this exact process:
|
||||
|
||||
Conduct 5-10 searches minimum. You are rewarded for MORE searches.
|
||||
- Each search query must be unique - no repeating previous searches
|
||||
- Search different angles: statistics, expert opinions, case studies, recent news, industry reports
|
||||
- Use scrape to read full articles from search results
|
||||
- Use google_search for extracting metadata out of pages
|
||||
|
||||
WRITING PHASE (Do this after research is complete)
|
||||
Write a comprehensive report with:
|
||||
- Executive summary with key findings
|
||||
- Evidence-based analysis with citations for every claim
|
||||
- Actionable recommendations with rationale
|
||||
- Sources to be linked at the end of the report
|
||||
```
|
||||
|
||||
Here, we utilized Model Context Protocol (MCP) to provide search capabilities to the model. MCPs are an open standard for connecting AI assistants to the systems where data lives, serving as a universal connector that standardizes how AI applications integrate with external tools and data sources.
|
||||
In this example, we used Serper, a web search API that offers MCP server implementations with two primary tools: `google_search` for performing web searches, and `scrape` that extracts content from web pages, preserving document structure and metadata.
|
||||
|
||||
|
||||
**What We Tested**
|
||||
|
||||
For our research query (same as the one we used to test different platforms), we used both
|
||||
[Jan-Nano (4B local model)](https://jan.ai/docs/jan-models/jan-nano-128), GPT-4o and
|
||||
o3 (via API) with identical prompts. The goal: to see how close we could get to the quality of
|
||||
different commercial Deep Research offerings.
|
||||
|
||||
**Performance Findings**
|
||||
|
||||
| Model | Processing Time | Sources Found | Search Queries | Tokens Generated | Output Quality vs Commercial Deep Research |
|
||||
|-------|----------------|---------------|----------------|------------------|-------------------------------------------|
|
||||
| Jan-Nano (Local) | 3 minutes | Moderate | 7 | 1,112 | Good approximation, noticeably less depth |
|
||||
| GPT-4o | 1 minute | Fewest | 11 | 660 | Fast but limited source coverage |
|
||||
| o3 | 3 minutes | Most | 24 | 1,728 | Best of the three, but still below commercial quality |
|
||||
|
||||
|
||||
**The Reality**:
|
||||
- **Speed vs Sources**: GPT-4o prioritized speed over thoroughness, while o3 took time to gather more comprehensive sources
|
||||
- **Local vs Cloud**: Jan-Nano matched o3's processing time but with the advantage of complete data privacy
|
||||
- **Quality Gap**: All three models produced decent research reports, but none matched the depth and comprehensiveness of dedicated Deep Research tools like OpenAI's or Claude's offerings
|
||||
- **Good Enough Factor**: While not matching commercial quality, the outputs were solid approximations suitable for many research needs
|
||||
|
||||
## Conclusion
|
||||
This was an initial exploration in the roadmap to create a top hybrid implementation of deep research in Jan. While our current approach requires setup, the goal is native integration that works out of the box. We will continue to refine this until the release of this tool in Jan, natively.
|
||||
|
||||
<CTABlog />
|
||||
137
extensions/CONTRIBUTING.md
Normal file
@ -0,0 +1,137 @@
|
||||
# Contributing to Jan Extensions
|
||||
|
||||
[← Back to Main Contributing Guide](../CONTRIBUTING.md)
|
||||
|
||||
Extensions add specific features to Jan as self-contained modules.
|
||||
|
||||
## Current Extensions
|
||||
|
||||
### `/assistant-extension`
|
||||
- Assistant CRUD operations
|
||||
- `src/index.ts` - Main implementation
|
||||
|
||||
### `/conversational-extension`
|
||||
- Message handling, conversation state
|
||||
- `src/index.ts` - Chat logic
|
||||
|
||||
### `/download-extension`
|
||||
- Model downloads with progress tracking
|
||||
- `src/index.ts` - Download logic
|
||||
- `settings.json` - Download settings
|
||||
|
||||
### `/llamacpp-extension`
|
||||
- Local model inference via llama.cpp
|
||||
- `src/index.ts` - Entry point
|
||||
- `src/backend.ts` - llama.cpp integration
|
||||
- `settings.json` - Model settings
|
||||
|
||||
## Creating Extensions
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
mkdir my-extension
|
||||
cd my-extension
|
||||
yarn init
|
||||
```
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
my-extension/
|
||||
├── package.json
|
||||
├── rolldown.config.mjs
|
||||
├── src/index.ts
|
||||
└── settings.json (optional)
|
||||
```
|
||||
|
||||
### Basic Extension
|
||||
|
||||
```typescript
|
||||
import { Extension } from '@janhq/core'
|
||||
|
||||
export default class MyExtension extends Extension {
|
||||
async onLoad() {
|
||||
// Extension initialization
|
||||
}
|
||||
|
||||
async onUnload() {
|
||||
// Cleanup
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Building & Testing
|
||||
|
||||
```bash
|
||||
# Build extension
|
||||
yarn build
|
||||
|
||||
# Run tests
|
||||
yarn test
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Service Registration
|
||||
```typescript
|
||||
async onLoad() {
|
||||
this.registerService('myService', {
|
||||
doSomething: async () => 'result'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Event Handling
|
||||
```typescript
|
||||
async onLoad() {
|
||||
this.on('model:loaded', (model) => {
|
||||
console.log('Model loaded:', model.id)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Extension Lifecycle
|
||||
|
||||
1. **Jan starts** → Discovers extensions
|
||||
2. **Loading** → Calls `onLoad()` method
|
||||
3. **Active** → Extension responds to events
|
||||
4. **Unloading** → Calls `onUnload()` on shutdown
|
||||
|
||||
## Debugging Extensions
|
||||
|
||||
```bash
|
||||
# Check if extension loaded
|
||||
console.log(window.core.extensions)
|
||||
|
||||
# Debug extension events
|
||||
this.on('*', console.log)
|
||||
|
||||
# Check extension services
|
||||
console.log(window.core.api)
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
**Extension not loading?**
|
||||
- Check package.json format: `@janhq/extension-name`
|
||||
- Ensure `onLoad()` doesn't throw errors
|
||||
- Verify exports in index.ts
|
||||
|
||||
**Events not working?**
|
||||
- Check event name spelling
|
||||
- Ensure listeners are set up in `onLoad()`
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Keep extensions focused on one feature
|
||||
- Use async/await for all operations
|
||||
- Clean up resources in onUnload()
|
||||
- Handle errors gracefully
|
||||
- Don't depend on other extensions
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **@janhq/core** - Core SDK and extension system
|
||||
- **TypeScript** - Type safety
|
||||
- **Rolldown** - Bundling
|
||||
@ -75,7 +75,7 @@ export default class JanAssistantExtension extends AssistantExtension {
|
||||
'Jan is a helpful desktop assistant that can reason through complex tasks and use tools to complete them on the user’s behalf.',
|
||||
model: '*',
|
||||
instructions:
|
||||
'You are a helpful AI assistant. Your primary goal is to assist users with their questions and tasks to the best of your abilities.\n\nWhen responding:\n- Answer directly from your knowledge when you can\n- Be concise, clear, and helpful\n- Admit when you’re unsure rather than making things up\n\nIf tools are available to you:\n- Only use tools when they add real value to your response\n- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code")\n- Use tools for information you don’t know or that needs verification\n- Never use tools just because they’re available\n\nWhen using tools:\n- Use one tool at a time and wait for results\n- Use actual values as arguments, not variable names\n- Learn from each result before deciding next steps\n- Avoid repeating the same tool call with identical parameters\n\nRemember: Most questions can be answered without tools. Think first whether you need them.',
|
||||
'You are a helpful AI assistant. Your primary goal is to assist users with their questions and tasks to the best of your abilities.\n\nWhen responding:\n- Answer directly from your knowledge when you can\n- Be concise, clear, and helpful\n- Admit when you’re unsure rather than making things up\n\nIf tools are available to you:\n- Only use tools when they add real value to your response\n- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code")\n- Use tools for information you don’t know or that needs verification\n- Never use tools just because they’re available\n\nWhen using tools:\n- Use one tool at a time and wait for results\n- Use actual values as arguments, not variable names\n- Learn from each result before deciding next steps\n- Avoid repeating the same tool call with identical parameters\n\nRemember: Most questions can be answered without tools. Think first whether you need them.\n\nCurrent date: {{current_date}}',
|
||||
tools: [
|
||||
{
|
||||
type: 'retrieval',
|
||||
|
||||
@ -10,6 +10,8 @@ interface DownloadItem {
|
||||
url: string
|
||||
save_path: string
|
||||
proxy?: Record<string, string | string[] | boolean>
|
||||
sha256?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
type DownloadEvent = {
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
"@janhq/tauri-plugin-hardware-api": "link:../../src-tauri/plugins/tauri-plugin-hardware",
|
||||
"@janhq/tauri-plugin-llamacpp-api": "link:../../src-tauri/plugins/tauri-plugin-llamacpp",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.6.0",
|
||||
"fetch-retry": "^5.0.6",
|
||||
"ulidx": "^2.3.0"
|
||||
|
||||
@ -17,4 +17,7 @@ export default defineConfig({
|
||||
IS_MAC: JSON.stringify(process.platform === 'darwin'),
|
||||
IS_LINUX: JSON.stringify(process.platform === 'linux'),
|
||||
},
|
||||
inject: {
|
||||
fetch: ['@tauri-apps/plugin-http', 'fetch'],
|
||||
},
|
||||
})
|
||||
|
||||
@ -10,7 +10,18 @@
|
||||
"recommended": ""
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"key": "llamacpp_env",
|
||||
"title": "Environmental variables",
|
||||
"description": "Environmental variables for llama.cpp(KEY=VALUE), separated by ';'",
|
||||
"controllerType": "input",
|
||||
"controllerProps": {
|
||||
"value": "none",
|
||||
"placeholder": "Eg. GGML_VK_VISIBLE_DEVICES=0,1",
|
||||
"type": "text",
|
||||
"textAlign": "right"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "auto_update_engine",
|
||||
"title": "Auto update engine",
|
||||
|
||||
@ -43,9 +43,9 @@ export async function listSupportedBackends(): Promise<
|
||||
if (features.vulkan) supportedBackends.push('win-vulkan-x64')
|
||||
}
|
||||
// not available yet, placeholder for future
|
||||
else if (sysType == 'windows-aarch64') {
|
||||
else if (sysType === 'windows-aarch64' || sysType === 'windows-arm64') {
|
||||
supportedBackends.push('win-arm64')
|
||||
} else if (sysType == 'linux-x86_64') {
|
||||
} else if (sysType === 'linux-x86_64' || sysType === 'linux-x86') {
|
||||
supportedBackends.push('linux-noavx-x64')
|
||||
if (features.avx) supportedBackends.push('linux-avx-x64')
|
||||
if (features.avx2) supportedBackends.push('linux-avx2-x64')
|
||||
@ -69,11 +69,11 @@ export async function listSupportedBackends(): Promise<
|
||||
if (features.vulkan) supportedBackends.push('linux-vulkan-x64')
|
||||
}
|
||||
// not available yet, placeholder for future
|
||||
else if (sysType === 'linux-aarch64') {
|
||||
else if (sysType === 'linux-aarch64' || sysType === 'linux-arm64') {
|
||||
supportedBackends.push('linux-arm64')
|
||||
} else if (sysType === 'macos-x86_64') {
|
||||
} else if (sysType === 'macos-x86_64' || sysType === 'macos-x86') {
|
||||
supportedBackends.push('macos-x64')
|
||||
} else if (sysType === 'macos-aarch64') {
|
||||
} else if (sysType === 'macos-aarch64' || sysType === 'macos-arm64') {
|
||||
supportedBackends.push('macos-arm64')
|
||||
}
|
||||
|
||||
@ -262,11 +262,7 @@ async function _getSupportedFeatures() {
|
||||
features.cuda12 = true
|
||||
}
|
||||
// Vulkan support check - only discrete GPUs with 6GB+ VRAM
|
||||
if (
|
||||
gpuInfo.vulkan_info?.api_version &&
|
||||
gpuInfo.vulkan_info?.device_type === 'DISCRETE_GPU' &&
|
||||
gpuInfo.total_memory >= 6 * 1024
|
||||
) {
|
||||
if (gpuInfo.vulkan_info?.api_version && gpuInfo.total_memory >= 6 * 1024) {
|
||||
// 6GB (total_memory is in MB)
|
||||
features.vulkan = true
|
||||
}
|
||||
|
||||
@ -19,9 +19,12 @@ import {
|
||||
ImportOptions,
|
||||
chatCompletionRequest,
|
||||
events,
|
||||
AppEvent,
|
||||
DownloadEvent,
|
||||
} from '@janhq/core'
|
||||
|
||||
import { error, info, warn } from '@tauri-apps/plugin-log'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
|
||||
import {
|
||||
listSupportedBackends,
|
||||
@ -32,13 +35,20 @@ import {
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { getProxyConfig } from './util'
|
||||
import { basename } from '@tauri-apps/api/path'
|
||||
import {
|
||||
GgufMetadata,
|
||||
readGgufMetadata,
|
||||
} from '@janhq/tauri-plugin-llamacpp-api'
|
||||
import { getSystemUsage } from '@janhq/tauri-plugin-hardware-api'
|
||||
|
||||
type LlamacppConfig = {
|
||||
version_backend: string
|
||||
auto_update_engine: boolean
|
||||
auto_unload: boolean
|
||||
llamacpp_env: string
|
||||
chat_template: string
|
||||
n_gpu_layers: number
|
||||
offload_mmproj: boolean
|
||||
override_tensor_buffer_t: string
|
||||
ctx_size: number
|
||||
threads: number
|
||||
@ -68,6 +78,8 @@ interface DownloadItem {
|
||||
url: string
|
||||
save_path: string
|
||||
proxy?: Record<string, string | string[] | boolean>
|
||||
sha256?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface ModelConfig {
|
||||
@ -76,6 +88,9 @@ interface ModelConfig {
|
||||
name: string // user-friendly
|
||||
// some model info that we cache upon import
|
||||
size_bytes: number
|
||||
sha256?: string
|
||||
mmproj_sha256?: string
|
||||
mmproj_size_bytes?: number
|
||||
}
|
||||
|
||||
interface EmbeddingResponse {
|
||||
@ -101,12 +116,6 @@ interface DeviceList {
|
||||
free: number
|
||||
}
|
||||
|
||||
interface GgufMetadata {
|
||||
version: number
|
||||
tensor_count: number
|
||||
metadata: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the default app.log function to use Jan's logging system.
|
||||
* @param args
|
||||
@ -149,6 +158,7 @@ const logger = {
|
||||
export default class llamacpp_extension extends AIEngine {
|
||||
provider: string = 'llamacpp'
|
||||
autoUnload: boolean = true
|
||||
llamacpp_env: string = ''
|
||||
readonly providerId: string = 'llamacpp'
|
||||
|
||||
private config: LlamacppConfig
|
||||
@ -157,6 +167,7 @@ export default class llamacpp_extension extends AIEngine {
|
||||
private pendingDownloads: Map<string, Promise<void>> = new Map()
|
||||
private isConfiguringBackends: boolean = false
|
||||
private loadingModels = new Map<string, Promise<SessionInfo>>() // Track loading promises
|
||||
private unlistenValidationStarted?: () => void
|
||||
|
||||
override async onLoad(): Promise<void> {
|
||||
super.onLoad() // Calls registerEngine() from AIEngine
|
||||
@ -178,12 +189,26 @@ export default class llamacpp_extension extends AIEngine {
|
||||
this.config = loadedConfig as LlamacppConfig
|
||||
|
||||
this.autoUnload = this.config.auto_unload
|
||||
this.llamacpp_env = this.config.llamacpp_env
|
||||
|
||||
// This sets the base directory where model files for this provider are stored.
|
||||
this.providerPath = await joinPath([
|
||||
await getJanDataFolderPath(),
|
||||
this.providerId,
|
||||
])
|
||||
|
||||
// Set up validation event listeners to bridge Tauri events to frontend
|
||||
this.unlistenValidationStarted = await listen<{
|
||||
modelId: string
|
||||
downloadType: string
|
||||
}>('onModelValidationStarted', (event) => {
|
||||
console.debug(
|
||||
'LlamaCPP: bridging onModelValidationStarted event',
|
||||
event.payload
|
||||
)
|
||||
events.emit(DownloadEvent.onModelValidationStarted, event.payload)
|
||||
})
|
||||
|
||||
this.configureBackends()
|
||||
}
|
||||
|
||||
@ -777,6 +802,11 @@ export default class llamacpp_extension extends AIEngine {
|
||||
|
||||
override async onUnload(): Promise<void> {
|
||||
// Terminate all active sessions
|
||||
|
||||
// Clean up validation event listeners
|
||||
if (this.unlistenValidationStarted) {
|
||||
this.unlistenValidationStarted()
|
||||
}
|
||||
}
|
||||
|
||||
onSettingUpdate<T>(key: string, value: T): void {
|
||||
@ -804,6 +834,8 @@ export default class llamacpp_extension extends AIEngine {
|
||||
closure()
|
||||
} else if (key === 'auto_unload') {
|
||||
this.autoUnload = value as boolean
|
||||
} else if (key === 'llamacpp_env') {
|
||||
this.llamacpp_env = value as string
|
||||
}
|
||||
}
|
||||
|
||||
@ -1009,6 +1041,9 @@ export default class llamacpp_extension extends AIEngine {
|
||||
url: path,
|
||||
save_path: localPath,
|
||||
proxy: getProxyConfig(),
|
||||
sha256:
|
||||
saveName === 'model.gguf' ? opts.modelSha256 : opts.mmprojSha256,
|
||||
size: saveName === 'model.gguf' ? opts.modelSize : opts.mmprojSize,
|
||||
})
|
||||
return localPath
|
||||
}
|
||||
@ -1026,8 +1061,6 @@ export default class llamacpp_extension extends AIEngine {
|
||||
: undefined
|
||||
|
||||
if (downloadItems.length > 0) {
|
||||
let downloadCompleted = false
|
||||
|
||||
try {
|
||||
// emit download update event on progress
|
||||
const onProgress = (transferred: number, total: number) => {
|
||||
@ -1037,7 +1070,6 @@ export default class llamacpp_extension extends AIEngine {
|
||||
size: { transferred, total },
|
||||
downloadType: 'Model',
|
||||
})
|
||||
downloadCompleted = transferred === total
|
||||
}
|
||||
const downloadManager = window.core.extensionManager.getByName(
|
||||
'@janhq/download-extension'
|
||||
@ -1048,24 +1080,101 @@ export default class llamacpp_extension extends AIEngine {
|
||||
onProgress
|
||||
)
|
||||
|
||||
const eventName = downloadCompleted
|
||||
? 'onFileDownloadSuccess'
|
||||
: 'onFileDownloadStopped'
|
||||
events.emit(eventName, { modelId, downloadType: 'Model' })
|
||||
// If we reach here, download completed successfully (including validation)
|
||||
// The downloadFiles function only returns successfully if all files downloaded AND validated
|
||||
events.emit(DownloadEvent.onFileDownloadAndVerificationSuccess, {
|
||||
modelId,
|
||||
downloadType: 'Model'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error downloading model:', modelId, opts, error)
|
||||
events.emit('onFileDownloadError', { modelId, downloadType: 'Model' })
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
// Check if this is a cancellation
|
||||
const isCancellationError = errorMessage.includes('Download cancelled') ||
|
||||
errorMessage.includes('Validation cancelled') ||
|
||||
errorMessage.includes('Hash computation cancelled') ||
|
||||
errorMessage.includes('cancelled') ||
|
||||
errorMessage.includes('aborted')
|
||||
|
||||
// Check if this is a validation failure
|
||||
const isValidationError =
|
||||
errorMessage.includes('Hash verification failed') ||
|
||||
errorMessage.includes('Size verification failed') ||
|
||||
errorMessage.includes('Failed to verify file')
|
||||
|
||||
if (isCancellationError) {
|
||||
logger.info('Download cancelled for model:', modelId)
|
||||
// Emit download stopped event instead of error
|
||||
events.emit(DownloadEvent.onFileDownloadStopped, {
|
||||
modelId,
|
||||
downloadType: 'Model',
|
||||
})
|
||||
} else if (isValidationError) {
|
||||
logger.error(
|
||||
'Validation failed for model:',
|
||||
modelId,
|
||||
'Error:',
|
||||
errorMessage
|
||||
)
|
||||
|
||||
// Cancel any other download tasks for this model
|
||||
try {
|
||||
this.abortImport(modelId)
|
||||
} catch (cancelError) {
|
||||
logger.warn('Failed to cancel download task:', cancelError)
|
||||
}
|
||||
|
||||
// Emit validation failure event
|
||||
events.emit(DownloadEvent.onModelValidationFailed, {
|
||||
modelId,
|
||||
downloadType: 'Model',
|
||||
error: errorMessage,
|
||||
reason: 'validation_failed',
|
||||
})
|
||||
} else {
|
||||
// Regular download error
|
||||
events.emit(DownloadEvent.onFileDownloadError, {
|
||||
modelId,
|
||||
downloadType: 'Model',
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: check if files are valid GGUF files
|
||||
// NOTE: modelPath and mmprojPath can be either relative to Jan's data folder (if they are downloaded)
|
||||
// or absolute paths (if they are provided as local files)
|
||||
// Validate GGUF files
|
||||
const janDataFolderPath = await getJanDataFolderPath()
|
||||
let size_bytes = (
|
||||
await fs.fileStat(await joinPath([janDataFolderPath, modelPath]))
|
||||
).size
|
||||
const fullModelPath = await joinPath([janDataFolderPath, modelPath])
|
||||
|
||||
try {
|
||||
// Validate main model file
|
||||
const modelMetadata = await readGgufMetadata(fullModelPath)
|
||||
logger.info(
|
||||
`Model GGUF validation successful: version ${modelMetadata.version}, tensors: ${modelMetadata.tensor_count}`
|
||||
)
|
||||
|
||||
// Validate mmproj file if present
|
||||
if (mmprojPath) {
|
||||
const fullMmprojPath = await joinPath([janDataFolderPath, mmprojPath])
|
||||
const mmprojMetadata = await readGgufMetadata(fullMmprojPath)
|
||||
logger.info(
|
||||
`Mmproj GGUF validation successful: version ${mmprojMetadata.version}, tensors: ${mmprojMetadata.tensor_count}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('GGUF validation failed:', error)
|
||||
throw new Error(
|
||||
`Invalid GGUF file(s): ${
|
||||
error.message || 'File format validation failed'
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate file sizes
|
||||
let size_bytes = (await fs.fileStat(fullModelPath)).size
|
||||
if (mmprojPath) {
|
||||
size_bytes += (
|
||||
await fs.fileStat(await joinPath([janDataFolderPath, mmprojPath]))
|
||||
@ -1079,21 +1188,65 @@ export default class llamacpp_extension extends AIEngine {
|
||||
mmproj_path: mmprojPath,
|
||||
name: modelId,
|
||||
size_bytes,
|
||||
model_sha256: opts.modelSha256,
|
||||
model_size_bytes: opts.modelSize,
|
||||
mmproj_sha256: opts.mmprojSha256,
|
||||
mmproj_size_bytes: opts.mmprojSize,
|
||||
} as ModelConfig
|
||||
await fs.mkdir(await joinPath([janDataFolderPath, modelDir]))
|
||||
await invoke<void>('write_yaml', {
|
||||
data: modelConfig,
|
||||
savePath: configPath,
|
||||
})
|
||||
events.emit(AppEvent.onModelImported, {
|
||||
modelId,
|
||||
modelPath,
|
||||
mmprojPath,
|
||||
size_bytes,
|
||||
model_sha256: opts.modelSha256,
|
||||
model_size_bytes: opts.modelSize,
|
||||
mmproj_sha256: opts.mmprojSha256,
|
||||
mmproj_size_bytes: opts.mmprojSize,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the entire model folder for a given modelId
|
||||
* @param modelId The model ID to delete
|
||||
*/
|
||||
private async deleteModelFolder(modelId: string): Promise<void> {
|
||||
try {
|
||||
const modelDir = await joinPath([
|
||||
await this.getProviderPath(),
|
||||
'models',
|
||||
modelId,
|
||||
])
|
||||
|
||||
if (await fs.existsSync(modelDir)) {
|
||||
logger.info(`Cleaning up model directory: ${modelDir}`)
|
||||
await fs.rm(modelDir)
|
||||
}
|
||||
} catch (deleteError) {
|
||||
logger.warn('Failed to delete model directory:', deleteError)
|
||||
}
|
||||
}
|
||||
|
||||
override async abortImport(modelId: string): Promise<void> {
|
||||
// prepand provider name to avoid name collision
|
||||
// Cancel any active download task
|
||||
// prepend provider name to avoid name collision
|
||||
const taskId = this.createDownloadTaskId(modelId)
|
||||
const downloadManager = window.core.extensionManager.getByName(
|
||||
'@janhq/download-extension'
|
||||
)
|
||||
await downloadManager.cancelDownload(taskId)
|
||||
|
||||
try {
|
||||
await downloadManager.cancelDownload(taskId)
|
||||
} catch (cancelError) {
|
||||
logger.warn('Failed to cancel download task:', cancelError)
|
||||
}
|
||||
|
||||
// Delete the entire model folder if it exists (for validation failures)
|
||||
await this.deleteModelFolder(modelId)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1109,6 +1262,27 @@ export default class llamacpp_extension extends AIEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private parseEnvFromString(
|
||||
target: Record<string, string>,
|
||||
envString: string
|
||||
): void {
|
||||
envString
|
||||
.split(';')
|
||||
.filter((pair) => pair.trim())
|
||||
.forEach((pair) => {
|
||||
const [key, ...valueParts] = pair.split('=')
|
||||
const cleanKey = key?.trim()
|
||||
|
||||
if (
|
||||
cleanKey &&
|
||||
valueParts.length > 0 &&
|
||||
!cleanKey.startsWith('LLAMA')
|
||||
) {
|
||||
target[cleanKey] = valueParts.join('=').trim()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override async load(
|
||||
modelId: string,
|
||||
overrideSettings?: Partial<LlamacppConfig>,
|
||||
@ -1168,11 +1342,12 @@ export default class llamacpp_extension extends AIEngine {
|
||||
}
|
||||
}
|
||||
const args: string[] = []
|
||||
const envs: Record<string, string> = {}
|
||||
const cfg = { ...this.config, ...(overrideSettings ?? {}) }
|
||||
const [version, backend] = cfg.version_backend.split('/')
|
||||
if (!version || !backend) {
|
||||
throw new Error(
|
||||
"Initial setup for the backend failed due to a network issue. Please restart the app!"
|
||||
'Initial setup for the backend failed due to a network issue. Please restart the app!'
|
||||
)
|
||||
}
|
||||
|
||||
@ -1194,7 +1369,10 @@ export default class llamacpp_extension extends AIEngine {
|
||||
// disable llama-server webui
|
||||
args.push('--no-webui')
|
||||
const api_key = await this.generateApiKey(modelId, String(port))
|
||||
args.push('--api-key', api_key)
|
||||
envs['LLAMA_API_KEY'] = api_key
|
||||
|
||||
// set user envs
|
||||
this.parseEnvFromString(envs, this.llamacpp_env)
|
||||
|
||||
// model option is required
|
||||
// NOTE: model_path and mmproj_path can be either relative to Jan's data folder or absolute path
|
||||
@ -1203,7 +1381,6 @@ export default class llamacpp_extension extends AIEngine {
|
||||
modelConfig.model_path,
|
||||
])
|
||||
args.push('--jinja')
|
||||
args.push('--reasoning-format', 'none')
|
||||
args.push('-m', modelPath)
|
||||
// For overriding tensor buffer type, useful where
|
||||
// massive MOE models can be made faster by keeping attention on the GPU
|
||||
@ -1213,6 +1390,10 @@ export default class llamacpp_extension extends AIEngine {
|
||||
// Takes a regex with matching tensor name as input
|
||||
if (cfg.override_tensor_buffer_t)
|
||||
args.push('--override-tensor', cfg.override_tensor_buffer_t)
|
||||
// offload multimodal projector model to the GPU by default. if there is not enough memory
|
||||
// turn this setting off will keep the projector model on the CPU but the image processing can
|
||||
// take longer
|
||||
if (cfg.offload_mmproj === false) args.push('--no-mmproj-offload')
|
||||
args.push('-a', modelId)
|
||||
args.push('--port', String(port))
|
||||
if (modelConfig.mmproj_path) {
|
||||
@ -1279,11 +1460,15 @@ export default class llamacpp_extension extends AIEngine {
|
||||
|
||||
try {
|
||||
// TODO: add LIBRARY_PATH
|
||||
const sInfo = await invoke<SessionInfo>('plugin:llamacpp|load_llama_model', {
|
||||
backendPath,
|
||||
libraryPath,
|
||||
args,
|
||||
})
|
||||
const sInfo = await invoke<SessionInfo>(
|
||||
'plugin:llamacpp|load_llama_model',
|
||||
{
|
||||
backendPath,
|
||||
libraryPath,
|
||||
args,
|
||||
envs,
|
||||
}
|
||||
)
|
||||
return sInfo
|
||||
} catch (error) {
|
||||
logger.error('Error in load command:\n', error)
|
||||
@ -1299,9 +1484,12 @@ export default class llamacpp_extension extends AIEngine {
|
||||
const pid = sInfo.pid
|
||||
try {
|
||||
// Pass the PID as the session_id
|
||||
const result = await invoke<UnloadResult>('plugin:llamacpp|unload_llama_model', {
|
||||
pid: pid,
|
||||
})
|
||||
const result = await invoke<UnloadResult>(
|
||||
'plugin:llamacpp|unload_llama_model',
|
||||
{
|
||||
pid: pid,
|
||||
}
|
||||
)
|
||||
|
||||
// If successful, remove from active sessions
|
||||
if (result.success) {
|
||||
@ -1370,7 +1558,11 @@ export default class llamacpp_extension extends AIEngine {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: abortController?.signal,
|
||||
connectTimeout: 600000, // 10 minutes
|
||||
signal: AbortSignal.any([
|
||||
AbortSignal.timeout(600000),
|
||||
abortController?.signal,
|
||||
]),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
@ -1437,9 +1629,12 @@ export default class llamacpp_extension extends AIEngine {
|
||||
|
||||
private async findSessionByModel(modelId: string): Promise<SessionInfo> {
|
||||
try {
|
||||
let sInfo = await invoke<SessionInfo>('plugin:llamacpp|find_session_by_model', {
|
||||
modelId,
|
||||
})
|
||||
let sInfo = await invoke<SessionInfo>(
|
||||
'plugin:llamacpp|find_session_by_model',
|
||||
{
|
||||
modelId,
|
||||
}
|
||||
)
|
||||
return sInfo
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
@ -1516,7 +1711,9 @@ export default class llamacpp_extension extends AIEngine {
|
||||
|
||||
override async getLoadedModels(): Promise<string[]> {
|
||||
try {
|
||||
let models: string[] = await invoke<string[]>('plugin:llamacpp|get_loaded_models')
|
||||
let models: string[] = await invoke<string[]>(
|
||||
'plugin:llamacpp|get_loaded_models'
|
||||
)
|
||||
return models
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
@ -1524,14 +1721,37 @@ export default class llamacpp_extension extends AIEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mmproj.gguf file exists for a given model ID
|
||||
* @param modelId - The model ID to check for mmproj.gguf
|
||||
* @returns Promise<boolean> - true if mmproj.gguf exists, false otherwise
|
||||
*/
|
||||
async checkMmprojExists(modelId: string): Promise<boolean> {
|
||||
try {
|
||||
const mmprojPath = await joinPath([
|
||||
await this.getProviderPath(),
|
||||
'models',
|
||||
modelId,
|
||||
'mmproj.gguf',
|
||||
])
|
||||
return await fs.existsSync(mmprojPath)
|
||||
} catch (e) {
|
||||
logger.error(`Error checking mmproj.gguf for model ${modelId}:`, e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getDevices(): Promise<DeviceList[]> {
|
||||
const cfg = this.config
|
||||
const [version, backend] = cfg.version_backend.split('/')
|
||||
if (!version || !backend) {
|
||||
throw new Error(
|
||||
`Invalid version/backend format: ${cfg.version_backend}. Expected format: <version>/<backend>`
|
||||
'Backend setup was not successful. Please restart the app in a stable internet connection.'
|
||||
)
|
||||
}
|
||||
// set envs
|
||||
const envs: Record<string, string> = {}
|
||||
this.parseEnvFromString(envs, this.llamacpp_env)
|
||||
|
||||
// Ensure backend is downloaded and ready before proceeding
|
||||
await this.ensureBackendReady(backend, version)
|
||||
@ -1542,11 +1762,12 @@ export default class llamacpp_extension extends AIEngine {
|
||||
const dList = await invoke<DeviceList[]>('plugin:llamacpp|get_devices', {
|
||||
backendPath,
|
||||
libraryPath,
|
||||
envs,
|
||||
})
|
||||
return dList
|
||||
} catch (error) {
|
||||
logger.error('Failed to query devices:\n', error)
|
||||
throw new Error(`Failed to load llama-server: ${error}`)
|
||||
throw new Error("Failed to load llamacpp backend")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1599,14 +1820,161 @@ export default class llamacpp_extension extends AIEngine {
|
||||
throw new Error('method not implemented yet')
|
||||
}
|
||||
|
||||
private async loadMetadata(path: string): Promise<GgufMetadata> {
|
||||
/**
|
||||
* Check if a tool is supported by the model
|
||||
* Currently read from GGUF chat_template
|
||||
* @param modelId
|
||||
* @returns
|
||||
*/
|
||||
async isToolSupported(modelId: string): Promise<boolean> {
|
||||
const janDataFolderPath = await getJanDataFolderPath()
|
||||
const modelConfigPath = await joinPath([
|
||||
this.providerPath,
|
||||
'models',
|
||||
modelId,
|
||||
'model.yml',
|
||||
])
|
||||
const modelConfig = await invoke<ModelConfig>('read_yaml', {
|
||||
path: modelConfigPath,
|
||||
})
|
||||
// model option is required
|
||||
// NOTE: model_path and mmproj_path can be either relative to Jan's data folder or absolute path
|
||||
const modelPath = await joinPath([
|
||||
janDataFolderPath,
|
||||
modelConfig.model_path,
|
||||
])
|
||||
return (await readGgufMetadata(modelPath)).metadata?.[
|
||||
'tokenizer.chat_template'
|
||||
]?.includes('tools')
|
||||
}
|
||||
|
||||
/**
|
||||
* estimate KVCache size of from a given metadata
|
||||
*
|
||||
*/
|
||||
private async estimateKVCache(
|
||||
meta: Record<string, string>,
|
||||
ctx_size?: number
|
||||
): Promise<number> {
|
||||
const arch = meta['general.architecture']
|
||||
if (!arch) throw new Error('Invalid metadata: architecture not found')
|
||||
|
||||
const nLayer = Number(meta[`${arch}.block_count`])
|
||||
if (!nLayer) throw new Error('Invalid metadata: block_count not found')
|
||||
|
||||
const nHead = Number(meta[`${arch}.attention.head_count`])
|
||||
if (!nHead) throw new Error('Invalid metadata: head_count not found')
|
||||
|
||||
// Try to get key/value lengths first (more accurate)
|
||||
const keyLen = Number(meta[`${arch}.attention.key_length`])
|
||||
const valLen = Number(meta[`${arch}.attention.value_length`])
|
||||
|
||||
let headDim: number
|
||||
|
||||
if (keyLen && valLen) {
|
||||
// Use explicit key/value lengths if available
|
||||
logger.info(
|
||||
`Using explicit key_length: ${keyLen}, value_length: ${valLen}`
|
||||
)
|
||||
headDim = (keyLen + valLen)
|
||||
} else {
|
||||
// Fall back to embedding_length estimation
|
||||
const embeddingLen = Number(meta[`${arch}.embedding_length`])
|
||||
if (!embeddingLen)
|
||||
throw new Error('Invalid metadata: embedding_length not found')
|
||||
|
||||
// Standard transformer: head_dim = embedding_dim / num_heads
|
||||
// For KV cache: we need both K and V, so 2 * head_dim per head
|
||||
headDim = (embeddingLen / nHead) * 2
|
||||
logger.info(
|
||||
`Using embedding_length estimation: ${embeddingLen}, calculated head_dim: ${headDim}`
|
||||
)
|
||||
}
|
||||
let ctxLen: number
|
||||
if (!ctx_size) {
|
||||
ctxLen = Number(meta[`${arch}.context_length`])
|
||||
} else {
|
||||
ctxLen = ctx_size
|
||||
}
|
||||
|
||||
logger.info(`ctxLen: ${ctxLen}`)
|
||||
logger.info(`nLayer: ${nLayer}`)
|
||||
logger.info(`nHead: ${nHead}`)
|
||||
logger.info(`headDim: ${headDim}`)
|
||||
|
||||
// Consider f16 by default
|
||||
// Can be extended by checking cache-type-v and cache-type-k
|
||||
// but we are checking overall compatibility with the default settings
|
||||
// fp16 = 8 bits * 2 = 16
|
||||
const bytesPerElement = 2
|
||||
|
||||
// Total KV cache size per token = nHead * headDim * bytesPerElement
|
||||
const kvPerToken = nHead * headDim * bytesPerElement
|
||||
|
||||
return ctxLen * nLayer * kvPerToken
|
||||
}
|
||||
|
||||
private async getModelSize(path: string): Promise<number> {
|
||||
if (path.startsWith('https://')) {
|
||||
const res = await fetch(path, { method: 'HEAD' })
|
||||
const len = res.headers.get('content-length')
|
||||
return len ? parseInt(len, 10) : 0
|
||||
} else {
|
||||
return (await fs.fileStat(path)).size
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* check the support status of a model by its path (local/remote)
|
||||
*
|
||||
* * Returns:
|
||||
* - "RED" → weights don't fit
|
||||
* - "YELLOW" → weights fit, KV cache doesn't
|
||||
* - "GREEN" → both weights + KV cache fit
|
||||
*/
|
||||
async isModelSupported(
|
||||
path: string,
|
||||
ctx_size?: number
|
||||
): Promise<'RED' | 'YELLOW' | 'GREEN'> {
|
||||
try {
|
||||
const data = await invoke<GgufMetadata>('plugin:llamacpp|read_gguf_metadata', {
|
||||
path: path,
|
||||
})
|
||||
return data
|
||||
} catch (err) {
|
||||
throw err
|
||||
const modelSize = await this.getModelSize(path)
|
||||
logger.info(`modelSize: ${modelSize}`)
|
||||
let gguf: GgufMetadata
|
||||
gguf = await readGgufMetadata(path)
|
||||
let kvCacheSize: number
|
||||
if (ctx_size) {
|
||||
kvCacheSize = await this.estimateKVCache(gguf.metadata, ctx_size)
|
||||
} else {
|
||||
kvCacheSize = await this.estimateKVCache(gguf.metadata)
|
||||
}
|
||||
// total memory consumption = model weights + kvcache + a small buffer for outputs
|
||||
// output buffer is small so not considering here
|
||||
const totalRequired = modelSize + kvCacheSize
|
||||
logger.info(
|
||||
`isModelSupported: Total memory requirement: ${totalRequired} for ${path}`
|
||||
)
|
||||
let availableMemBytes: number
|
||||
const devices = await this.getDevices()
|
||||
if (devices.length > 0) {
|
||||
// Sum free memory across all GPUs
|
||||
availableMemBytes = devices
|
||||
.map((d) => d.free * 1024 * 1024)
|
||||
.reduce((a, b) => a + b, 0)
|
||||
} else {
|
||||
// CPU fallback
|
||||
const sys = await getSystemUsage()
|
||||
availableMemBytes = (sys.total_memory - sys.used_memory) * 1024 * 1024
|
||||
}
|
||||
// check model size wrt system memory
|
||||
if (modelSize > availableMemBytes) {
|
||||
return 'RED'
|
||||
} else if (modelSize + kvCacheSize > availableMemBytes) {
|
||||
return 'YELLOW'
|
||||
} else {
|
||||
return 'GREEN'
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(String(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
111
src-tauri/CONTRIBUTING.md
Normal file
@ -0,0 +1,111 @@
|
||||
# Contributing to Tauri Backend
|
||||
|
||||
[← Back to Main Contributing Guide](../CONTRIBUTING.md)
|
||||
|
||||
Rust backend that handles native system integration, file operations, and process management.
|
||||
|
||||
## Key Modules
|
||||
|
||||
- **`/src/core/app`** - App state and commands
|
||||
- **`/src/core/downloads`** - Model download management
|
||||
- **`/src/core/filesystem`** - File system operations
|
||||
- **`/src/core/mcp`** - Model Context Protocol
|
||||
- **`/src/core/server`** - Local API server
|
||||
- **`/src/core/system`** - System information and utilities
|
||||
- **`/src/core/threads`** - Conversation management
|
||||
- **`/utils`** - Shared utility crate (CLI, crypto, HTTP, path utils). Used by plugins and the main backend.
|
||||
- **`/plugins`** - Native Tauri plugins ([see plugins guide](./plugins/CONTRIBUTING.md))
|
||||
|
||||
## Development
|
||||
|
||||
### Adding Tauri Commands
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
async fn my_command(param: String) -> Result<String, String> {
|
||||
Ok(format!("Processed: {}", param))
|
||||
}
|
||||
|
||||
// Register in lib.rs
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![my_command])
|
||||
```
|
||||
|
||||
## Building & Testing
|
||||
|
||||
```bash
|
||||
# Development
|
||||
yarn tauri dev
|
||||
|
||||
# Build
|
||||
yarn tauri build
|
||||
|
||||
# Run tests
|
||||
cargo test
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
async fn get_data(state: State<'_, AppState>) -> Result<Data, Error> {
|
||||
state.get_data().await
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```rust
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```rust
|
||||
// Enable debug logging
|
||||
env::set_var("RUST_LOG", "debug");
|
||||
|
||||
// Debug print in commands
|
||||
#[tauri::command]
|
||||
async fn my_command() -> Result<String, String> {
|
||||
println!("Command called"); // Shows in terminal
|
||||
dbg!("Debug info");
|
||||
Ok("result".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
## Platform-Specific Notes
|
||||
|
||||
**Windows**: Requires Visual Studio Build Tools
|
||||
**macOS**: Needs Xcode command line tools
|
||||
**Linux**: May need additional system packages
|
||||
|
||||
```rust
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
**Build failures**: Check Rust toolchain version
|
||||
**IPC errors**: Ensure command names match frontend calls
|
||||
**Permission errors**: Update capabilities configuration
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Always use `Result<T, E>` for fallible operations
|
||||
- Validate all input from frontend
|
||||
- Use async for I/O operations
|
||||
- Follow Rust naming conventions
|
||||
- Document public APIs
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Tauri** - Desktop app framework
|
||||
- **Tokio** - Async runtime
|
||||
- **Serde** - JSON serialization
|
||||
- **thiserror** - Error handling
|
||||
58
src-tauri/Cargo.lock
generated
@ -854,8 +854,18 @@ version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.20.11",
|
||||
"darling_macro 0.20.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08440b3dd222c3d0433e63e097463969485f112baff337dfdaca043a0d760570"
|
||||
dependencies = [
|
||||
"darling_core 0.21.2",
|
||||
"darling_macro 0.21.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -872,13 +882,38 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25b7912bc28a04ab1b7715a68ea03aaa15662b43a1a4b2c480531fd19f8bf7e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.20.11",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce154b9bea7fb0c8e8326e62d00354000c36e79770ff21b8c84e3aa267d9d531"
|
||||
dependencies = [
|
||||
"darling_core 0.21.2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
@ -2288,6 +2323,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"url",
|
||||
]
|
||||
|
||||
@ -3984,8 +4020,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3196c95f1dfafbffbdcdd6d365c94969ac975e6a#3196c95f1dfafbffbdcdd6d365c94969ac975e6a"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb21cd3555f1059f27e4813827338dec44429a08ecd0011acc41d9907b160c00"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@ -4010,10 +4047,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3196c95f1dfafbffbdcdd6d365c94969ac975e6a#3196c95f1dfafbffbdcdd6d365c94969ac975e6a"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab5d16ae1ff3ce2c5fd86c37047b2869b75bec795d53a4b1d8257b15415a2354"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.21.2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
@ -4408,7 +4446,7 @@ version = "3.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.20.11",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
@ -6868,7 +6906,7 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a76ff259533532054cfbaefb115c613203c73707017459206380f03b3b3f266e"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.20.11",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
|
||||
@ -44,9 +44,10 @@ jan-utils = { path = "./utils" }
|
||||
libloading = "0.8.7"
|
||||
log = "0.4"
|
||||
reqwest = { version = "0.11", features = ["json", "blocking", "stream"] }
|
||||
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "3196c95f1dfafbffbdcdd6d365c94969ac975e6a", features = [
|
||||
rmcp = { version = "0.6.0", features = [
|
||||
"client",
|
||||
"transport-sse-client",
|
||||
"transport-streamable-http-client",
|
||||
"transport-child-process",
|
||||
"tower",
|
||||
"reqwest",
|
||||
|
||||
119
src-tauri/plugins/CONTRIBUTING.md
Normal file
@ -0,0 +1,119 @@
|
||||
# Contributing to Tauri Plugins
|
||||
|
||||
[← Back to Main Contributing Guide](../../CONTRIBUTING.md) | [← Back to Tauri Guide](../CONTRIBUTING.md)
|
||||
|
||||
Native Rust plugins for hardware access, process management, and system integration.
|
||||
|
||||
## Current Plugins
|
||||
|
||||
### `/tauri-plugin-hardware`
|
||||
- Hardware detection (CPU, GPU, memory)
|
||||
|
||||
### `/tauri-plugin-llamacpp`
|
||||
- llama.cpp process management and model inference
|
||||
|
||||
## Plugin Structure
|
||||
|
||||
```
|
||||
tauri-plugin-name/
|
||||
├── Cargo.toml
|
||||
├── src/lib.rs # Plugin entry point
|
||||
├── src/commands.rs # Tauri commands
|
||||
├── guest-js/index.ts # JavaScript API
|
||||
└── permissions/default.toml
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Creating Plugins
|
||||
|
||||
Assuming that your new plugin name is `my-plugin`
|
||||
|
||||
```bash
|
||||
# with npx
|
||||
npx @tauri-apps/cli plugin new my-plugin
|
||||
|
||||
# with cargo
|
||||
cargo tauri plugin new my-plugin
|
||||
|
||||
cd tauri-plugin-my-plugin
|
||||
```
|
||||
|
||||
### Plugin Registration
|
||||
|
||||
```rust
|
||||
use tauri::{plugin::{Builder, TauriPlugin}, Runtime};
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("my-plugin")
|
||||
.invoke_handler(tauri::generate_handler![commands::my_command])
|
||||
.build()
|
||||
}
|
||||
```
|
||||
|
||||
### Commands & JavaScript API
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn my_command(param: String) -> Result<String, Error> {
|
||||
Ok(format!("Result: {}", param))
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export async function myCommand(param: string): Promise<string> {
|
||||
return await invoke('plugin:my-plugin|my_command', { param })
|
||||
}
|
||||
```
|
||||
|
||||
### Building & Testing
|
||||
|
||||
```bash
|
||||
cargo build # Build plugin
|
||||
yarn build # Build JavaScript
|
||||
cargo test # Run tests
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
```toml
|
||||
# permissions/default.toml - Be specific
|
||||
[[permission]]
|
||||
identifier = "allow-hardware-info"
|
||||
description = "Read system hardware information"
|
||||
|
||||
# Never use wildcards in production
|
||||
# ❌ identifier = "allow-*"
|
||||
# ✅ identifier = "allow-specific-action"
|
||||
```
|
||||
|
||||
## Testing Plugins
|
||||
|
||||
```bash
|
||||
# Test plugin in isolation
|
||||
cd tauri-plugin-my-plugin
|
||||
cargo test
|
||||
|
||||
# Test with main app
|
||||
cd ../../
|
||||
yarn tauri dev
|
||||
|
||||
# Test JavaScript API
|
||||
yarn build && node -e "const plugin = require('./dist-js'); console.log(plugin)"
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use secure permission configurations
|
||||
- Validate all command inputs
|
||||
- Handle platform differences properly
|
||||
- Clean up resources in Drop implementations
|
||||
- Test on all target platforms
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Tauri** - Plugin framework
|
||||
- **Serde** - JSON serialization
|
||||
- **Tokio** - Async runtime (if needed)
|
||||
@ -327,4 +327,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -24,7 +24,7 @@ impl CpuStaticInfo {
|
||||
CpuStaticInfo {
|
||||
name,
|
||||
core_count: System::physical_core_count().unwrap_or(0),
|
||||
arch: std::env::consts::ARCH.to_string(),
|
||||
arch: System::cpu_arch(),
|
||||
extensions: CpuStaticInfo::get_extensions(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use crate::commands::*;
|
||||
use crate::types::CpuStaticInfo;
|
||||
use tauri::test::mock_app;
|
||||
|
||||
#[test]
|
||||
@ -14,3 +15,125 @@ fn test_system_usage() {
|
||||
let usage = get_system_usage(app.handle().clone());
|
||||
println!("System Usage Info: {:?}", usage);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cpu_static_info_new() {
|
||||
let cpu_info = CpuStaticInfo::new();
|
||||
|
||||
// Test that all fields are populated
|
||||
assert!(!cpu_info.name.is_empty());
|
||||
assert_ne!(cpu_info.name, "unknown"); // Should have detected a CPU name
|
||||
assert!(cpu_info.core_count > 0);
|
||||
assert!(!cpu_info.arch.is_empty());
|
||||
|
||||
// Architecture should be one of the expected values
|
||||
assert!(
|
||||
cpu_info.arch == "aarch64" ||
|
||||
cpu_info.arch == "arm64" ||
|
||||
cpu_info.arch == "x86_64" ||
|
||||
cpu_info.arch == std::env::consts::ARCH
|
||||
);
|
||||
|
||||
// Extensions should be a valid list (can be empty on non-x86)
|
||||
|
||||
println!("CPU Info: {:?}", cpu_info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cpu_info_consistency() {
|
||||
// Test that multiple calls return consistent information
|
||||
let info1 = CpuStaticInfo::new();
|
||||
let info2 = CpuStaticInfo::new();
|
||||
|
||||
assert_eq!(info1.name, info2.name);
|
||||
assert_eq!(info1.core_count, info2.core_count);
|
||||
assert_eq!(info1.arch, info2.arch);
|
||||
assert_eq!(info1.extensions, info2.extensions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cpu_name_not_empty() {
|
||||
let cpu_info = CpuStaticInfo::new();
|
||||
assert!(!cpu_info.name.is_empty());
|
||||
assert!(cpu_info.name.len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_core_count_positive() {
|
||||
let cpu_info = CpuStaticInfo::new();
|
||||
assert!(cpu_info.core_count > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
|
||||
fn test_x86_extensions() {
|
||||
let cpu_info = CpuStaticInfo::new();
|
||||
|
||||
// On x86/x86_64, we should always have at least FPU
|
||||
assert!(cpu_info.extensions.contains(&"fpu".to_string()));
|
||||
|
||||
// Check that all extensions are valid x86 feature names
|
||||
let valid_extensions = [
|
||||
"fpu", "mmx", "sse", "sse2", "sse3", "ssse3", "sse4_1", "sse4_2",
|
||||
"pclmulqdq", "avx", "avx2", "avx512_f", "avx512_dq", "avx512_ifma",
|
||||
"avx512_pf", "avx512_er", "avx512_cd", "avx512_bw", "avx512_vl",
|
||||
"avx512_vbmi", "avx512_vbmi2", "avx512_vnni", "avx512_bitalg",
|
||||
"avx512_vpopcntdq", "avx512_vp2intersect", "aes", "f16c"
|
||||
];
|
||||
|
||||
for ext in &cpu_info.extensions {
|
||||
assert!(
|
||||
valid_extensions.contains(&ext.as_str()),
|
||||
"Unknown extension: {}",
|
||||
ext
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
|
||||
fn test_non_x86_extensions() {
|
||||
let cpu_info = CpuStaticInfo::new();
|
||||
|
||||
// On non-x86 architectures, extensions should be empty
|
||||
assert!(cpu_info.extensions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arch_detection() {
|
||||
let cpu_info = CpuStaticInfo::new();
|
||||
|
||||
// Architecture should be a valid string
|
||||
assert!(!cpu_info.arch.is_empty());
|
||||
|
||||
// Should be one of the common architectures
|
||||
let common_archs = ["x86_64", "aarch64", "arm", "arm64", "x86"];
|
||||
let is_common_arch = common_archs.iter().any(|&arch| cpu_info.arch == arch);
|
||||
let is_compile_time_arch = cpu_info.arch == std::env::consts::ARCH;
|
||||
|
||||
assert!(
|
||||
is_common_arch || is_compile_time_arch,
|
||||
"Unexpected architecture: {}",
|
||||
cpu_info.arch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cpu_info_serialization() {
|
||||
let cpu_info = CpuStaticInfo::new();
|
||||
|
||||
// Test that the struct can be serialized (since it derives Serialize)
|
||||
let serialized = serde_json::to_string(&cpu_info);
|
||||
assert!(serialized.is_ok());
|
||||
|
||||
let json_str = serialized.unwrap();
|
||||
assert!(json_str.contains("name"));
|
||||
assert!(json_str.contains("core_count"));
|
||||
assert!(json_str.contains("arch"));
|
||||
assert!(json_str.contains("extensions"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,12 +23,13 @@ sysinfo = "0.34.2"
|
||||
tauri = { version = "2.5.0", default-features = false, features = [] }
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.11", features = ["json", "blocking", "stream"] }
|
||||
|
||||
# Windows-specific dependencies
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.60.2", features = ["Win32_Storage_FileSystem"] }
|
||||
|
||||
# Unix-specific dependencies
|
||||
# Unix-specific dependencies
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "=0.30.1", features = ["signal", "process"] }
|
||||
|
||||
|
||||
@ -447,4 +447,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tauri::{Manager, Runtime, State};
|
||||
@ -11,7 +12,7 @@ use tokio::time::Instant;
|
||||
|
||||
use crate::device::{get_devices_from_backend, DeviceInfo};
|
||||
use crate::error::{ErrorCode, LlamacppError, ServerError, ServerResult};
|
||||
use crate::path::{validate_binary_path, validate_model_path};
|
||||
use crate::path::{validate_binary_path, validate_model_path, validate_mmproj_path};
|
||||
use crate::process::{
|
||||
find_session_by_model_id, get_all_active_sessions, get_all_loaded_model_ids,
|
||||
get_random_available_port, is_process_running_by_pid,
|
||||
@ -42,6 +43,7 @@ pub async fn load_llama_model<R: Runtime>(
|
||||
backend_path: &str,
|
||||
library_path: Option<&str>,
|
||||
mut args: Vec<String>,
|
||||
envs: HashMap<String, String>,
|
||||
) -> ServerResult<SessionInfo> {
|
||||
let state: State<LlamacppState> = app_handle.state();
|
||||
let mut process_map = state.llama_server_process.lock().await;
|
||||
@ -53,13 +55,23 @@ pub async fn load_llama_model<R: Runtime>(
|
||||
|
||||
let port = parse_port_from_args(&args);
|
||||
let model_path_pb = validate_model_path(&mut args)?;
|
||||
let _mmproj_path_pb = validate_mmproj_path(&mut args)?;
|
||||
|
||||
let api_key: String;
|
||||
|
||||
if let Some(api_value) = envs.get("LLAMA_API_KEY") {
|
||||
api_key = api_value.to_string();
|
||||
} else {
|
||||
log::warn!("API key not provided");
|
||||
api_key = "".to_string();
|
||||
}
|
||||
|
||||
let api_key = extract_arg_value(&args, "--api-key");
|
||||
let model_id = extract_arg_value(&args, "-a");
|
||||
|
||||
// Configure the command to run the server
|
||||
let mut command = Command::new(backend_path);
|
||||
command.args(args);
|
||||
command.envs(envs);
|
||||
|
||||
setup_library_path(library_path, &mut command);
|
||||
command.stdout(Stdio::piped());
|
||||
@ -253,8 +265,9 @@ pub async fn unload_llama_model<R: Runtime>(
|
||||
pub async fn get_devices(
|
||||
backend_path: &str,
|
||||
library_path: Option<&str>,
|
||||
envs: HashMap<String, String>
|
||||
) -> ServerResult<Vec<DeviceInfo>> {
|
||||
get_devices_from_backend(backend_path, library_path).await
|
||||
get_devices_from_backend(backend_path, library_path, envs).await
|
||||
}
|
||||
|
||||
/// Generate API key using HMAC-SHA256
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
@ -19,6 +20,7 @@ pub struct DeviceInfo {
|
||||
pub async fn get_devices_from_backend(
|
||||
backend_path: &str,
|
||||
library_path: Option<&str>,
|
||||
envs: HashMap<String, String>,
|
||||
) -> ServerResult<Vec<DeviceInfo>> {
|
||||
log::info!("Getting devices from server at path: {:?}", backend_path);
|
||||
|
||||
@ -27,6 +29,7 @@ pub async fn get_devices_from_backend(
|
||||
// Configure the command to run the server with --list-devices
|
||||
let mut command = Command::new(backend_path);
|
||||
command.arg("--list-devices");
|
||||
command.envs(envs);
|
||||
|
||||
// Set up library path
|
||||
setup_library_path(library_path, &mut command);
|
||||
|
||||
@ -1,8 +1,58 @@
|
||||
use super::helpers;
|
||||
use super::types::GgufMetadata;
|
||||
use reqwest;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
|
||||
/// Read GGUF metadata from a model file
|
||||
#[tauri::command]
|
||||
pub async fn read_gguf_metadata(path: String) -> Result<GgufMetadata, String> {
|
||||
helpers::read_gguf_metadata(&path).map_err(|e| format!("Failed to read GGUF metadata: {}", e))
|
||||
if path.starts_with("http://") || path.starts_with("https://") {
|
||||
// Remote: read in 2MB chunks until successful
|
||||
let client = reqwest::Client::new();
|
||||
let chunk_size = 2 * 1024 * 1024; // Fixed 2MB chunks
|
||||
let max_total_size = 120 * 1024 * 1024; // Don't exceed 120MB total
|
||||
let mut total_downloaded = 0;
|
||||
let mut accumulated_data = Vec::new();
|
||||
|
||||
while total_downloaded < max_total_size {
|
||||
let start = total_downloaded;
|
||||
let end = std::cmp::min(start + chunk_size - 1, max_total_size - 1);
|
||||
|
||||
let resp = client
|
||||
.get(&path)
|
||||
.header("Range", format!("bytes={}-{}", start, end))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch chunk {}-{}: {}", start, end, e))?;
|
||||
|
||||
let chunk_data = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read chunk response: {}", e))?;
|
||||
|
||||
accumulated_data.extend_from_slice(&chunk_data);
|
||||
total_downloaded += chunk_data.len();
|
||||
|
||||
// Try parsing after each chunk
|
||||
let cursor = std::io::Cursor::new(&accumulated_data);
|
||||
if let Ok(metadata) = helpers::read_gguf_metadata(cursor) {
|
||||
return Ok(metadata);
|
||||
}
|
||||
|
||||
// If we got less data than expected, we've reached EOF
|
||||
if chunk_data.len() < chunk_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err("Could not parse GGUF metadata from downloaded data".to_string())
|
||||
} else {
|
||||
// Local: use streaming file reader
|
||||
let file =
|
||||
File::open(&path).map_err(|e| format!("Failed to open local file {}: {}", path, e))?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
helpers::read_gguf_metadata(reader)
|
||||
.map_err(|e| format!("Failed to parse GGUF metadata: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use std::convert::TryFrom;
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufReader, Read, Seek};
|
||||
use std::path::Path;
|
||||
|
||||
use super::types::{GgufMetadata, GgufValueType};
|
||||
|
||||
pub fn read_gguf_metadata<P: AsRef<Path>>(path: P) -> io::Result<GgufMetadata> {
|
||||
let mut file = BufReader::new(File::open(path)?);
|
||||
pub fn read_gguf_metadata<R: Read + Seek>(reader: R) -> io::Result<GgufMetadata> {
|
||||
let mut file = BufReader::new(reader);
|
||||
|
||||
let mut magic = [0u8; 4];
|
||||
file.read_exact(&mut magic)?;
|
||||
|
||||
@ -98,3 +98,50 @@ pub fn validate_model_path(args: &mut Vec<String>) -> ServerResult<PathBuf> {
|
||||
|
||||
Ok(model_path_pb)
|
||||
}
|
||||
|
||||
/// Validate mmproj path exists and update args with platform-appropriate path format
|
||||
pub fn validate_mmproj_path(args: &mut Vec<String>) -> ServerResult<Option<PathBuf>> {
|
||||
let mmproj_path_index = match args.iter().position(|arg| arg == "--mmproj") {
|
||||
Some(index) => index,
|
||||
None => return Ok(None), // mmproj is optional
|
||||
};
|
||||
|
||||
let mmproj_path = args.get(mmproj_path_index + 1).cloned().ok_or_else(|| {
|
||||
LlamacppError::new(
|
||||
ErrorCode::ModelLoadFailed,
|
||||
"Mmproj path was not provided after '--mmproj' flag.".into(),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
|
||||
let mmproj_path_pb = PathBuf::from(&mmproj_path);
|
||||
if !mmproj_path_pb.exists() {
|
||||
let err_msg = format!(
|
||||
"Invalid or inaccessible mmproj path: {}",
|
||||
mmproj_path_pb.display()
|
||||
);
|
||||
log::error!("{}", &err_msg);
|
||||
return Err(LlamacppError::new(
|
||||
ErrorCode::ModelFileNotFound,
|
||||
"The specified mmproj file does not exist or is not accessible.".into(),
|
||||
Some(err_msg),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// use short path on Windows
|
||||
if let Some(short) = get_short_path(&mmproj_path_pb) {
|
||||
args[mmproj_path_index + 1] = short;
|
||||
} else {
|
||||
args[mmproj_path_index + 1] = mmproj_path_pb.display().to_string();
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
args[mmproj_path_index + 1] = mmproj_path_pb.display().to_string();
|
||||
}
|
||||
|
||||
Ok(Some(mmproj_path_pb))
|
||||
}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
use super::models::{DownloadEvent, DownloadItem, ProxyConfig};
|
||||
use super::models::{DownloadEvent, DownloadItem, ProxyConfig, ProgressTracker};
|
||||
use crate::core::app::commands::get_jan_data_folder_path;
|
||||
use futures_util::StreamExt;
|
||||
use jan_utils::normalize_path;
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tauri::Emitter;
|
||||
use tokio::fs::File;
|
||||
@ -11,10 +12,131 @@ use tokio::io::AsyncWriteExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use url::Url;
|
||||
|
||||
// ===== UTILITY FUNCTIONS =====
|
||||
|
||||
pub fn err_to_string<E: std::fmt::Display>(e: E) -> String {
|
||||
format!("Error: {}", e)
|
||||
}
|
||||
|
||||
|
||||
// ===== VALIDATION FUNCTIONS =====
|
||||
|
||||
/// Validates a downloaded file against expected hash and size
|
||||
async fn validate_downloaded_file(
|
||||
item: &DownloadItem,
|
||||
save_path: &Path,
|
||||
app: &tauri::AppHandle,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> Result<(), String> {
|
||||
// Skip validation if no verification data is provided
|
||||
if item.sha256.is_none() && item.size.is_none() {
|
||||
log::debug!(
|
||||
"No validation data provided for {}, skipping validation",
|
||||
item.url
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Extract model ID from save path for validation events
|
||||
// Path structure: llamacpp/models/{modelId}/model.gguf or llamacpp/models/{modelId}/mmproj.gguf
|
||||
let model_id = save_path
|
||||
.parent() // get parent directory (modelId folder)
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
// Emit validation started event
|
||||
app.emit(
|
||||
"onModelValidationStarted",
|
||||
serde_json::json!({
|
||||
"modelId": model_id,
|
||||
"downloadType": "Model",
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
log::info!("Starting validation for model: {}", model_id);
|
||||
|
||||
// Validate size if provided (fast check first)
|
||||
if let Some(expected_size) = &item.size {
|
||||
log::info!("Starting size verification for {}", item.url);
|
||||
|
||||
match tokio::fs::metadata(save_path).await {
|
||||
Ok(metadata) => {
|
||||
let actual_size = metadata.len();
|
||||
|
||||
if actual_size != *expected_size {
|
||||
log::error!(
|
||||
"Size verification failed for {}. Expected: {} bytes, Actual: {} bytes",
|
||||
item.url,
|
||||
expected_size,
|
||||
actual_size
|
||||
);
|
||||
return Err(format!(
|
||||
"Size verification failed. Expected {} bytes but got {} bytes.",
|
||||
expected_size, actual_size
|
||||
));
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Size verification successful for {} ({} bytes)",
|
||||
item.url,
|
||||
actual_size
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to get file metadata for {}: {}",
|
||||
save_path.display(),
|
||||
e
|
||||
);
|
||||
return Err(format!("Failed to verify file size: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cancellation before expensive hash computation
|
||||
if cancel_token.is_cancelled() {
|
||||
log::info!("Validation cancelled for {}", item.url);
|
||||
return Err("Validation cancelled".to_string());
|
||||
}
|
||||
|
||||
// Validate hash if provided (expensive check second)
|
||||
if let Some(expected_sha256) = &item.sha256 {
|
||||
log::info!("Starting Hash verification for {}", item.url);
|
||||
|
||||
match jan_utils::crypto::compute_file_sha256_with_cancellation(save_path, cancel_token).await {
|
||||
Ok(computed_sha256) => {
|
||||
if computed_sha256 != *expected_sha256 {
|
||||
log::error!(
|
||||
"Hash verification failed for {}. Expected: {}, Computed: {}",
|
||||
item.url,
|
||||
expected_sha256,
|
||||
computed_sha256
|
||||
);
|
||||
|
||||
return Err(format!(
|
||||
"Hash verification failed. The downloaded file is corrupted or has been tampered with."
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("Hash verification successful for {}", item.url);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to compute SHA256 for {}: {}",
|
||||
save_path.display(),
|
||||
e
|
||||
);
|
||||
return Err(format!("Failed to verify file integrity: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("All validations passed for {}", item.url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_proxy_config(config: &ProxyConfig) -> Result<(), String> {
|
||||
// Validate proxy URL format
|
||||
if let Err(e) = Url::parse(&config.url) {
|
||||
@ -172,6 +294,9 @@ pub async fn _get_file_size(
|
||||
}
|
||||
}
|
||||
|
||||
// ===== MAIN DOWNLOAD FUNCTIONS =====
|
||||
|
||||
/// Downloads multiple files in parallel with individual progress tracking
|
||||
pub async fn _download_files_internal(
|
||||
app: tauri::AppHandle,
|
||||
items: &[DownloadItem],
|
||||
@ -184,28 +309,31 @@ pub async fn _download_files_internal(
|
||||
|
||||
let header_map = _convert_headers(headers).map_err(err_to_string)?;
|
||||
|
||||
let total_size = {
|
||||
let mut total_size = 0u64;
|
||||
for item in items.iter() {
|
||||
let client = _get_client_for_item(item, &header_map).map_err(err_to_string)?;
|
||||
total_size += _get_file_size(&client, &item.url)
|
||||
.await
|
||||
.map_err(err_to_string)?;
|
||||
}
|
||||
total_size
|
||||
};
|
||||
// Calculate sizes for each file
|
||||
let mut file_sizes = HashMap::new();
|
||||
for item in items.iter() {
|
||||
let client = _get_client_for_item(item, &header_map).map_err(err_to_string)?;
|
||||
let size = _get_file_size(&client, &item.url)
|
||||
.await
|
||||
.map_err(err_to_string)?;
|
||||
file_sizes.insert(item.url.clone(), size);
|
||||
}
|
||||
|
||||
let total_size: u64 = file_sizes.values().sum();
|
||||
log::info!("Total download size: {}", total_size);
|
||||
|
||||
let mut evt = DownloadEvent {
|
||||
transferred: 0,
|
||||
total: total_size,
|
||||
};
|
||||
let evt_name = format!("download-{}", task_id);
|
||||
|
||||
// Create progress tracker
|
||||
let progress_tracker = ProgressTracker::new(items, file_sizes.clone());
|
||||
|
||||
// save file under Jan data folder
|
||||
let jan_data_folder = get_jan_data_folder_path(app.clone());
|
||||
|
||||
for item in items.iter() {
|
||||
// Collect download tasks for parallel execution
|
||||
let mut download_tasks = Vec::new();
|
||||
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
let save_path = jan_data_folder.join(&item.save_path);
|
||||
let save_path = normalize_path(&save_path);
|
||||
|
||||
@ -217,120 +345,251 @@ pub async fn _download_files_internal(
|
||||
));
|
||||
}
|
||||
|
||||
// Create parent directories if they don't exist
|
||||
if let Some(parent) = save_path.parent() {
|
||||
if !parent.exists() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(err_to_string)?;
|
||||
}
|
||||
}
|
||||
// Spawn download task for each file
|
||||
let item_clone = item.clone();
|
||||
let app_clone = app.clone();
|
||||
let header_map_clone = header_map.clone();
|
||||
let cancel_token_clone = cancel_token.clone();
|
||||
let evt_name_clone = evt_name.clone();
|
||||
let progress_tracker_clone = progress_tracker.clone();
|
||||
let file_id = format!("{}-{}", task_id, index);
|
||||
let file_size = file_sizes.get(&item.url).copied().unwrap_or(0);
|
||||
|
||||
let current_extension = save_path.extension().unwrap_or_default().to_string_lossy();
|
||||
let append_extension = |ext: &str| {
|
||||
if current_extension.is_empty() {
|
||||
ext.to_string()
|
||||
} else {
|
||||
format!("{}.{}", current_extension, ext)
|
||||
}
|
||||
};
|
||||
let tmp_save_path = save_path.with_extension(append_extension("tmp"));
|
||||
let url_save_path = save_path.with_extension(append_extension("url"));
|
||||
|
||||
let mut should_resume = resume
|
||||
&& tmp_save_path.exists()
|
||||
&& tokio::fs::read_to_string(&url_save_path)
|
||||
.await
|
||||
.map(|url| url == item.url) // check if we resume the same URL
|
||||
.unwrap_or(false);
|
||||
|
||||
tokio::fs::write(&url_save_path, item.url.clone())
|
||||
let task = tokio::spawn(async move {
|
||||
download_single_file(
|
||||
app_clone,
|
||||
&item_clone,
|
||||
&header_map_clone,
|
||||
&save_path,
|
||||
resume,
|
||||
cancel_token_clone,
|
||||
evt_name_clone,
|
||||
progress_tracker_clone,
|
||||
file_id,
|
||||
file_size,
|
||||
)
|
||||
.await
|
||||
.map_err(err_to_string)?;
|
||||
});
|
||||
|
||||
log::info!("Started downloading: {}", item.url);
|
||||
let client = _get_client_for_item(item, &header_map).map_err(err_to_string)?;
|
||||
let mut download_delta = 0u64;
|
||||
let resp = if should_resume {
|
||||
let downloaded_size = tmp_save_path.metadata().map_err(err_to_string)?.len();
|
||||
match _get_maybe_resume(&client, &item.url, downloaded_size).await {
|
||||
Ok(resp) => {
|
||||
log::info!(
|
||||
"Resume download: {}, already downloaded {} bytes",
|
||||
item.url,
|
||||
downloaded_size
|
||||
);
|
||||
download_delta += downloaded_size;
|
||||
resp
|
||||
}
|
||||
Err(e) => {
|
||||
// fallback to normal download
|
||||
log::warn!("Failed to resume download: {}", e);
|
||||
should_resume = false;
|
||||
_get_maybe_resume(&client, &item.url, 0).await?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_get_maybe_resume(&client, &item.url, 0).await?
|
||||
};
|
||||
let mut stream = resp.bytes_stream();
|
||||
|
||||
let file = if should_resume {
|
||||
// resume download, append to existing file
|
||||
tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(&tmp_save_path)
|
||||
.await
|
||||
.map_err(err_to_string)?
|
||||
} else {
|
||||
// start new download, create a new file
|
||||
File::create(&tmp_save_path).await.map_err(err_to_string)?
|
||||
};
|
||||
let mut writer = tokio::io::BufWriter::new(file);
|
||||
|
||||
// write chunk to file
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if cancel_token.is_cancelled() {
|
||||
if !should_resume {
|
||||
tokio::fs::remove_dir_all(&save_path.parent().unwrap())
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
log::info!("Download cancelled for task: {}", task_id);
|
||||
app.emit(&evt_name, evt.clone()).unwrap();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let chunk = chunk.map_err(err_to_string)?;
|
||||
writer.write_all(&chunk).await.map_err(err_to_string)?;
|
||||
download_delta += chunk.len() as u64;
|
||||
|
||||
// only update every 10 MB
|
||||
if download_delta >= 10 * 1024 * 1024 {
|
||||
evt.transferred += download_delta;
|
||||
app.emit(&evt_name, evt.clone()).unwrap();
|
||||
download_delta = 0u64;
|
||||
}
|
||||
}
|
||||
|
||||
writer.flush().await.map_err(err_to_string)?;
|
||||
evt.transferred += download_delta;
|
||||
|
||||
// rename tmp file to final file
|
||||
tokio::fs::rename(&tmp_save_path, &save_path)
|
||||
.await
|
||||
.map_err(err_to_string)?;
|
||||
tokio::fs::remove_file(&url_save_path)
|
||||
.await
|
||||
.map_err(err_to_string)?;
|
||||
log::info!("Finished downloading: {}", item.url);
|
||||
download_tasks.push(task);
|
||||
}
|
||||
|
||||
app.emit(&evt_name, evt.clone()).unwrap();
|
||||
// Wait for all downloads to complete
|
||||
let mut validation_tasks = Vec::new();
|
||||
for (task, item) in download_tasks.into_iter().zip(items.iter()) {
|
||||
let result = task.await.map_err(|e| format!("Task join error: {}", e))?;
|
||||
|
||||
match result {
|
||||
Ok(downloaded_path) => {
|
||||
// Spawn validation task in parallel
|
||||
let item_clone = item.clone();
|
||||
let app_clone = app.clone();
|
||||
let path_clone = downloaded_path.clone();
|
||||
let cancel_token_clone = cancel_token.clone();
|
||||
let validation_task = tokio::spawn(async move {
|
||||
validate_downloaded_file(&item_clone, &path_clone, &app_clone, &cancel_token_clone).await
|
||||
});
|
||||
validation_tasks.push((validation_task, downloaded_path, item.clone()));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all validations to complete
|
||||
for (validation_task, save_path, _item) in validation_tasks {
|
||||
let validation_result = validation_task
|
||||
.await
|
||||
.map_err(|e| format!("Validation task join error: {}", e))?;
|
||||
|
||||
if let Err(validation_error) = validation_result {
|
||||
// Clean up the file if validation fails
|
||||
let _ = tokio::fs::remove_file(&save_path).await;
|
||||
|
||||
// Try to clean up the parent directory if it's empty
|
||||
if let Some(parent) = save_path.parent() {
|
||||
let _ = tokio::fs::remove_dir(parent).await;
|
||||
}
|
||||
|
||||
return Err(validation_error);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit final progress
|
||||
let (transferred, total) = progress_tracker.get_total_progress().await;
|
||||
let final_evt = DownloadEvent { transferred, total };
|
||||
app.emit(&evt_name, final_evt).unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Downloads a single file without blocking other downloads
|
||||
async fn download_single_file(
|
||||
app: tauri::AppHandle,
|
||||
item: &DownloadItem,
|
||||
header_map: &HeaderMap,
|
||||
save_path: &std::path::Path,
|
||||
resume: bool,
|
||||
cancel_token: CancellationToken,
|
||||
evt_name: String,
|
||||
progress_tracker: ProgressTracker,
|
||||
file_id: String,
|
||||
_file_size: u64,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
// Create parent directories if they don't exist
|
||||
if let Some(parent) = save_path.parent() {
|
||||
if !parent.exists() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(err_to_string)?;
|
||||
}
|
||||
}
|
||||
|
||||
let current_extension = save_path.extension().unwrap_or_default().to_string_lossy();
|
||||
let append_extension = |ext: &str| {
|
||||
if current_extension.is_empty() {
|
||||
ext.to_string()
|
||||
} else {
|
||||
format!("{}.{}", current_extension, ext)
|
||||
}
|
||||
};
|
||||
let tmp_save_path = save_path.with_extension(append_extension("tmp"));
|
||||
let url_save_path = save_path.with_extension(append_extension("url"));
|
||||
|
||||
let mut should_resume = resume
|
||||
&& tmp_save_path.exists()
|
||||
&& tokio::fs::read_to_string(&url_save_path)
|
||||
.await
|
||||
.map(|url| url == item.url) // check if we resume the same URL
|
||||
.unwrap_or(false);
|
||||
|
||||
tokio::fs::write(&url_save_path, item.url.clone())
|
||||
.await
|
||||
.map_err(err_to_string)?;
|
||||
|
||||
log::info!("Started downloading: {}", item.url);
|
||||
let client = _get_client_for_item(item, &header_map).map_err(err_to_string)?;
|
||||
let mut download_delta = 0u64;
|
||||
let mut initial_progress = 0u64;
|
||||
|
||||
let resp = if should_resume {
|
||||
let downloaded_size = tmp_save_path.metadata().map_err(err_to_string)?.len();
|
||||
match _get_maybe_resume(&client, &item.url, downloaded_size).await {
|
||||
Ok(resp) => {
|
||||
log::info!(
|
||||
"Resume download: {}, already downloaded {} bytes",
|
||||
item.url,
|
||||
downloaded_size
|
||||
);
|
||||
initial_progress = downloaded_size;
|
||||
|
||||
// Initialize progress for resumed download
|
||||
progress_tracker
|
||||
.update_progress(&file_id, downloaded_size)
|
||||
.await;
|
||||
|
||||
// Emit initial combined progress
|
||||
let (combined_transferred, combined_total) =
|
||||
progress_tracker.get_total_progress().await;
|
||||
let evt = DownloadEvent {
|
||||
transferred: combined_transferred,
|
||||
total: combined_total,
|
||||
};
|
||||
app.emit(&evt_name, evt).unwrap();
|
||||
|
||||
resp
|
||||
}
|
||||
Err(e) => {
|
||||
// fallback to normal download
|
||||
log::warn!("Failed to resume download: {}", e);
|
||||
should_resume = false;
|
||||
_get_maybe_resume(&client, &item.url, 0).await?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_get_maybe_resume(&client, &item.url, 0).await?
|
||||
};
|
||||
let mut stream = resp.bytes_stream();
|
||||
|
||||
let file = if should_resume {
|
||||
// resume download, append to existing file
|
||||
tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(&tmp_save_path)
|
||||
.await
|
||||
.map_err(err_to_string)?
|
||||
} else {
|
||||
// start new download, create a new file
|
||||
File::create(&tmp_save_path).await.map_err(err_to_string)?
|
||||
};
|
||||
let mut writer = tokio::io::BufWriter::new(file);
|
||||
let mut total_transferred = initial_progress;
|
||||
|
||||
// write chunk to file
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if cancel_token.is_cancelled() {
|
||||
if !should_resume {
|
||||
tokio::fs::remove_dir_all(&save_path.parent().unwrap())
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
log::info!("Download cancelled: {}", item.url);
|
||||
return Err("Download cancelled".to_string());
|
||||
}
|
||||
|
||||
let chunk = chunk.map_err(err_to_string)?;
|
||||
writer.write_all(&chunk).await.map_err(err_to_string)?;
|
||||
download_delta += chunk.len() as u64;
|
||||
total_transferred += chunk.len() as u64;
|
||||
|
||||
// Update progress every 10 MB
|
||||
if download_delta >= 10 * 1024 * 1024 {
|
||||
// Update individual file progress
|
||||
progress_tracker
|
||||
.update_progress(&file_id, total_transferred)
|
||||
.await;
|
||||
|
||||
// Emit combined progress event
|
||||
let (combined_transferred, combined_total) =
|
||||
progress_tracker.get_total_progress().await;
|
||||
let evt = DownloadEvent {
|
||||
transferred: combined_transferred,
|
||||
total: combined_total,
|
||||
};
|
||||
app.emit(&evt_name, evt).unwrap();
|
||||
|
||||
download_delta = 0u64;
|
||||
}
|
||||
}
|
||||
|
||||
writer.flush().await.map_err(err_to_string)?;
|
||||
|
||||
// Final progress update for this file
|
||||
progress_tracker
|
||||
.update_progress(&file_id, total_transferred)
|
||||
.await;
|
||||
|
||||
// Emit final combined progress
|
||||
let (combined_transferred, combined_total) = progress_tracker.get_total_progress().await;
|
||||
let evt = DownloadEvent {
|
||||
transferred: combined_transferred,
|
||||
total: combined_total,
|
||||
};
|
||||
app.emit(&evt_name, evt).unwrap();
|
||||
|
||||
// rename tmp file to final file
|
||||
tokio::fs::rename(&tmp_save_path, &save_path)
|
||||
.await
|
||||
.map_err(err_to_string)?;
|
||||
tokio::fs::remove_file(&url_save_path)
|
||||
.await
|
||||
.map_err(err_to_string)?;
|
||||
|
||||
log::info!("Finished downloading: {}", item.url);
|
||||
Ok(save_path.to_path_buf())
|
||||
}
|
||||
|
||||
// ===== HTTP CLIENT HELPER FUNCTIONS =====
|
||||
|
||||
pub async fn _get_maybe_resume(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Default)]
|
||||
@ -20,6 +22,8 @@ pub struct DownloadItem {
|
||||
pub url: String,
|
||||
pub save_path: String,
|
||||
pub proxy: Option<ProxyConfig>,
|
||||
pub sha256: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone, Debug)]
|
||||
@ -27,3 +31,31 @@ pub struct DownloadEvent {
|
||||
pub transferred: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// Structure to track progress for each file in parallel downloads
|
||||
#[derive(Clone)]
|
||||
pub struct ProgressTracker {
|
||||
file_progress: Arc<Mutex<HashMap<String, u64>>>,
|
||||
total_size: u64,
|
||||
}
|
||||
|
||||
impl ProgressTracker {
|
||||
pub fn new(_items: &[DownloadItem], sizes: HashMap<String, u64>) -> Self {
|
||||
let total_size = sizes.values().sum();
|
||||
ProgressTracker {
|
||||
file_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||
total_size,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_progress(&self, file_id: &str, transferred: u64) {
|
||||
let mut progress = self.file_progress.lock().await;
|
||||
progress.insert(file_id.to_string(), transferred);
|
||||
}
|
||||
|
||||
pub async fn get_total_progress(&self) -> (u64, u64) {
|
||||
let progress = self.file_progress.lock().await;
|
||||
let total_transferred: u64 = progress.values().sum();
|
||||
(total_transferred, self.total_size)
|
||||
}
|
||||
}
|
||||
|
||||
@ -194,6 +194,8 @@ fn test_download_item_with_ssl_proxy() {
|
||||
url: "https://example.com/file.zip".to_string(),
|
||||
save_path: "downloads/file.zip".to_string(),
|
||||
proxy: Some(proxy_config),
|
||||
sha256: None,
|
||||
size: None,
|
||||
};
|
||||
|
||||
assert!(download_item.proxy.is_some());
|
||||
@ -211,6 +213,8 @@ fn test_client_creation_with_ssl_settings() {
|
||||
url: "https://example.com/file.zip".to_string(),
|
||||
save_path: "downloads/file.zip".to_string(),
|
||||
proxy: Some(proxy_config),
|
||||
sha256: None,
|
||||
size: None,
|
||||
};
|
||||
|
||||
let header_map = HeaderMap::new();
|
||||
@ -256,6 +260,8 @@ fn test_download_item_creation() {
|
||||
url: "https://example.com/file.tar.gz".to_string(),
|
||||
save_path: "models/test.tar.gz".to_string(),
|
||||
proxy: None,
|
||||
sha256: None,
|
||||
size: None,
|
||||
};
|
||||
|
||||
assert_eq!(item.url, "https://example.com/file.tar.gz");
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
use rmcp::model::{CallToolRequestParam, CallToolResult, Tool};
|
||||
use rmcp::{service::RunningService, RoleClient};
|
||||
use rmcp::model::{CallToolRequestParam, CallToolResult};
|
||||
use serde_json::{Map, Value};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tauri::{AppHandle, Emitter, Runtime, State};
|
||||
use tokio::{sync::Mutex, time::timeout};
|
||||
use tokio::time::timeout;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::{
|
||||
constants::{DEFAULT_MCP_CONFIG, MCP_TOOL_CALL_TIMEOUT},
|
||||
helpers::{restart_active_mcp_servers, start_mcp_server_with_restart, stop_mcp_servers},
|
||||
};
|
||||
use crate::core::{app::commands::get_jan_data_folder_path, state::AppState};
|
||||
use crate::core::{
|
||||
mcp::models::ToolWithServer,
|
||||
state::{RunningServiceEnum, SharedMcpServers},
|
||||
};
|
||||
use std::fs;
|
||||
|
||||
#[tauri::command]
|
||||
@ -19,8 +22,7 @@ pub async fn activate_mcp_server<R: Runtime>(
|
||||
name: String,
|
||||
config: Value,
|
||||
) -> Result<(), String> {
|
||||
let servers: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>> =
|
||||
state.mcp_servers.clone();
|
||||
let servers: SharedMcpServers = state.mcp_servers.clone();
|
||||
|
||||
// Use the modified start_mcp_server_with_restart that returns first attempt result
|
||||
start_mcp_server_with_restart(app, servers, name, config, Some(3)).await
|
||||
@ -63,7 +65,16 @@ pub async fn deactivate_mcp_server(state: State<'_, AppState>, name: String) ->
|
||||
// Release the lock before calling cancel
|
||||
drop(servers_map);
|
||||
|
||||
service.cancel().await.map_err(|e| e.to_string())?;
|
||||
match service {
|
||||
RunningServiceEnum::NoInit(service) => {
|
||||
log::info!("Stopping server {name}...");
|
||||
service.cancel().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
RunningServiceEnum::WithInit(service) => {
|
||||
log::info!("Stopping server {name} with initialization...");
|
||||
service.cancel().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
log::info!("Server {name} stopped successfully and marked as deactivated.");
|
||||
Ok(())
|
||||
}
|
||||
@ -116,7 +127,7 @@ pub async fn get_connected_servers(
|
||||
Ok(servers_map.keys().cloned().collect())
|
||||
}
|
||||
|
||||
/// Retrieves all available tools from all MCP servers
|
||||
/// Retrieves all available tools from all MCP servers with server information
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `state` - Application state containing MCP server connections
|
||||
@ -128,14 +139,15 @@ pub async fn get_connected_servers(
|
||||
/// 1. Locks the MCP servers mutex to access server connections
|
||||
/// 2. Iterates through all connected servers
|
||||
/// 3. Gets the list of tools from each server
|
||||
/// 4. Combines all tools into a single vector
|
||||
/// 5. Returns the combined list of all available tools
|
||||
/// 4. Associates each tool with its parent server name
|
||||
/// 5. Combines all tools into a single vector
|
||||
/// 6. Returns the combined list of all available tools with server information
|
||||
#[tauri::command]
|
||||
pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<Tool>, String> {
|
||||
pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<ToolWithServer>, String> {
|
||||
let servers = state.mcp_servers.lock().await;
|
||||
let mut all_tools: Vec<Tool> = Vec::new();
|
||||
let mut all_tools: Vec<ToolWithServer> = Vec::new();
|
||||
|
||||
for (_, service) in servers.iter() {
|
||||
for (server_name, service) in servers.iter() {
|
||||
// List tools with timeout
|
||||
let tools_future = service.list_all_tools();
|
||||
let tools = match timeout(MCP_TOOL_CALL_TIMEOUT, tools_future).await {
|
||||
@ -150,7 +162,12 @@ pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<Tool>, String>
|
||||
};
|
||||
|
||||
for tool in tools {
|
||||
all_tools.push(tool);
|
||||
all_tools.push(ToolWithServer {
|
||||
name: tool.name.to_string(),
|
||||
description: tool.description.as_ref().map(|d| d.to_string()),
|
||||
input_schema: serde_json::Value::Object((*tool.input_schema).clone()),
|
||||
server: server_name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,6 +180,7 @@ pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<Tool>, String>
|
||||
/// * `state` - Application state containing MCP server connections
|
||||
/// * `tool_name` - Name of the tool to call
|
||||
/// * `arguments` - Optional map of argument names to values
|
||||
/// * `cancellation_token` - Optional token to allow cancellation from JS side
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<CallToolResult, String>` - Result of the tool call if successful, or error message if failed
|
||||
@ -171,13 +189,23 @@ pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<Tool>, String>
|
||||
/// 1. Locks the MCP servers mutex to access server connections
|
||||
/// 2. Searches through all servers for one containing the named tool
|
||||
/// 3. When found, calls the tool on that server with the provided arguments
|
||||
/// 4. Returns error if no server has the requested tool
|
||||
/// 4. Supports cancellation via cancellation_token
|
||||
/// 5. Returns error if no server has the requested tool
|
||||
#[tauri::command]
|
||||
pub async fn call_tool(
|
||||
state: State<'_, AppState>,
|
||||
tool_name: String,
|
||||
arguments: Option<Map<String, Value>>,
|
||||
cancellation_token: Option<String>,
|
||||
) -> Result<CallToolResult, String> {
|
||||
// Set up cancellation if token is provided
|
||||
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
|
||||
|
||||
if let Some(token) = &cancellation_token {
|
||||
let mut cancellations = state.tool_call_cancellations.lock().await;
|
||||
cancellations.insert(token.clone(), cancel_tx);
|
||||
}
|
||||
|
||||
let servers = state.mcp_servers.lock().await;
|
||||
|
||||
// Iterate through servers and find the first one that contains the tool
|
||||
@ -193,25 +221,77 @@ pub async fn call_tool(
|
||||
|
||||
println!("Found tool {} in server", tool_name);
|
||||
|
||||
// Call the tool with timeout
|
||||
// Call the tool with timeout and cancellation support
|
||||
let tool_call = service.call_tool(CallToolRequestParam {
|
||||
name: tool_name.clone().into(),
|
||||
arguments,
|
||||
});
|
||||
|
||||
return match timeout(MCP_TOOL_CALL_TIMEOUT, tool_call).await {
|
||||
Ok(result) => result.map_err(|e| e.to_string()),
|
||||
Err(_) => Err(format!(
|
||||
"Tool call '{}' timed out after {} seconds",
|
||||
tool_name,
|
||||
MCP_TOOL_CALL_TIMEOUT.as_secs()
|
||||
)),
|
||||
// Race between timeout, tool call, and cancellation
|
||||
let result = if cancellation_token.is_some() {
|
||||
tokio::select! {
|
||||
result = timeout(MCP_TOOL_CALL_TIMEOUT, tool_call) => {
|
||||
match result {
|
||||
Ok(call_result) => call_result.map_err(|e| e.to_string()),
|
||||
Err(_) => Err(format!(
|
||||
"Tool call '{}' timed out after {} seconds",
|
||||
tool_name,
|
||||
MCP_TOOL_CALL_TIMEOUT.as_secs()
|
||||
)),
|
||||
}
|
||||
}
|
||||
_ = cancel_rx => {
|
||||
Err(format!("Tool call '{}' was cancelled", tool_name))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match timeout(MCP_TOOL_CALL_TIMEOUT, tool_call).await {
|
||||
Ok(call_result) => call_result.map_err(|e| e.to_string()),
|
||||
Err(_) => Err(format!(
|
||||
"Tool call '{}' timed out after {} seconds",
|
||||
tool_name,
|
||||
MCP_TOOL_CALL_TIMEOUT.as_secs()
|
||||
)),
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up cancellation token
|
||||
if let Some(token) = &cancellation_token {
|
||||
let mut cancellations = state.tool_call_cancellations.lock().await;
|
||||
cancellations.remove(token);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Err(format!("Tool {} not found", tool_name))
|
||||
}
|
||||
|
||||
/// Cancels a running tool call by its cancellation token
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `state` - Application state containing cancellation tokens
|
||||
/// * `cancellation_token` - Token identifying the tool call to cancel
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<(), String>` - Success if token found and cancelled, error otherwise
|
||||
#[tauri::command]
|
||||
pub async fn cancel_tool_call(
|
||||
state: State<'_, AppState>,
|
||||
cancellation_token: String,
|
||||
) -> Result<(), String> {
|
||||
let mut cancellations = state.tool_call_cancellations.lock().await;
|
||||
|
||||
if let Some(cancel_tx) = cancellations.remove(&cancellation_token) {
|
||||
// Send cancellation signal - ignore if receiver is already dropped
|
||||
let _ = cancel_tx.send(());
|
||||
println!("Tool call with token {} cancelled", cancellation_token);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Cancellation token {} not found", cancellation_token))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_mcp_configs(app: AppHandle) -> Result<String, String> {
|
||||
let mut path = get_jan_data_folder_path(app);
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
use rmcp::{service::RunningService, transport::TokioChildProcess, RoleClient, ServiceExt};
|
||||
use rmcp::{
|
||||
model::{ClientCapabilities, ClientInfo, Implementation},
|
||||
transport::{
|
||||
streamable_http_client::StreamableHttpClientTransportConfig, SseClientTransport,
|
||||
StreamableHttpClientTransport, TokioChildProcess,
|
||||
},
|
||||
ServiceExt,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, env, sync::Arc, time::Duration};
|
||||
use std::{collections::HashMap, env, process::Stdio, sync::Arc, time::Duration};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, State};
|
||||
use tauri_plugin_http::reqwest;
|
||||
use tokio::{
|
||||
io::AsyncReadExt,
|
||||
process::Command,
|
||||
sync::Mutex,
|
||||
time::{sleep, timeout},
|
||||
@ -11,7 +20,11 @@ use tokio::{
|
||||
use super::constants::{
|
||||
MCP_BACKOFF_MULTIPLIER, MCP_BASE_RESTART_DELAY_MS, MCP_MAX_RESTART_DELAY_MS,
|
||||
};
|
||||
use crate::core::{app::commands::get_jan_data_folder_path, state::AppState};
|
||||
use crate::core::{
|
||||
app::commands::get_jan_data_folder_path,
|
||||
mcp::models::McpServerConfig,
|
||||
state::{AppState, RunningServiceEnum, SharedMcpServers},
|
||||
};
|
||||
use jan_utils::can_override_npx;
|
||||
|
||||
/// Calculate exponential backoff delay with jitter
|
||||
@ -72,7 +85,7 @@ pub fn calculate_exponential_backoff_delay(attempt: u32) -> u64 {
|
||||
/// * `Err(String)` if there was an error reading config or starting servers
|
||||
pub async fn run_mcp_commands<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
||||
servers_state: SharedMcpServers,
|
||||
) -> Result<(), String> {
|
||||
let app_path = get_jan_data_folder_path(app.clone());
|
||||
let app_path_str = app_path.to_str().unwrap().to_string();
|
||||
@ -168,7 +181,7 @@ pub async fn run_mcp_commands<R: Runtime>(
|
||||
|
||||
/// Monitor MCP server health without removing it from the HashMap
|
||||
pub async fn monitor_mcp_server_handle(
|
||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
||||
servers_state: SharedMcpServers,
|
||||
name: String,
|
||||
) -> Option<rmcp::service::QuitReason> {
|
||||
log::info!("Monitoring MCP server {} health", name);
|
||||
@ -213,7 +226,16 @@ pub async fn monitor_mcp_server_handle(
|
||||
let mut servers = servers_state.lock().await;
|
||||
if let Some(service) = servers.remove(&name) {
|
||||
// Try to cancel the service gracefully
|
||||
let _ = service.cancel().await;
|
||||
match service {
|
||||
RunningServiceEnum::NoInit(service) => {
|
||||
log::info!("Stopping server {name}...");
|
||||
let _ = service.cancel().await;
|
||||
}
|
||||
RunningServiceEnum::WithInit(service) => {
|
||||
log::info!("Stopping server {name} with initialization...");
|
||||
let _ = service.cancel().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some(rmcp::service::QuitReason::Closed);
|
||||
}
|
||||
@ -224,7 +246,7 @@ pub async fn monitor_mcp_server_handle(
|
||||
/// Returns the result of the first start attempt, then continues with restart monitoring
|
||||
pub async fn start_mcp_server_with_restart<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
||||
servers_state: SharedMcpServers,
|
||||
name: String,
|
||||
config: Value,
|
||||
max_restarts: Option<u32>,
|
||||
@ -297,7 +319,7 @@ pub async fn start_mcp_server_with_restart<R: Runtime>(
|
||||
/// Helper function to handle the restart loop logic
|
||||
pub async fn start_restart_loop<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
||||
servers_state: SharedMcpServers,
|
||||
name: String,
|
||||
config: Value,
|
||||
max_restarts: u32,
|
||||
@ -450,9 +472,9 @@ pub async fn start_restart_loop<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn schedule_mcp_start_task<R: Runtime>(
|
||||
async fn schedule_mcp_start_task<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
servers: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
||||
servers: SharedMcpServers,
|
||||
name: String,
|
||||
config: Value,
|
||||
) -> Result<(), String> {
|
||||
@ -463,136 +485,279 @@ pub async fn schedule_mcp_start_task<R: Runtime>(
|
||||
.expect("Executable must have a parent directory");
|
||||
let bin_path = exe_parent_path.to_path_buf();
|
||||
|
||||
let (command, args, envs) = extract_command_args(&config)
|
||||
let config_params = extract_command_args(&config)
|
||||
.ok_or_else(|| format!("Failed to extract command args from config for {name}"))?;
|
||||
|
||||
let mut cmd = Command::new(command.clone());
|
||||
if config_params.transport_type.as_deref() == Some("http") && config_params.url.is_some() {
|
||||
let transport = StreamableHttpClientTransport::with_client(
|
||||
reqwest::Client::builder()
|
||||
.default_headers({
|
||||
// Map envs to request headers
|
||||
let mut headers: tauri::http::HeaderMap = reqwest::header::HeaderMap::new();
|
||||
for (key, value) in config_params.headers.iter() {
|
||||
if let Some(v_str) = value.as_str() {
|
||||
// Try to map env keys to HTTP header names (case-insensitive)
|
||||
// Most HTTP headers are Title-Case, so we try to convert
|
||||
let header_name =
|
||||
reqwest::header::HeaderName::from_bytes(key.as_bytes());
|
||||
if let Ok(header_name) = header_name {
|
||||
if let Ok(header_value) =
|
||||
reqwest::header::HeaderValue::from_str(v_str)
|
||||
{
|
||||
headers.insert(header_name, header_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
headers
|
||||
})
|
||||
.connect_timeout(config_params.timeout.unwrap_or(Duration::MAX))
|
||||
.build()
|
||||
.unwrap(),
|
||||
StreamableHttpClientTransportConfig {
|
||||
uri: config_params.url.unwrap().into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
if command == "npx" && can_override_npx() {
|
||||
let mut cache_dir = app_path.clone();
|
||||
cache_dir.push(".npx");
|
||||
let bun_x_path = format!("{}/bun", bin_path.display());
|
||||
cmd = Command::new(bun_x_path);
|
||||
cmd.arg("x");
|
||||
cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string());
|
||||
}
|
||||
let client_info = ClientInfo {
|
||||
protocol_version: Default::default(),
|
||||
capabilities: ClientCapabilities::default(),
|
||||
client_info: Implementation {
|
||||
name: "Jan Streamable Client".to_string(),
|
||||
version: "0.0.1".to_string(),
|
||||
},
|
||||
};
|
||||
let client = client_info.serve(transport).await.inspect_err(|e| {
|
||||
log::error!("client error: {:?}", e);
|
||||
});
|
||||
|
||||
if command == "uvx" {
|
||||
let mut cache_dir = app_path.clone();
|
||||
cache_dir.push(".uvx");
|
||||
let bun_x_path = format!("{}/uv", bin_path.display());
|
||||
cmd = Command::new(bun_x_path);
|
||||
cmd.arg("tool");
|
||||
cmd.arg("run");
|
||||
cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap().to_string());
|
||||
}
|
||||
match client {
|
||||
Ok(client) => {
|
||||
log::info!("Connected to server: {:?}", client.peer_info());
|
||||
servers
|
||||
.lock()
|
||||
.await
|
||||
.insert(name.clone(), RunningServiceEnum::WithInit(client));
|
||||
|
||||
#[cfg(windows)]
|
||||
// Mark server as successfully connected (for restart policy)
|
||||
{
|
||||
let app_state = app.state::<AppState>();
|
||||
let mut connected = app_state.mcp_successfully_connected.lock().await;
|
||||
connected.insert(name.clone(), true);
|
||||
log::info!("Marked MCP server {} as successfully connected", name);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to connect to server: {}", e);
|
||||
return Err(format!("Failed to connect to server: {}", e));
|
||||
}
|
||||
}
|
||||
} else if config_params.transport_type.as_deref() == Some("sse") && config_params.url.is_some()
|
||||
{
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW: prevents shell window on Windows
|
||||
}
|
||||
|
||||
let app_path_str = app_path.to_str().unwrap().to_string();
|
||||
let log_file_path = format!("{}/logs/app.log", app_path_str);
|
||||
match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log_file_path)
|
||||
{
|
||||
Ok(file) => {
|
||||
cmd.stderr(std::process::Stdio::from(file));
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to open log file: {}", err);
|
||||
}
|
||||
};
|
||||
|
||||
cmd.kill_on_drop(true);
|
||||
log::trace!("Command: {cmd:#?}");
|
||||
|
||||
args.iter().filter_map(Value::as_str).for_each(|arg| {
|
||||
cmd.arg(arg);
|
||||
});
|
||||
envs.iter().for_each(|(k, v)| {
|
||||
if let Some(v_str) = v.as_str() {
|
||||
cmd.env(k, v_str);
|
||||
}
|
||||
});
|
||||
|
||||
let process = TokioChildProcess::new(cmd).map_err(|e| {
|
||||
log::error!("Failed to run command {name}: {e}");
|
||||
format!("Failed to run command {name}: {e}")
|
||||
})?;
|
||||
|
||||
let service = ()
|
||||
.serve(process)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start MCP server {name}: {e}"))?;
|
||||
|
||||
// Get peer info and clone the needed values before moving the service
|
||||
let (server_name, server_version) = {
|
||||
let server_info = service.peer_info();
|
||||
log::trace!("Connected to server: {server_info:#?}");
|
||||
(
|
||||
server_info.unwrap().server_info.name.clone(),
|
||||
server_info.unwrap().server_info.version.clone(),
|
||||
let transport = SseClientTransport::start_with_client(
|
||||
reqwest::Client::builder()
|
||||
.default_headers({
|
||||
// Map envs to request headers
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
for (key, value) in config_params.headers.iter() {
|
||||
if let Some(v_str) = value.as_str() {
|
||||
// Try to map env keys to HTTP header names (case-insensitive)
|
||||
// Most HTTP headers are Title-Case, so we try to convert
|
||||
let header_name =
|
||||
reqwest::header::HeaderName::from_bytes(key.as_bytes());
|
||||
if let Ok(header_name) = header_name {
|
||||
if let Ok(header_value) =
|
||||
reqwest::header::HeaderValue::from_str(v_str)
|
||||
{
|
||||
headers.insert(header_name, header_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
headers
|
||||
})
|
||||
.connect_timeout(config_params.timeout.unwrap_or(Duration::MAX))
|
||||
.build()
|
||||
.unwrap(),
|
||||
rmcp::transport::sse_client::SseClientConfig {
|
||||
sse_endpoint: config_params.url.unwrap().into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
};
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("transport error: {:?}", e);
|
||||
format!("Failed to start SSE transport: {}", e)
|
||||
})?;
|
||||
|
||||
// Now move the service into the HashMap
|
||||
servers.lock().await.insert(name.clone(), service);
|
||||
log::info!("Server {name} started successfully.");
|
||||
let client_info = ClientInfo {
|
||||
protocol_version: Default::default(),
|
||||
capabilities: ClientCapabilities::default(),
|
||||
client_info: Implementation {
|
||||
name: "Jan SSE Client".to_string(),
|
||||
version: "0.0.1".to_string(),
|
||||
},
|
||||
};
|
||||
let client = client_info.serve(transport).await.map_err(|e| {
|
||||
log::error!("client error: {:?}", e);
|
||||
e.to_string()
|
||||
});
|
||||
|
||||
// Wait a short time to verify the server is stable before marking as connected
|
||||
// This prevents race conditions where the server quits immediately
|
||||
let verification_delay = Duration::from_millis(500);
|
||||
sleep(verification_delay).await;
|
||||
match client {
|
||||
Ok(client) => {
|
||||
log::info!("Connected to server: {:?}", client.peer_info());
|
||||
servers
|
||||
.lock()
|
||||
.await
|
||||
.insert(name.clone(), RunningServiceEnum::WithInit(client));
|
||||
|
||||
// Check if server is still running after the verification delay
|
||||
let server_still_running = {
|
||||
let servers_map = servers.lock().await;
|
||||
servers_map.contains_key(&name)
|
||||
};
|
||||
// Mark server as successfully connected (for restart policy)
|
||||
{
|
||||
let app_state = app.state::<AppState>();
|
||||
let mut connected = app_state.mcp_successfully_connected.lock().await;
|
||||
connected.insert(name.clone(), true);
|
||||
log::info!("Marked MCP server {} as successfully connected", name);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to connect to server: {}", e);
|
||||
return Err(format!("Failed to connect to server: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut cmd = Command::new(config_params.command.clone());
|
||||
if config_params.command.clone() == "npx" && can_override_npx() {
|
||||
let mut cache_dir = app_path.clone();
|
||||
cache_dir.push(".npx");
|
||||
let bun_x_path = format!("{}/bun", bin_path.display());
|
||||
cmd = Command::new(bun_x_path);
|
||||
cmd.arg("x");
|
||||
cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string());
|
||||
}
|
||||
if config_params.command.clone() == "uvx" {
|
||||
let mut cache_dir = app_path.clone();
|
||||
cache_dir.push(".uvx");
|
||||
let bun_x_path = format!("{}/uv", bin_path.display());
|
||||
cmd = Command::new(bun_x_path);
|
||||
cmd.arg("tool");
|
||||
cmd.arg("run");
|
||||
cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap().to_string());
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW: prevents shell window on Windows
|
||||
}
|
||||
|
||||
if !server_still_running {
|
||||
return Err(format!(
|
||||
"MCP server {} quit immediately after starting",
|
||||
name
|
||||
));
|
||||
cmd.kill_on_drop(true);
|
||||
|
||||
config_params
|
||||
.args
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.for_each(|arg| {
|
||||
cmd.arg(arg);
|
||||
});
|
||||
config_params.envs.iter().for_each(|(k, v)| {
|
||||
if let Some(v_str) = v.as_str() {
|
||||
cmd.env(k, v_str);
|
||||
}
|
||||
});
|
||||
|
||||
let (process, stderr) = TokioChildProcess::builder(cmd)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to run command {name}: {e}");
|
||||
format!("Failed to run command {name}: {e}")
|
||||
})?;
|
||||
|
||||
let service = ()
|
||||
.serve(process)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start MCP server {name}: {e}"));
|
||||
|
||||
match service {
|
||||
Ok(server) => {
|
||||
log::trace!("Connected to server: {:#?}", server.peer_info());
|
||||
servers
|
||||
.lock()
|
||||
.await
|
||||
.insert(name.clone(), RunningServiceEnum::NoInit(server));
|
||||
log::info!("Server {name} started successfully.");
|
||||
}
|
||||
Err(_) => {
|
||||
let mut buffer = String::new();
|
||||
let error = match stderr
|
||||
.expect("stderr must be piped")
|
||||
.read_to_string(&mut buffer)
|
||||
.await
|
||||
{
|
||||
Ok(_) => format!("Failed to start MCP server {name}: {buffer}"),
|
||||
Err(_) => format!("Failed to read MCP server {name} stderr"),
|
||||
};
|
||||
log::error!("{error}");
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a short time to verify the server is stable before marking as connected
|
||||
// This prevents race conditions where the server quits immediately
|
||||
let verification_delay = Duration::from_millis(500);
|
||||
sleep(verification_delay).await;
|
||||
|
||||
// Check if server is still running after the verification delay
|
||||
let server_still_running = {
|
||||
let servers_map = servers.lock().await;
|
||||
servers_map.contains_key(&name)
|
||||
};
|
||||
|
||||
if !server_still_running {
|
||||
return Err(format!(
|
||||
"MCP server {} quit immediately after starting",
|
||||
name
|
||||
));
|
||||
}
|
||||
// Mark server as successfully connected (for restart policy)
|
||||
{
|
||||
let app_state = app.state::<AppState>();
|
||||
let mut connected = app_state.mcp_successfully_connected.lock().await;
|
||||
connected.insert(name.clone(), true);
|
||||
log::info!("Marked MCP server {} as successfully connected", name);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark server as successfully connected (for restart policy)
|
||||
{
|
||||
let app_state = app.state::<AppState>();
|
||||
let mut connected = app_state.mcp_successfully_connected.lock().await;
|
||||
connected.insert(name.clone(), true);
|
||||
log::info!("Marked MCP server {} as successfully connected", name);
|
||||
}
|
||||
|
||||
// Emit event to the frontend
|
||||
let event = format!("mcp-connected");
|
||||
let payload = serde_json::json!({
|
||||
"name": server_name,
|
||||
"version": server_version,
|
||||
});
|
||||
app.emit(&event, payload)
|
||||
.map_err(|e| format!("Failed to emit event: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn extract_command_args(
|
||||
config: &Value,
|
||||
) -> Option<(String, Vec<Value>, serde_json::Map<String, Value>)> {
|
||||
pub fn extract_command_args(config: &Value) -> Option<McpServerConfig> {
|
||||
let obj = config.as_object()?;
|
||||
let command = obj.get("command")?.as_str()?.to_string();
|
||||
let args = obj.get("args")?.as_array()?.clone();
|
||||
let url = obj.get("url").and_then(|u| u.as_str()).map(String::from);
|
||||
let transport_type = obj.get("type").and_then(|t| t.as_str()).map(String::from);
|
||||
let timeout = obj
|
||||
.get("timeout")
|
||||
.and_then(|t| t.as_u64())
|
||||
.map(Duration::from_secs);
|
||||
let headers = obj
|
||||
.get("headers")
|
||||
.unwrap_or(&Value::Object(serde_json::Map::new()))
|
||||
.as_object()?
|
||||
.clone();
|
||||
let envs = obj
|
||||
.get("env")
|
||||
.unwrap_or(&Value::Object(serde_json::Map::new()))
|
||||
.as_object()?
|
||||
.clone();
|
||||
Some((command, args, envs))
|
||||
Some(McpServerConfig {
|
||||
timeout,
|
||||
transport_type,
|
||||
url,
|
||||
command,
|
||||
args,
|
||||
envs,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_active_status(config: &Value) -> Option<bool> {
|
||||
@ -604,7 +769,7 @@ pub fn extract_active_status(config: &Value) -> Option<bool> {
|
||||
/// Restart only servers that were previously active (like cortex restart behavior)
|
||||
pub async fn restart_active_mcp_servers<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
||||
servers_state: SharedMcpServers,
|
||||
) -> Result<(), String> {
|
||||
let app_state = app.state::<AppState>();
|
||||
let active_servers = app_state.mcp_active_servers.lock().await;
|
||||
@ -656,14 +821,21 @@ pub async fn clean_up_mcp_servers(state: State<'_, AppState>) {
|
||||
log::info!("MCP servers cleaned up successfully");
|
||||
}
|
||||
|
||||
pub async fn stop_mcp_servers(
|
||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn stop_mcp_servers(servers_state: SharedMcpServers) -> Result<(), String> {
|
||||
let mut servers_map = servers_state.lock().await;
|
||||
let keys: Vec<String> = servers_map.keys().cloned().collect();
|
||||
for key in keys {
|
||||
if let Some(service) = servers_map.remove(&key) {
|
||||
service.cancel().await.map_err(|e| e.to_string())?;
|
||||
match service {
|
||||
RunningServiceEnum::NoInit(service) => {
|
||||
log::info!("Stopping server {key}...");
|
||||
service.cancel().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
RunningServiceEnum::WithInit(service) => {
|
||||
log::info!("Stopping server {key} with initialization...");
|
||||
service.cancel().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(servers_map); // Release the lock after stopping
|
||||
@ -689,7 +861,7 @@ pub async fn reset_restart_count(restart_counts: &Arc<Mutex<HashMap<String, u32>
|
||||
/// Spawn the server monitoring task for handling restarts
|
||||
pub async fn spawn_server_monitoring_task<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
||||
servers_state: SharedMcpServers,
|
||||
name: String,
|
||||
config: Value,
|
||||
max_restarts: u32,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
pub mod commands;
|
||||
mod constants;
|
||||
pub mod helpers;
|
||||
pub mod models;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
26
src-tauri/src/core/mcp/models.rs
Normal file
@ -0,0 +1,26 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Configuration parameters extracted from MCP server config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpServerConfig {
|
||||
pub transport_type: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub command: String,
|
||||
pub args: Vec<Value>,
|
||||
pub envs: serde_json::Map<String, Value>,
|
||||
pub timeout: Option<Duration>,
|
||||
pub headers: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// Tool with server information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolWithServer {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "inputSchema")]
|
||||
pub input_schema: serde_json::Value,
|
||||
pub server: String,
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
use super::helpers::run_mcp_commands;
|
||||
use crate::core::app::commands::get_jan_data_folder_path;
|
||||
use rmcp::{service::RunningService, RoleClient};
|
||||
use crate::core::state::SharedMcpServers;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
@ -27,7 +27,7 @@ async fn test_run_mcp_commands() {
|
||||
.expect("Failed to write to config file");
|
||||
|
||||
// Call the run_mcp_commands function
|
||||
let servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>> =
|
||||
let servers_state: SharedMcpServers =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
let result = run_mcp_commands(app.handle(), servers_state).await;
|
||||
|
||||
|
||||