Merge branch 'dev' into fix/og-image-date-update

This commit is contained in:
Emre Can Kartal 2025-08-22 15:41:01 +03:00 committed by GitHub
commit 82eb18bc00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
447 changed files with 12487 additions and 23686 deletions

View File

@ -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
View 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
View 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 }}"

View File

@ -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

View File

@ -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! 🚀

View File

@ -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
View 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

View File

@ -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>
}

View File

@ -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',

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

View File

@ -11,11 +11,6 @@
"type": "page",
"title": "Documentation"
},
"cortex": {
"type": "page",
"title": "Cortex",
"display": "hidden"
},
"platforms": {
"type": "page",
"title": "Platforms",

View 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, Janv1), 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
- Janv1 (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; permodel 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 autoupdater 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
- gptoss “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).

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

View File

@ -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",

View File

@ -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 Cover Image](./_assets/jan-app-new.png)
## 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>

View 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:
![Jan-v1 SimpleQA Performance](../_assets/simpleqa_jan_v1.png)
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:
![Jan-v1 Creativity Benchmarks](../_assets/creative_bench_jan_v1.png)
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
![Jan-v1 Demo](../_assets/jan_v1_demo.gif)
### 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)

View 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:
![Lucy SimpleQA Performance](../_assets/simpleqa_lucy.png)
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
![Lucy Demo](../_assets/lucy_demo.gif)
### 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},
}
```

View 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"
}
}

View File

@ -0,0 +1,6 @@
{
"browserbase": {
"title": "Browserbase",
"href": "/docs/mcp-examples/browser/browserbase"
}
}

View 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"
}
}

View File

@ -0,0 +1,6 @@
{
"octagon": {
"title": "Octagon Deep Research",
"href": "/docs/mcp-examples/deepresearch/octagon"
}
}

View File

@ -0,0 +1,6 @@
{
"canva": {
"title": "Canva",
"href": "/docs/mcp-examples/design/canva"
}
}

View File

@ -0,0 +1,10 @@
{
"todoist": {
"title": "Todoist",
"href": "/docs/mcp-examples/productivity/todoist"
},
"linear": {
"title": "Linear",
"href": "/docs/mcp-examples/productivity/linear"
}
}

View 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
![Linear signup page](../../_assets/linear1.png)
Once logged in, you'll see your workspace:
![Linear main dashboard](../../_assets/linear2.png)
### 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`
![Linear MCP configuration in Jan](../../_assets/linear3.png)
### Authenticate with Linear
When you first use Linear tools, a browser tab will open for authentication:
![Linear authentication page](../../_assets/linear4.png)
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`
![Adding kimi-k2 model](../../_assets/linear6.png)
2. Enable tools for the model:
![Enable tools for kimi-k2](../../_assets/linear7.png)
### Verify Available Tools
You should see all Linear tools in the chat interface:
![Linear tools available in chat](../../_assets/linear8.png)
### Epic Project Management
Watch AI transform mundane tasks into epic narratives:
![Linear MCP creating Shakespearean war epic tasks](../../_assets/mcplinear2.gif)
## 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!

View 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
![Todoist welcome screen](../../_assets/todoist1.png)
Once logged in, you'll see your main dashboard:
![Todoist main dashboard](../../_assets/todoist2.png)
### 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)
![Todoist API token in settings](../../_assets/todoist3.png)
### 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`
![Todoist MCP configuration in Jan](../../_assets/todoist4.png)
## 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.
![Model selection with tools enabled](../../_assets/gpt5-add.png)
### Verify Tools Available
You should see the Todoist tools in the tools panel:
![Todoist tools available in chat](../../_assets/todoist5.png)
### Start Managing Tasks
Now you can manage your todo list through natural conversation:
![Todoist MCP in action](../../_assets/mcptodoist_extreme.gif)
## 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!

View 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"
}
}

