diff --git a/.github/workflows/autoqa-reliability.yml b/.github/workflows/autoqa-reliability.yml new file mode 100644 index 000000000..759e93717 --- /dev/null +++ b/.github/workflows/autoqa-reliability.yml @@ -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 }}" diff --git a/.github/workflows/jan-astro-docs.yml b/.github/workflows/jan-astro-docs.yml index b551d847d..4e28f8180 100644 --- a/.github/workflows/jan-astro-docs.yml +++ b/.github/workflows/jan-astro-docs.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a254fb49..16379e575 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,32 +1,266 @@ -# Contributing to jan +# Contributing to Jan -First off, thank you for considering contributing to jan. It's people like you that make jan such an amazing project. +First off, thank you for considering contributing to Jan. It's people like you that make Jan such an amazing project. + +Jan is an AI assistant that can run 100% offline on your device. Think ChatGPT, but private, local, and under your complete control. If you're thinking about contributing, you're already awesome - let's make AI accessible to everyone, one commit at a time. + +## Quick Links to Component Guides + +- **[Web App](./web-app/CONTRIBUTING.md)** - React UI and logic +- **[Core SDK](./core/CONTRIBUTING.md)** - TypeScript SDK and extension system +- **[Extensions](./extensions/CONTRIBUTING.md)** - Supportive modules for the frontend +- **[Tauri Backend](./src-tauri/CONTRIBUTING.md)** - Rust native integration +- **[Tauri Plugins](./src-tauri/plugins/CONTRIBUTING.md)** - Hardware and system plugins + +## How Jan Actually Works + +Jan is a desktop app that runs local AI models. Here's how the components actually connect: + +``` +┌──────────────────────────────────────────────────────────┐ +│ Web App (Frontend) │ +│ (web-app/) │ +│ • React UI │ +│ • Chat Interface │ +│ • Settings Pages │ +│ • Model Hub │ +└────────────┬─────────────────────────────┬───────────────┘ + │ │ + │ imports │ imports + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────┐ + │ Core SDK │ │ Extensions │ + │ (core/) │ │ (extensions/) │ + │ │ │ │ + │ • TypeScript APIs │◄─────│ • Assistant Mgmt │ + │ • Extension System │ uses │ • Conversations │ + │ • Event Bus │ │ • Downloads │ + │ • Type Definitions │ │ • LlamaCPP │ + └──────────┬───────────┘ └───────────┬──────────┘ + │ │ + │ ┌──────────────────────┐ │ + │ │ Web App │ │ + │ └──────────┬───────────┘ │ + │ │ │ + └──────────────┼───────────────┘ + │ + ▼ + Tauri IPC + (invoke commands) + │ + ▼ +┌───────────────────────────────────────────────────────────┐ +│ Tauri Backend (Rust) │ +│ (src-tauri/) │ +│ │ +│ • Window Management • File System Access │ +│ • Process Control • System Integration │ +│ • IPC Command Handler • Security & Permissions │ +└───────────────────────────┬───────────────────────────────┘ + │ + │ + ▼ +┌───────────────────────────────────────────────────────────┐ +│ Tauri Plugins (Rust) │ +│ (src-tauri/plugins/) │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Hardware Plugin │ │ LlamaCPP Plugin │ │ +│ │ │ │ │ │ +│ │ • CPU/GPU Info │ │ • Process Mgmt │ │ +│ │ • Memory Stats │ │ • Model Loading │ │ +│ │ • System Info │ │ • Inference │ │ +│ └──────────────────┘ └──────────────────┘ │ +└───────────────────────────────────────────────────────────┘ +``` + +### The Communication Flow + +1. **JavaScript Layer Relationships**: + - Web App imports Core SDK and Extensions as JavaScript modules + - Extensions use Core SDK for shared functionality + - All run in the browser/webview context + +2. **All Three → Backend**: Through Tauri IPC + - **Web App** → Backend: `await invoke('app_command', data)` + - **Core SDK** → Backend: `await invoke('core_command', data)` + - **Extensions** → Backend: `await invoke('ext_command', data)` + - Each component can independently call backend commands + +3. **Backend → Plugins**: Native Rust integration + - Backend loads plugins as Rust libraries + - Direct function calls, no IPC overhead + +4. **Response Flow**: + - Plugin → Backend → IPC → Requester (Web App/Core/Extension) → UI updates + +### Real-World Example: Loading a Model + +Here's what actually happens when you click "Download Llama 3": + +1. **Web App** (`web-app/`) - User clicks download button +2. **Extension** (`extensions/download-extension`) - Handles the download logic +3. **Tauri Backend** (`src-tauri/`) - Actually downloads the file to disk +4. **Extension** (`extensions/llamacpp-extension`) - Prepares model for loading +5. **Tauri Plugin** (`src-tauri/plugins/llamacpp`) - Starts llama.cpp process +6. **Hardware Plugin** (`src-tauri/plugins/hardware`) - Detects GPU, optimizes settings +7. **Model ready!** - User can start chatting + +## Project Structure + +``` +jan/ +├── web-app/ # React frontend (what users see) +├── src-tauri/ # Rust backend (system integration) +│ ├── src/core/ # Core Tauri commands +│ └── plugins/ # Tauri plugins (hardware, llamacpp) +├── core/ # TypeScript SDK (API layer) +├── extensions/ # JavaScript extensions +│ ├── assistant-extension/ +│ ├── conversational-extension/ +│ ├── download-extension/ +│ └── llamacpp-extension/ +├── docs/ # Documentation website +├── website/ # Marketing website +├── autoqa/ # Automated testing +├── scripts/ # Build utilities +│ +├── package.json # Root workspace configuration +├── Makefile # Build automation commands +├── mise.toml # Mise tool configuration +├── LICENSE # Apache 2.0 license +└── README.md # Project overview +``` + +## Development Setup + +### The Scenic Route (Build from Source) + +**Prerequisites:** +- Node.js ≥ 20.0.0 +- Yarn ≥ 1.22.0 +- Rust (for Tauri) +- Make ≥ 3.81 + +**Option 1: The Easy Way (Make)** +```bash +git clone https://github.com/menloresearch/jan +cd jan +make dev +``` + +**Option 2: The Easier Way (Mise)** +```bash +git clone https://github.com/menloresearch/jan +cd jan + +# Install mise +curl https://mise.run | sh + +# Let mise handle everything +mise install # installs Node.js, Rust, and other tools +mise dev # runs the full development setup +``` ## How Can I Contribute? ### Reporting Bugs -- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/menloresearch/jan/issues). -- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/menloresearch/jan/issues/new). +- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/menloresearch/jan/issues) +- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/menloresearch/jan/issues/new) +- Include your system specs and error logs - it helps a ton ### Suggesting Enhancements -- Open a new issue with a clear title and description. +- Open a new issue with a clear title and description +- Explain why this enhancement would be useful +- Include mockups or examples if you can ### Your First Code Contribution -- Fork the repo. -- Create a new branch (`git checkout -b feature-name`). -- Commit your changes (`git commit -am 'Add some feature'`). -- Push to the branch (`git push origin feature-name`). -- Open a new Pull Request. +**Choose Your Adventure:** +- **Frontend UI and logic** → `web-app/` +- **Shared API declarations** → `core/` +- **Backend system integration** → `src-tauri/` +- **Business logic features** → `extensions/` +- **Dedicated backend handler** → `src-tauri/plugins/` -## Styleguides +**The Process:** +1. Fork the repo +2. Create a new branch (`git checkout -b feature-name`) +3. Make your changes (and write tests!) +4. Commit your changes (`git commit -am 'Add some feature'`) +5. Push to the branch (`git push origin feature-name`) +6. Open a new Pull Request against `dev` branch -### Git Commit Messages +## Testing -- Use the present tense ("Add feature" not "Added feature"). +```bash +yarn test # All tests +cd src-tauri && cargo test # Rust tests +cd autoqa && python main.py # End-to-end tests +``` + +## Code Standards + +### TypeScript/JavaScript +- TypeScript required (we're not animals) +- ESLint + Prettier +- Functional React components +- Proper typing (no `any` - seriously!) + +### Rust +- `cargo fmt` + `cargo clippy` +- `Result` 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! 🚀 diff --git a/Makefile b/Makefile index 4bd823437..2515f8bf4 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,8 @@ test: lint yarn copy:assets:tauri yarn build:icon cargo test --manifest-path src-tauri/Cargo.toml --no-default-features --features test-tauri -- --test-threads=1 + cargo test --manifest-path src-tauri/plugins/tauri-plugin-hardware/Cargo.toml + cargo test --manifest-path src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml # Builds and publishes the app build-and-publish: install-and-build diff --git a/core/CONTRIBUTING.md b/core/CONTRIBUTING.md new file mode 100644 index 000000000..4d0a16989 --- /dev/null +++ b/core/CONTRIBUTING.md @@ -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 diff --git a/core/src/browser/extensions/engines/AIEngine.ts b/core/src/browser/extensions/engines/AIEngine.ts index a23e8c45e..b203092ce 100644 --- a/core/src/browser/extensions/engines/AIEngine.ts +++ b/core/src/browser/extensions/engines/AIEngine.ts @@ -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 } @@ -270,4 +272,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 } diff --git a/core/src/types/api/index.ts b/core/src/types/api/index.ts index 853195178..ade6421ff 100644 --- a/core/src/types/api/index.ts +++ b/core/src/types/api/index.ts @@ -58,6 +58,7 @@ export enum AppEvent { onAppUpdateDownloadUpdate = 'onAppUpdateDownloadUpdate', onAppUpdateDownloadError = 'onAppUpdateDownloadError', onAppUpdateDownloadSuccess = 'onAppUpdateDownloadSuccess', + onModelImported = 'onModelImported', onUserSubmitQuickAsk = 'onUserSubmitQuickAsk', onSelectedText = 'onSelectedText', diff --git a/docs/src/pages/post/_assets/claude-agent.png b/docs/src/pages/post/_assets/claude-agent.png new file mode 100644 index 000000000..9fe2873c9 Binary files /dev/null and b/docs/src/pages/post/_assets/claude-agent.png differ diff --git a/docs/src/pages/post/_assets/claude-report-visualizer.png b/docs/src/pages/post/_assets/claude-report-visualizer.png new file mode 100644 index 000000000..e0c29f6da Binary files /dev/null and b/docs/src/pages/post/_assets/claude-report-visualizer.png differ diff --git a/docs/src/pages/post/_assets/deepresearch-flow.png b/docs/src/pages/post/_assets/deepresearch-flow.png new file mode 100644 index 000000000..c7c923307 Binary files /dev/null and b/docs/src/pages/post/_assets/deepresearch-flow.png differ diff --git a/docs/src/pages/post/_assets/edit-mcp-settings.gif b/docs/src/pages/post/_assets/edit-mcp-settings.gif new file mode 100644 index 000000000..3e5b638b4 Binary files /dev/null and b/docs/src/pages/post/_assets/edit-mcp-settings.gif differ diff --git a/docs/src/pages/post/_assets/enable-tools-local.gif b/docs/src/pages/post/_assets/enable-tools-local.gif new file mode 100644 index 000000000..39e907d9f Binary files /dev/null and b/docs/src/pages/post/_assets/enable-tools-local.gif differ diff --git a/docs/src/pages/post/_assets/experimental-settings-jan.png b/docs/src/pages/post/_assets/experimental-settings-jan.png new file mode 100644 index 000000000..18ec94430 Binary files /dev/null and b/docs/src/pages/post/_assets/experimental-settings-jan.png differ diff --git a/docs/src/pages/post/_assets/jan-nano-hub.png b/docs/src/pages/post/_assets/jan-nano-hub.png new file mode 100644 index 000000000..ac939b8d3 Binary files /dev/null and b/docs/src/pages/post/_assets/jan-nano-hub.png differ diff --git a/docs/src/pages/post/_assets/openai-deep-research-flow.png b/docs/src/pages/post/_assets/openai-deep-research-flow.png new file mode 100644 index 000000000..11b2ac23d Binary files /dev/null and b/docs/src/pages/post/_assets/openai-deep-research-flow.png differ diff --git a/docs/src/pages/post/_assets/research-result-local.png b/docs/src/pages/post/_assets/research-result-local.png new file mode 100644 index 000000000..18e98aa32 Binary files /dev/null and b/docs/src/pages/post/_assets/research-result-local.png differ diff --git a/docs/src/pages/post/_assets/revised-deepresearch-flow.png b/docs/src/pages/post/_assets/revised-deepresearch-flow.png new file mode 100644 index 000000000..c8b436388 Binary files /dev/null and b/docs/src/pages/post/_assets/revised-deepresearch-flow.png differ diff --git a/docs/src/pages/post/_assets/successful-serper.png b/docs/src/pages/post/_assets/successful-serper.png new file mode 100644 index 000000000..6345eeba5 Binary files /dev/null and b/docs/src/pages/post/_assets/successful-serper.png differ diff --git a/docs/src/pages/post/deepresearch.mdx b/docs/src/pages/post/deepresearch.mdx new file mode 100644 index 000000000..62e584082 --- /dev/null +++ b/docs/src/pages/post/deepresearch.mdx @@ -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. + +OpenAI’s [Deep Research API cookbook](https://cookbook.openai.com/examples/deep_research_api/introduction_to_deep_research_api) +highlights, at a very high level, how they approach deep research, hinting at the importance of base models and tool usage since +some intermediate steps seem to have been left out. + +![OpenAI's Deep Research 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 | 10–30 | 10–15 | PDF, Docx, Plain Text | Paid | +| Grok's DeeperSearch | Ability to access all of Twitter | 70–100 | 5–10 | Ability to specify format (PDF / Markdown) | Free | +| Claude | Breadth + depth search | 100+ | 5–10 | PDF, Markdown, Artifact | Paid | +| Gemini | Editable planning | 50+ | 10–20 | Google Docs export | Free | +| Perplexity | Ability to specify sources | 50–100 | 3–5 | PDF, Markdown, Docx, Perplexity Page | Paid and Limited Free | +| Kimi | Interactive synthesis | 50–100 | 30–60+ | PDF, Interactive website | Free | + +In our testing, we used the following prompt to assess the quality of the generated report by +the providers above. You can refer to the reports generated [here](https://github.com/menloresearch/prompt-experiments). + +``` +Generate a comprehensive report about the state of AI in the past week. Include all +new model releases and notable architectural improvements from a variety of sources. +``` + +[Google's generated report](https://github.com/menloresearch/prompt-experiments/blob/main/Gemini%202.5%20Flash%20Report.pdf) was the most verbose, with a whopping 23 pages that reads +like a professional intelligence briefing. It opens with an executive summary, +systematically categorizes developments, and provides forward-looking strategic +insights—connecting OpenAI's open-weight release to broader democratization trends +and linking infrastructure investments to competitive positioning. + +[OpenAI](https://github.com/menloresearch/prompt-experiments/blob/main/OpenAI%20Deep%20Research.pdf) produced the most citation-heavy output with 134 references throughout 10 pages +(albeit most of them being from the same source). + +[Perplexity](https://github.com/menloresearch/prompt-experiments/blob/main/Perplexity%20Deep%20Research.pdf) delivered the most actionable 6-page report that maximizes information +density while maintaining scannability. Despite being the shortest, it captures all +major developments with sufficient context for decision-making. + +[Claude](https://github.com/menloresearch/prompt-experiments/blob/main/Claude%20Deep%20Research.pdf) produced a comprehensive analysis that interestingly ignored the time constraint, +covering an 8-month period from January-August 2025 instead of the requested week (Jul 31-Aug +7th 2025). Rather than cataloging recent events, Claude traced the evolution of trends over months. + +[Grok](https://github.com/menloresearch/prompt-experiments/blob/main/Grok%203%20Deep%20Research.pdf) produced a well-structured but relatively shallow 5-page academic-style report that +read more like an event catalog than strategic analysis. + +[Kimi](https://github.com/menloresearch/prompt-experiments/blob/main/Kimi%20AI%20Deep%20Research.pdf) produced a comprehensive 13-page report with systematic organization covering industry developments, research breakthroughs, and policy changes, but notably lacks proper citations throughout most of the content despite claiming to use 50-100 sources. + +### Understanding Search Strategies + +In [Claude’s Research mode](https://www.anthropic.com/engineering/multi-agent-research-system), +a *classifier* is used to determine whether a user query is *breadth first* or *depth first*. This +results in a customization of the pipeline that is used for conducting research. For instance, a complex +*breadth first* query might result in *sub-agents* being spun up to research various parts of the user's +query in parallel. Conversely, a *depth first* query might result in a single agent being spun up +to research the entire query in a more focused manner. + +Here's a screenshot of this in action (in Claude Desktop): +![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? + + +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`). + + +**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. + + diff --git a/extensions/CONTRIBUTING.md b/extensions/CONTRIBUTING.md new file mode 100644 index 000000000..ee5c5aa9f --- /dev/null +++ b/extensions/CONTRIBUTING.md @@ -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 \ No newline at end of file diff --git a/extensions/llamacpp-extension/package.json b/extensions/llamacpp-extension/package.json index b5db33c5e..585365130 100644 --- a/extensions/llamacpp-extension/package.json +++ b/extensions/llamacpp-extension/package.json @@ -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" diff --git a/extensions/llamacpp-extension/rolldown.config.mjs b/extensions/llamacpp-extension/rolldown.config.mjs index 86b6798d7..64f92f29a 100644 --- a/extensions/llamacpp-extension/rolldown.config.mjs +++ b/extensions/llamacpp-extension/rolldown.config.mjs @@ -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'], + }, }) diff --git a/extensions/llamacpp-extension/src/backend.ts b/extensions/llamacpp-extension/src/backend.ts index b4d86d154..a597a9a15 100644 --- a/extensions/llamacpp-extension/src/backend.ts +++ b/extensions/llamacpp-extension/src/backend.ts @@ -264,7 +264,6 @@ async function _getSupportedFeatures() { // 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 ) { // 6GB (total_memory is in MB) diff --git a/extensions/llamacpp-extension/src/index.ts b/extensions/llamacpp-extension/src/index.ts index 3612c678b..bf03024a0 100644 --- a/extensions/llamacpp-extension/src/index.ts +++ b/extensions/llamacpp-extension/src/index.ts @@ -19,6 +19,7 @@ import { ImportOptions, chatCompletionRequest, events, + AppEvent, } from '@janhq/core' import { error, info, warn } from '@tauri-apps/plugin-log' @@ -32,6 +33,7 @@ import { import { invoke } from '@tauri-apps/api/core' import { getProxyConfig } from './util' import { basename } from '@tauri-apps/api/path' +import { readGgufMetadata } from '@janhq/tauri-plugin-llamacpp-api' type LlamacppConfig = { version_backend: string @@ -39,6 +41,7 @@ type LlamacppConfig = { auto_unload: boolean chat_template: string n_gpu_layers: number + offload_mmproj: boolean override_tensor_buffer_t: string ctx_size: number threads: number @@ -101,12 +104,6 @@ interface DeviceList { free: number } -interface GgufMetadata { - version: number - tensor_count: number - metadata: Record -} - /** * Override the default app.log function to use Jan's logging system. * @param args @@ -1059,13 +1056,34 @@ export default class llamacpp_extension extends AIEngine { } } - // 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])) @@ -1085,6 +1103,12 @@ export default class llamacpp_extension extends AIEngine { data: modelConfig, savePath: configPath, }) + events.emit(AppEvent.onModelImported, { + modelId, + modelPath, + mmprojPath, + size_bytes, + }) } override async abortImport(modelId: string): Promise { @@ -1168,11 +1192,12 @@ export default class llamacpp_extension extends AIEngine { } } const args: string[] = [] + const envs: Record = {} 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 +1219,7 @@ 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 // model option is required // NOTE: model_path and mmproj_path can be either relative to Jan's data folder or absolute path @@ -1203,7 +1228,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 +1237,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 +1307,15 @@ export default class llamacpp_extension extends AIEngine { try { // TODO: add LIBRARY_PATH - const sInfo = await invoke('plugin:llamacpp|load_llama_model', { - backendPath, - libraryPath, - args, - }) + const sInfo = await invoke( + 'plugin:llamacpp|load_llama_model', + { + backendPath, + libraryPath, + args, + envs, + } + ) return sInfo } catch (error) { logger.error('Error in load command:\n', error) @@ -1299,9 +1331,12 @@ export default class llamacpp_extension extends AIEngine { const pid = sInfo.pid try { // Pass the PID as the session_id - const result = await invoke('plugin:llamacpp|unload_llama_model', { - pid: pid, - }) + const result = await invoke( + 'plugin:llamacpp|unload_llama_model', + { + pid: pid, + } + ) // If successful, remove from active sessions if (result.success) { @@ -1370,7 +1405,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 +1476,12 @@ export default class llamacpp_extension extends AIEngine { private async findSessionByModel(modelId: string): Promise { try { - let sInfo = await invoke('plugin:llamacpp|find_session_by_model', { - modelId, - }) + let sInfo = await invoke( + 'plugin:llamacpp|find_session_by_model', + { + modelId, + } + ) return sInfo } catch (e) { logger.error(e) @@ -1516,7 +1558,9 @@ export default class llamacpp_extension extends AIEngine { override async getLoadedModels(): Promise { try { - let models: string[] = await invoke('plugin:llamacpp|get_loaded_models') + let models: string[] = await invoke( + 'plugin:llamacpp|get_loaded_models' + ) return models } catch (e) { logger.error(e) @@ -1524,6 +1568,26 @@ 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 - true if mmproj.gguf exists, false otherwise + */ + async checkMmprojExists(modelId: string): Promise { + 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 { const cfg = this.config const [version, backend] = cfg.version_backend.split('/') @@ -1599,14 +1663,31 @@ export default class llamacpp_extension extends AIEngine { throw new Error('method not implemented yet') } - private async loadMetadata(path: string): Promise { - try { - const data = await invoke('plugin:llamacpp|read_gguf_metadata', { - path: path, - }) - return data - } catch (err) { - throw err - } + /** + * Check if a tool is supported by the model + * Currently read from GGUF chat_template + * @param modelId + * @returns + */ + async isToolSupported(modelId: string): Promise { + const janDataFolderPath = await getJanDataFolderPath() + const modelConfigPath = await joinPath([ + this.providerPath, + 'models', + modelId, + 'model.yml', + ]) + const modelConfig = await invoke('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') } } diff --git a/src-tauri/CONTRIBUTING.md b/src-tauri/CONTRIBUTING.md new file mode 100644 index 000000000..e9d68cfaf --- /dev/null +++ b/src-tauri/CONTRIBUTING.md @@ -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 { + 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 { + 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 { + 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` 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 \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fc6bfd301..32638bc56 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", ] @@ -3984,8 +4019,8 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.2.1" -source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3196c95f1dfafbffbdcdd6d365c94969ac975e6a#3196c95f1dfafbffbdcdd6d365c94969ac975e6a" +version = "0.5.0" +source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=209dbac50f51737ad953c3a2c8e28f3619b6c277#209dbac50f51737ad953c3a2c8e28f3619b6c277" dependencies = [ "base64 0.22.1", "chrono", @@ -4010,10 +4045,10 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.2.1" -source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3196c95f1dfafbffbdcdd6d365c94969ac975e6a#3196c95f1dfafbffbdcdd6d365c94969ac975e6a" +version = "0.5.0" +source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=209dbac50f51737ad953c3a2c8e28f3619b6c277#209dbac50f51737ad953c3a2c8e28f3619b6c277" dependencies = [ - "darling", + "darling 0.21.2", "proc-macro2", "quote", "serde_json", @@ -4408,7 +4443,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 +6903,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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 744b84830..efd69e9bf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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", diff --git a/src-tauri/plugins/CONTRIBUTING.md b/src-tauri/plugins/CONTRIBUTING.md new file mode 100644 index 000000000..2f999d617 --- /dev/null +++ b/src-tauri/plugins/CONTRIBUTING.md @@ -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() -> TauriPlugin { + 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 { + Ok(format!("Result: {}", param)) +} +``` + +```typescript +import { invoke } from '@tauri-apps/api/core' + +export async function myCommand(param: string): Promise { + 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) \ No newline at end of file diff --git a/src-tauri/plugins/tauri-plugin-hardware/permissions/schemas/schema.json b/src-tauri/plugins/tauri-plugin-hardware/permissions/schemas/schema.json index 6848c3288..c5abe1f43 100644 --- a/src-tauri/plugins/tauri-plugin-hardware/permissions/schemas/schema.json +++ b/src-tauri/plugins/tauri-plugin-hardware/permissions/schemas/schema.json @@ -327,4 +327,4 @@ ] } } -} +} \ No newline at end of file diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/permissions/schemas/schema.json b/src-tauri/plugins/tauri-plugin-llamacpp/permissions/schemas/schema.json index f832b4560..70ccaf6f7 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/permissions/schemas/schema.json +++ b/src-tauri/plugins/tauri-plugin-llamacpp/permissions/schemas/schema.json @@ -447,4 +447,4 @@ ] } } -} +} \ No newline at end of file diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/src/commands.rs b/src-tauri/plugins/tauri-plugin-llamacpp/src/commands.rs index a2592f345..35dc35c5e 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/src/commands.rs +++ b/src-tauri/plugins/tauri-plugin-llamacpp/src/commands.rs @@ -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( backend_path: &str, library_path: Option<&str>, mut args: Vec, + envs: HashMap, ) -> ServerResult { let state: State = app_handle.state(); let mut process_map = state.llama_server_process.lock().await; @@ -53,13 +55,23 @@ pub async fn load_llama_model( 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()); diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs b/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs index 44ed00109..a62fb069a 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs +++ b/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs @@ -98,3 +98,50 @@ pub fn validate_model_path(args: &mut Vec) -> ServerResult { Ok(model_path_pb) } + +/// Validate mmproj path exists and update args with platform-appropriate path format +pub fn validate_mmproj_path(args: &mut Vec) -> ServerResult> { + 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)) +} diff --git a/src-tauri/src/core/mcp/commands.rs b/src-tauri/src/core/mcp/commands.rs index 56b1a6124..48c7f88a1 100644 --- a/src-tauri/src/core/mcp/commands.rs +++ b/src-tauri/src/core/mcp/commands.rs @@ -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( name: String, config: Value, ) -> Result<(), String> { - let servers: Arc>>> = - 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, String> { +pub async fn get_tools(state: State<'_, AppState>) -> Result, String> { let servers = state.mcp_servers.lock().await; - let mut all_tools: Vec = Vec::new(); + let mut all_tools: Vec = 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, 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, 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` - 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, 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>, + cancellation_token: Option, ) -> Result { + // 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 { let mut path = get_jan_data_folder_path(app); diff --git a/src-tauri/src/core/mcp/helpers.rs b/src-tauri/src/core/mcp/helpers.rs index e6b72488d..80a8b5f86 100644 --- a/src-tauri/src/core/mcp/helpers.rs +++ b/src-tauri/src/core/mcp/helpers.rs @@ -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( app: &AppHandle, - servers_state: Arc>>>, + 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( /// Monitor MCP server health without removing it from the HashMap pub async fn monitor_mcp_server_handle( - servers_state: Arc>>>, + servers_state: SharedMcpServers, name: String, ) -> Option { 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( app: AppHandle, - servers_state: Arc>>>, + servers_state: SharedMcpServers, name: String, config: Value, max_restarts: Option, @@ -297,7 +319,7 @@ pub async fn start_mcp_server_with_restart( /// Helper function to handle the restart loop logic pub async fn start_restart_loop( app: AppHandle, - servers_state: Arc>>>, + servers_state: SharedMcpServers, name: String, config: Value, max_restarts: u32, @@ -450,9 +472,9 @@ pub async fn start_restart_loop( } } -pub async fn schedule_mcp_start_task( +async fn schedule_mcp_start_task( app: tauri::AppHandle, - servers: Arc>>>, + servers: SharedMcpServers, name: String, config: Value, ) -> Result<(), String> { @@ -463,136 +485,279 @@ pub async fn schedule_mcp_start_task( .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::(); + 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::(); + 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::(); + 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::(); - 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, serde_json::Map)> { +pub fn extract_command_args(config: &Value) -> Option { 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 { @@ -604,7 +769,7 @@ pub fn extract_active_status(config: &Value) -> Option { /// Restart only servers that were previously active (like cortex restart behavior) pub async fn restart_active_mcp_servers( app: &AppHandle, - servers_state: Arc>>>, + servers_state: SharedMcpServers, ) -> Result<(), String> { let app_state = app.state::(); 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>>>, -) -> Result<(), String> { +pub async fn stop_mcp_servers(servers_state: SharedMcpServers) -> Result<(), String> { let mut servers_map = servers_state.lock().await; let keys: Vec = 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 /// Spawn the server monitoring task for handling restarts pub async fn spawn_server_monitoring_task( app: AppHandle, - servers_state: Arc>>>, + servers_state: SharedMcpServers, name: String, config: Value, max_restarts: u32, diff --git a/src-tauri/src/core/mcp/mod.rs b/src-tauri/src/core/mcp/mod.rs index 5b20160de..b9627f02f 100644 --- a/src-tauri/src/core/mcp/mod.rs +++ b/src-tauri/src/core/mcp/mod.rs @@ -1,6 +1,7 @@ pub mod commands; mod constants; pub mod helpers; +pub mod models; #[cfg(test)] mod tests; diff --git a/src-tauri/src/core/mcp/models.rs b/src-tauri/src/core/mcp/models.rs new file mode 100644 index 000000000..cd3debbc8 --- /dev/null +++ b/src-tauri/src/core/mcp/models.rs @@ -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, + pub url: Option, + pub command: String, + pub args: Vec, + pub envs: serde_json::Map, + pub timeout: Option, + pub headers: serde_json::Map, +} + +/// Tool with server information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolWithServer { + pub name: String, + pub description: Option, + #[serde(rename = "inputSchema")] + pub input_schema: serde_json::Value, + pub server: String, +} diff --git a/src-tauri/src/core/mcp/tests.rs b/src-tauri/src/core/mcp/tests.rs index 8346449b2..081a188e8 100644 --- a/src-tauri/src/core/mcp/tests.rs +++ b/src-tauri/src/core/mcp/tests.rs @@ -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>>> = + let servers_state: SharedMcpServers = Arc::new(Mutex::new(HashMap::new())); let result = run_mcp_commands(app.handle(), servers_state).await; diff --git a/src-tauri/src/core/state.rs b/src-tauri/src/core/state.rs index bda2eb40c..ddbbcf7bd 100644 --- a/src-tauri/src/core/state.rs +++ b/src-tauri/src/core/state.rs @@ -1,20 +1,49 @@ use std::{collections::HashMap, sync::Arc}; use crate::core::downloads::models::DownloadManagerState; -use rmcp::{service::RunningService, RoleClient}; +use rmcp::{ + model::{CallToolRequestParam, CallToolResult, InitializeRequestParam, Tool}, + service::RunningService, + RoleClient, ServiceError, +}; +use tokio::sync::{Mutex, oneshot}; use tokio::task::JoinHandle; /// Server handle type for managing the proxy server lifecycle pub type ServerHandle = JoinHandle>>; -use tokio::sync::Mutex; + +pub enum RunningServiceEnum { + NoInit(RunningService), + WithInit(RunningService), +} +pub type SharedMcpServers = Arc>>; #[derive(Default)] pub struct AppState { pub app_token: Option, - pub mcp_servers: Arc>>>, + pub mcp_servers: SharedMcpServers, pub download_manager: Arc>, pub mcp_restart_counts: Arc>>, pub mcp_active_servers: Arc>>, pub mcp_successfully_connected: Arc>>, pub server_handle: Arc>>, + pub tool_call_cancellations: Arc>>>, +} + +impl RunningServiceEnum { + pub async fn list_all_tools(&self) -> Result, ServiceError> { + match self { + Self::NoInit(s) => s.list_all_tools().await, + Self::WithInit(s) => s.list_all_tools().await, + } + } + pub async fn call_tool( + &self, + params: CallToolRequestParam, + ) -> Result { + match self { + Self::NoInit(s) => s.call_tool(params).await, + Self::WithInit(s) => s.call_tool(params).await, + } + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 63d60a571..10a9d7556 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,6 +74,7 @@ pub fn run() { // MCP commands core::mcp::commands::get_tools, core::mcp::commands::call_tool, + core::mcp::commands::cancel_tool_call, core::mcp::commands::restart_mcp_servers, core::mcp::commands::get_connected_servers, core::mcp::commands::save_mcp_configs, @@ -105,6 +106,7 @@ pub fn run() { mcp_active_servers: Arc::new(Mutex::new(HashMap::new())), mcp_successfully_connected: Arc::new(Mutex::new(HashMap::new())), server_handle: Arc::new(Mutex::new(None)), + tool_call_cancellations: Arc::new(Mutex::new(HashMap::new())), }) .setup(|app| { app.handle().plugin( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c2e37e483..c5dcb9c1b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -35,7 +35,8 @@ "effects": ["fullScreenUI", "mica", "tabbed", "blur", "acrylic"], "state": "active", "radius": 8 - } + }, + "dragDropEnabled": false } ], "security": { diff --git a/web-app/CONTRIBUTING.md b/web-app/CONTRIBUTING.md new file mode 100644 index 000000000..32d7779bd --- /dev/null +++ b/web-app/CONTRIBUTING.md @@ -0,0 +1,128 @@ +# Contributing to Jan Web App + +[← Back to Main Contributing Guide](../CONTRIBUTING.md) + +React frontend using TypeScript, TanStack Router, Radix UI, and Tailwind CSS. State is managed by React State and Zustand. + +## Key Directories + +- **`/src/components/ui`** - UI components (buttons, dialogs, inputs) +- **`/src/containers`** - Complex feature components (ChatInput, ThreadContent) +- **`/src/hooks`** - Custom React hooks (useChat, useThreads, useAppState) +- **`/src/routes`** - TanStack Router pages +- **`/src/services`** - API layer for backend communication +- **`/src/types`** - TypeScript definitions + +## Development + +### Component Example + +```tsx +interface Props { + title: string + onAction?: () => void +} + +export const MyComponent: React.FC = ({ title, onAction }) => { + return ( +
+

{title}

+ +
+ ) +} +``` + +### Routing + +```tsx +export const Route = createFileRoute('/settings/general')({ + component: GeneralSettings +}) +``` + +### Building & Testing + +```bash +# Development +yarn dev +yarn build +yarn test +``` + +### State Management + +```tsx +// Local state +const [value, setValue] = useState('') + +// Global state (Zustand) +export const useAppState = create((set) => ({ + data: null, + setData: (data) => set({ data }) +})) +``` + +### Tauri Integration + +```tsx +import { invoke } from '@tauri-apps/api/tauri' + +const result = await invoke('command_name', { param: 'value' }) +``` + +## Performance Tips + +```tsx +// Use React.memo for expensive components +const ExpensiveComponent = React.memo(({ data }) => { + return
{processData(data)}
+}) + +// Debounce frequent updates +const debouncedValue = useDebounce(searchTerm, 300) + +// Virtual scrolling for large lists +import { VariableSizeList } from 'react-window' +``` + +## Debugging + +```bash +# React DevTools +# Install browser extension, then: +# - Inspect component tree +# - Debug hooks and state +# - Profile performance + +# Debug Tauri commands +console.log(await window.__TAURI__.invoke('command_name')) + +# Check for console errors +# Press F12 → Console tab +``` + +## Accessibility Guidelines + +- Use semantic HTML (` + + {submenuTitle || 'Submenu'} + + + +
+ {/* Use AnimatePresence to handle exit animations */} + + + {activeSubmenu + ? getSubmenuContent(activeSubmenu) + : children} + + +
+ + ) : ( + <> + + Menu + +
+ + + {children} + + +
+ + )} + + + ) + } + + return ( + + + {children} + + + ) +} + +function DropDrawerItem({ + className, + children, + onSelect, + onClick, + icon, + variant = 'default', + inset, + disabled, + ...props +}: React.ComponentProps & { + icon?: React.ReactNode +}) { + const { isMobile } = useComponentSelection() + const { useGroupState } = useGroupDetection() + const { itemRef, isInsideGroup } = useGroupState() + + if (isMobile) { + const handleClick = (e: React.MouseEvent) => { + if (disabled) return + + // If this item only has an icon (like a switch) and no other interactive content, + // don't handle clicks on the main area - let the icon handle everything + if (icon && !onClick && !onSelect) { + return + } + + // Check if the click came from the icon area (where the Switch is) + const target = e.target as HTMLElement + const iconContainer = (e.currentTarget as HTMLElement).querySelector( + '[data-icon-container]' + ) + + if (iconContainer && iconContainer.contains(target)) { + // Don't handle the click if it came from the icon area + return + } + + if (onClick) onClick(e) + if (onSelect) onSelect(e as unknown as Event) + } + + // Only wrap in DrawerClose if it's not a submenu item + const content = ( +
+
{children}
+ {icon && ( +
+ {icon} +
+ )} +
+ ) + + // Check if this is inside a submenu + const isInSubmenu = + (props as Record)['data-parent-submenu-id'] || + (props as Record)['data-parent-submenu'] + + if (isInSubmenu) { + return content + } + + return {content} + } + + return ( + } + variant={variant} + inset={inset} + disabled={disabled} + {...props} + > +
+
{children}
+ {icon &&
{icon}
} +
+
+ ) +} + +function DropDrawerSeparator({ + className, + ...props +}: React.ComponentProps) { + const { isMobile } = useComponentSelection() + + if (isMobile) { + return null + } + + return ( + + ) +} + +function DropDrawerLabel({ + className, + children, + ...props +}: + | React.ComponentProps + | React.ComponentProps) { + const { isMobile } = useComponentSelection() + + if (isMobile) { + return ( + + + {children} + + + ) + } + + return ( + + {children} + + ) +} + +function DropDrawerFooter({ + className, + children, + ...props +}: React.ComponentProps | React.ComponentProps<'div'>) { + const { isMobile } = useDropDrawerContext() + + if (isMobile) { + return ( + + {children} + + ) + } + + // No direct equivalent in DropdownMenu, so we'll just render a div + return ( +
+ {children} +
+ ) +} + +function DropDrawerGroup({ + className, + children, + ...props +}: React.ComponentProps<'div'> & { + children: React.ReactNode +}) { + const { isMobile } = useDropDrawerContext() + + // Add separators between children on mobile + const childrenWithSeparators = React.useMemo(() => { + if (!isMobile) return children + + const childArray = React.Children.toArray(children) + + // Filter out any existing separators + const filteredChildren = childArray.filter( + (child) => + React.isValidElement(child) && child.type !== DropDrawerSeparator + ) + + // Add separators between items + return filteredChildren.flatMap((child, index) => { + if (index === filteredChildren.length - 1) return [child] + return [ + child, +