View 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 experimental features](../../_assets/enable_mcp.png)
### Enable MCP
1. Go to **Settings** > **MCP Servers**
2. Toggle **Allow All MCP Tool Permission** ON
![Turn on MCP](../../_assets/turn_on_mcp.png)
### 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
![Serper homepage](../../_assets/serper_page.png)
![Serper playground with API key](../../_assets/serper_playground.png)
### 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`
![Serper MCP configuration in Jan](../../_assets/serper_janparams.png)
### 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**
![Download Jan v1 from Hub](../../_assets/download_janv1.png)
### 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
![Enable tools for Jan v1](../../_assets/toggle_tools.png)
## Usage
### Start a New Chat
With Jan v1 selected, you'll see the available Serper tools:
![Chat view with Serper tools](../../_assets/chat_jan_v1.png)
### 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. 😌
```
![Jan v1 using Serper for web search](../../_assets/jan_v1_serper.png)
![Jan v1 using Serper for web search](../../_assets/jan_v1_serper1.png)
**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.

View 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**
![Download Jan v1](./_assets/download_janv1.png)
<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**.
![Add HF Token](./_assets/hf_token.png)
### 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
![Turn on GPU acceleration](./_assets/gpu_accl.png)
<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
![Create New Thread](./_assets/threads-new-chat-updated.png)
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
![Favorites and Recents](./_assets/threads-favorites-and-recents-updated.png)
### 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
![Context Menu](./_assets/threads-context-menu-updated.png)
### 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
![Assistant Instruction](./_assets/assistant-dropdown.png)
![Add an Assistant Instruction](./_assets/assistant-edit-dialog.png)
### 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**
![Chat with a Model](./_assets/model-parameters.png)
### 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)
![Connect Remote APIs](./_assets/quick-start-03.png)
For detailed setup, see [Remote APIs](/docs/remote-models/openai).

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View 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:
![Deep Research Flow Excalidraw](./_assets/revised-deepresearch-flow.png)
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.
OpenAIs [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 UX Flow](./_assets/openai-deep-research-flow.png)
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 | 1030 | 1015 | PDF, Docx, Plain Text | Paid |
| Grok's DeeperSearch | Ability to access all of Twitter | 70100 | 510 | Ability to specify format (PDF / Markdown) | Free |
| Claude | Breadth + depth search | 100+ | 510 | PDF, Markdown, Artifact | Paid |
| Gemini | Editable planning | 50+ | 1020 | Google Docs export | Free |
| Perplexity | Ability to specify sources | 50100 | 35 | PDF, Markdown, Docx, Perplexity Page | Paid and Limited Free |
| Kimi | Interactive synthesis | 50100 | 3060+ | 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 [Claudes 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):
![Claude Agent Desktop](./_assets/claude-agent.png)
![Claude Report Visualizer](./_assets/claude-report-visualizer.png)
## 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
View 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

View File

@ -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 users 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 youre 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 dont know or that needs verification\n- Never use tools just because theyre 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 youre 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 dont know or that needs verification\n- Never use tools just because theyre 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',

View File

@ -10,6 +10,8 @@ interface DownloadItem {
url: string
save_path: string
proxy?: Record<string, string | string[] | boolean>
sha256?: string
size?: number
}
type DownloadEvent = {

View File

@ -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"

View File

@ -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'],
},
})

View File

@ -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",

View File

@ -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
}

View File

@ -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
View 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
View File

@ -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",

View File

@ -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",

View 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)

View File

@ -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(),
}
}

View File

@ -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"));
}
}

View File

@ -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"] }

View File

@ -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

View File

@ -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);

View File

@ -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))
}
}

View File

@ -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)?;

View File

@ -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))
}

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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");

View File

@ -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);

View File

@ -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,

View File

@ -1,6 +1,7 @@
pub mod commands;
mod constants;
pub mod helpers;
pub mod models;
#[cfg(test)]
mod tests;

View 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,
}

View File

@ -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;

Some files were not shown because too many files have changed in this diff Show More