Merge remote-tracking branch 'origin/dev' into mobile/dev

# Conflicts:
#	src-tauri/src/core/setup.rs
#	src-tauri/src/lib.rs
#	web-app/src/hooks/useChat.ts
This commit is contained in:
Vanalite 2025-10-01 09:52:01 +07:00
commit 262a1a9544
231 changed files with 1306 additions and 467 deletions

View File

@ -54,6 +54,8 @@ on:
value: ${{ jobs.build-windows-x64.outputs.WIN_SIG }} value: ${{ jobs.build-windows-x64.outputs.WIN_SIG }}
FILE_NAME: FILE_NAME:
value: ${{ jobs.build-windows-x64.outputs.FILE_NAME }} value: ${{ jobs.build-windows-x64.outputs.FILE_NAME }}
MSI_FILE_NAME:
value: ${{ jobs.build-windows-x64.outputs.MSI_FILE_NAME }}
jobs: jobs:
build-windows-x64: build-windows-x64:
@ -61,6 +63,7 @@ jobs:
outputs: outputs:
WIN_SIG: ${{ steps.metadata.outputs.WIN_SIG }} WIN_SIG: ${{ steps.metadata.outputs.WIN_SIG }}
FILE_NAME: ${{ steps.metadata.outputs.FILE_NAME }} FILE_NAME: ${{ steps.metadata.outputs.FILE_NAME }}
MSI_FILE_NAME: ${{ steps.metadata.outputs.MSI_FILE_NAME }}
permissions: permissions:
contents: write contents: write
steps: steps:
@ -189,9 +192,15 @@ jobs:
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: jan-windows-${{ inputs.new_version }} name: jan-windows-exe-${{ inputs.new_version }}
path: | path: |
./src-tauri/target/release/bundle/nsis/*.exe ./src-tauri/target/release/bundle/nsis/*.exe
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: jan-windows-msi-${{ inputs.new_version }}
path: |
./src-tauri/target/release/bundle/msi/*.msi
## Set output filename for windows ## Set output filename for windows
- name: Set output filename for windows - name: Set output filename for windows
@ -201,13 +210,18 @@ jobs:
if [ "${{ inputs.channel }}" != "stable" ]; then if [ "${{ inputs.channel }}" != "stable" ]; then
FILE_NAME=Jan-${{ inputs.channel }}_${{ inputs.new_version }}_x64-setup.exe FILE_NAME=Jan-${{ inputs.channel }}_${{ inputs.new_version }}_x64-setup.exe
WIN_SIG=$(cat Jan-${{ inputs.channel }}_${{ inputs.new_version }}_x64-setup.exe.sig) WIN_SIG=$(cat Jan-${{ inputs.channel }}_${{ inputs.new_version }}_x64-setup.exe.sig)
MSI_FILE="Jan-${{ inputs.channel }}_${{ inputs.new_version }}_x64_en-US.msi"
else else
FILE_NAME=Jan_${{ inputs.new_version }}_x64-setup.exe FILE_NAME=Jan_${{ inputs.new_version }}_x64-setup.exe
WIN_SIG=$(cat Jan_${{ inputs.new_version }}_x64-setup.exe.sig) WIN_SIG=$(cat Jan_${{ inputs.new_version }}_x64-setup.exe.sig)
MSI_FILE="Jan_${{ inputs.new_version }}_x64_en-US.msi"
fi fi
echo "::set-output name=WIN_SIG::$WIN_SIG" echo "::set-output name=WIN_SIG::$WIN_SIG"
echo "::set-output name=FILE_NAME::$FILE_NAME" echo "::set-output name=FILE_NAME::$FILE_NAME"
echo "::set-output name=MSI_FILE_NAME::$MSI_FILE"
id: metadata id: metadata
## Upload to s3 for nightly and beta ## Upload to s3 for nightly and beta
@ -220,6 +234,8 @@ jobs:
# Upload for tauri updater # Upload for tauri updater
aws s3 cp ./${{ steps.metadata.outputs.FILE_NAME }} s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-${{ inputs.channel }}/${{ steps.metadata.outputs.FILE_NAME }} aws s3 cp ./${{ steps.metadata.outputs.FILE_NAME }} s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-${{ inputs.channel }}/${{ steps.metadata.outputs.FILE_NAME }}
aws s3 cp ./${{ steps.metadata.outputs.FILE_NAME }}.sig s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-${{ inputs.channel }}/${{ steps.metadata.outputs.FILE_NAME }}.sig aws s3 cp ./${{ steps.metadata.outputs.FILE_NAME }}.sig s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-${{ inputs.channel }}/${{ steps.metadata.outputs.FILE_NAME }}.sig
aws s3 cp ./src-tauri/target/release/bundle/msi/${{ steps.metadata.outputs.MSI_FILE_NAME }} s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-${{ inputs.channel }}/${{ steps.metadata.outputs.MSI_FILE_NAME }}
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
@ -236,3 +252,13 @@ jobs:
asset_path: ./src-tauri/target/release/bundle/nsis/${{ steps.metadata.outputs.FILE_NAME }} asset_path: ./src-tauri/target/release/bundle/nsis/${{ steps.metadata.outputs.FILE_NAME }}
asset_name: ${{ steps.metadata.outputs.FILE_NAME }} asset_name: ${{ steps.metadata.outputs.FILE_NAME }}
asset_content_type: application/octet-stream asset_content_type: application/octet-stream
- name: Upload release assert if public provider is github
if: inputs.public_provider == 'github'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/upload-release-asset@v1.0.1
with:
upload_url: ${{ inputs.upload_url }}
asset_path: ./src-tauri/target/release/bundle/msi/${{ steps.metadata.outputs.MSI_FILE_NAME }}
asset_name: ${{ steps.metadata.outputs.MSI_FILE_NAME }}
asset_content_type: application/octet-stream

View File

@ -27,11 +27,13 @@
"devDependencies": { "devDependencies": {
"@npmcli/arborist": "^7.1.0", "@npmcli/arborist": "^7.1.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "19.1.2",
"@vitest/coverage-v8": "^2.1.8", "@vitest/coverage-v8": "^2.1.8",
"@vitest/ui": "^2.1.8", "@vitest/ui": "^2.1.8",
"eslint": "8.57.0", "eslint": "8.57.0",
"happy-dom": "^15.11.6", "happy-dom": "^15.11.6",
"pacote": "^21.0.0", "pacote": "^21.0.0",
"react": "19.0.0",
"request": "^2.88.2", "request": "^2.88.2",
"request-progress": "^3.0.0", "request-progress": "^3.0.0",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
@ -44,5 +46,8 @@
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"ulidx": "^2.3.0" "ulidx": "^2.3.0"
}, },
"peerDependencies": {
"react": "19.0.0"
},
"packageManager": "yarn@4.5.3" "packageManager": "yarn@4.5.3"
} }

View File

@ -10,7 +10,7 @@ export default defineConfig([
sourcemap: true, sourcemap: true,
}, },
platform: 'browser', platform: 'browser',
external: ['path'], external: ['path', 'react', 'react-dom', 'react/jsx-runtime'],
define: { define: {
NODE: JSON.stringify(`${pkgJson.name}/${pkgJson.node}`), NODE: JSON.stringify(`${pkgJson.name}/${pkgJson.node}`),
VERSION: JSON.stringify(pkgJson.version), VERSION: JSON.stringify(pkgJson.version),

View File

@ -250,4 +250,4 @@ describe('ConversationalExtension', () => {
expect(retrievedAssistant.modelId).toBe('') expect(retrievedAssistant.modelId).toBe('')
}) })
}) })

View File

@ -131,4 +131,4 @@ describe('LocalOAIEngine', () => {
expect(engine.loadedModel).toBeUndefined() expect(engine.loadedModel).toBeUndefined()
}) })
}) })
}) })

View File

@ -96,4 +96,4 @@ describe('MCPExtension', () => {
expect(healthy).toBe(true) expect(healthy).toBe(true)
}) })
}) })
}) })

View File

@ -1,5 +1,6 @@
import { MCPInterface, MCPTool, MCPToolCallResult } from '../../types' import { MCPInterface, MCPTool, MCPToolCallResult, MCPToolComponentProps } from '../../types'
import { BaseExtension, ExtensionTypeEnum } from '../extension' import { BaseExtension, ExtensionTypeEnum } from '../extension'
import type { ComponentType } from 'react'
/** /**
* MCP (Model Context Protocol) extension for managing tools and server communication. * MCP (Model Context Protocol) extension for managing tools and server communication.
@ -18,4 +19,16 @@ export abstract class MCPExtension extends BaseExtension implements MCPInterface
abstract getConnectedServers(): Promise<string[]> abstract getConnectedServers(): Promise<string[]>
abstract refreshTools(): Promise<void> abstract refreshTools(): Promise<void>
abstract isHealthy(): Promise<boolean> abstract isHealthy(): Promise<boolean>
}
/**
* Optional method to provide a custom UI component for tools
* @returns A React component or null if no custom component is provided
*/
getToolComponent?(): ComponentType<MCPToolComponentProps> | null
/**
* Optional method to get the list of tool names that should be disabled by default
* @returns Array of tool names that should be disabled by default for new users
*/
getDefaultDisabledTools?(): Promise<string[]>
}

View File

@ -131,4 +131,4 @@ describe('ModelManager', () => {
expect(modelManager.models.get('model-2')).toEqual(model2) expect(modelManager.models.get('model-2')).toEqual(model2)
}) })
}) })
}) })

View File

@ -16,4 +16,4 @@ if (!window.core) {
}) })
} }
// Add any other global mocks needed for core tests // Add any other global mocks needed for core tests

View File

@ -1,2 +1,2 @@
export * from './mcpEntity' export * from './mcpEntity'
export * from './mcpInterface' export * from './mcpInterface'

View File

@ -21,4 +21,18 @@ export interface MCPServerInfo {
name: string name: string
connected: boolean connected: boolean
tools?: MCPTool[] tools?: MCPTool[]
} }
/**
* Props for MCP tool UI components
*/
export interface MCPToolComponentProps {
/** List of available MCP tools */
tools: MCPTool[]
/** Function to check if a specific tool is currently enabled */
isToolEnabled: (toolName: string) => boolean
/** Function to toggle a tool's enabled/disabled state */
onToolToggle: (toolName: string, enabled: boolean) => void
}

View File

@ -29,4 +29,4 @@ export interface MCPInterface {
* Check if MCP service is healthy * Check if MCP service is healthy
*/ */
isHealthy(): Promise<boolean> isHealthy(): Promise<boolean>
} }

View File

@ -115,6 +115,9 @@
/docs/built-in/tensorrt-llm /docs/desktop/llama-cpp 302 /docs/built-in/tensorrt-llm /docs/desktop/llama-cpp 302
/docs/desktop/docs/desktop/linux /docs/desktop/install/linux 302 /docs/desktop/docs/desktop/linux /docs/desktop/install/linux 302
/windows /docs/desktop/install/windows 302 /windows /docs/desktop/install/windows 302
/docs/quickstart /docs/ 302
/docs/desktop/mac /docs/desktop/install/mac 302
/handbook/open-superintelligence /handbook/why/open-superintelligence 302
/guides/integrations/continue/ /docs/desktop/server-examples/continue-dev 302 /guides/integrations/continue/ /docs/desktop/server-examples/continue-dev 302
/continue-dev /docs/desktop/server-examples/continue-dev 302 /continue-dev /docs/desktop/server-examples/continue-dev 302
@ -133,4 +136,4 @@
/local-server/troubleshooting /docs/desktop/troubleshooting 302 /local-server/troubleshooting /docs/desktop/troubleshooting 302
/mcp /docs/desktop/mcp 302 /mcp /docs/desktop/mcp 302
/quickstart /docs/desktop/quickstart 302 /quickstart /docs/desktop/quickstart 302
/server-examples/continue-dev /docs/desktop/server-examples/continue-dev 302 /server-examples/continue-dev /docs/desktop/server-examples/continue-dev 302

View File

@ -6,7 +6,7 @@ const camelCase = (str) => {
return str.replace(/[-_](\w)/g, (_, c) => c.toUpperCase()) return str.replace(/[-_](\w)/g, (_, c) => c.toUpperCase())
} }
const categories = ['building-jan', 'research'] const categories = ['building-jan', 'research', 'guides']
/** /**
* @param {import("plop").NodePlopAPI} plop * @param {import("plop").NodePlopAPI} plop

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

View File

@ -19,6 +19,10 @@ const Blog = () => {
name: 'Research', name: 'Research',
id: 'research', id: 'research',
}, },
{
name: 'Guides',
id: 'guides',
},
] ]
return ( return (

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

View File

@ -20,5 +20,10 @@
"title": "Research", "title": "Research",
"display": "normal", "display": "normal",
"href": "/blog?category=research" "href": "/blog?category=research"
},
"guides-cat": {
"title": "Guides",
"display": "normal",
"href": "/blog?category=guides"
} }
} }

View File

@ -0,0 +1,123 @@
---
title: "Private AI for legal professionals who need confidentiality"
description: "It's possible to use AI without risking client data. Jan helps lawyers save time while keeping clients safe."
tags: AI, ai for law, ai for lawyers, ChatGPT alternative, Jan, local AI, offline AI
categories: guides
date: 2025-09-30
ogImage: assets/images/general/jan-for-ai-law-assistant-chat.jpeg
twitter:
card: summary_large_image
site: "@jandotai"
title: "Private AI for legal professionals who need confidentiality"
description: "It's possible to use AI without risking client data. Jan helps lawyers save time while keeping clients safe."
image: assets/images/general/jan-assistants-ai-for-legal.jpeg
---
import { Callout } from 'nextra/components'
import CTABlog from '@/components/Blog/CTA'
import { OpenAIStatusChecker } from '@/components/OpenAIStatusChecker'
# Private AI for legal professionals who need confidentiality
![AI for Law](/assets/images/general/jan-for-ai-law-assistant-chat.jpeg)
Yes, it's possible to use AI in legal work without risking client data.
<Callout type="warning">
Client trust depends on privacy. Sending documents into public AI tools risks compliance and reputation.
</Callout>
Start by [downloading Jan](/download) and installing the **Jan v1 model**. Once installed, you can create assistants tailored to your practice and keep contracts, case notes, and client files under your control.
<Callout type="info">
**Why use Jan for legal tasks**
- Runs locally on your hardware, no cloud uploads
- Keeps chats and interactions private
- Works offline once installed
- Lets you build assistants for your own workflows
</Callout>
---
## Create your assistant
Once Jan is installed with the **Jan v1 model**, onboarding will guide you through downloading and setup.
Click **Create assistant** to start:
![Create your first AI assistant in Jan](./_assets/create-assistant-1.jpeg)
*Create your first assistant in Jan*
Add an assistant name and prompt:
![Jan assistant for contract review](./_assets/jan-assistant-for-law.png)
*Example of a Jan assistant for contract review*
You can create assistants using specific prompts. Below are examples for common legal workflows.
---
## Contract review assistant
AI can help lawyers move faster through long contracts by pointing out what matters most.
**Prompt for Jan:**
> You are a contract review assistant.
> When I paste a contract:
> - Highlight risky or unusual clauses
> - Flag ambiguous or missing terms
> - Summarize the agreement in plain English for a non-lawyer client
> Format your response with sections: **Risks**, **Ambiguities/Missing**, **Summary**.
> Do not provide legal advice.
---
## Drafting assistant
Use AI to create first drafts of NDAs, service agreements, or client letters. You still refine the output, but AI saves time on boilerplate.
**Prompt for Jan:**
> You are a drafting assistant.
> When asked to draft a legal agreement or client letter:
> - Produce a professional first version
> - Use clear, concise language
> - Leave placeholders like [Party Name], [Date], [Amount] for details
> - Structure output with headings, numbered clauses, and consistent formatting
> Do not provide legal advice.
---
## Case preparation assistant
Case prep often means reading hundreds of pages. AI can turn depositions, discovery files, or judgments into concise notes.
![Jan legal case preparation assistant](./_assets/jan-for-ai-law-assistant-chat.jpeg)
*Jan chat interface for case preparation — process documents and extract key information*
**Prompt for Jan:**
> You are a case preparation assistant.
> When I provide case materials:
> - Extract key facts, issues, and arguments
> - Present them as bullet points under headings: **Facts**, **Issues**, **Arguments**
> - Keep summaries concise (under 500 words unless I request more)
> Use plain English, no speculation or legal conclusions.
---
## Knowledge management assistant
Law firms accumulate memos, policies, and precedents. AI can help organize and retrieve them quickly.
**Prompt for Jan:**
> You are a knowledge management assistant.
> When I ask questions about internal documents:
> - Return concise summaries or direct excerpts
> - Always cite the source (e.g., “Policy Manual, Section 4”)
> - If not found in provided material, reply “Not found in documents.”
> Do not invent information.
---
## Final note
AI in legal practice is not about replacing lawyers. Its about handling repetitive tasks safely so you can focus on real decisions.
With private AI, you gain efficiency without compromising client confidentiality.
<CTABlog />

View File

@ -0,0 +1,134 @@
---
title: "AI for teachers who care about student privacy"
description: "Use AI in teaching without risking student data. Jan helps teachers plan lessons, grade faster, and communicate with parents."
tags: AI, ai for teachers, ChatGPT alternative, Jan, local AI, offline AI, education
categories: guides
date: 2025-10-01
ogImage: assets/images/general/ai-for-teacher.jpeg
twitter:
card: summary_large_image
site: "@jandotai"
title: "AI for teachers who care about student privacy"
description: "Use AI in teaching without risking student data. Jan helps teachers plan lessons, grade faster, and communicate with parents."
image: assets/images/general/ai-for-teacher.jpeg
---
import { Callout } from 'nextra/components'
import CTABlog from '@/components/Blog/CTA'
# AI for teachers who care about student privacy
![AI for teachers](/assets/images/general/ai-for-teacher.jpeg)
AI can help teachers handle the work that piles up outside class. It can draft a lesson outline, suggest feedback on essays, or turn notes into a polite parent email. These are the tasks that usually stretch into evenings and weekends.
<Callout>
Most AI tools like ChatGPT run in the cloud. Sharing lesson plans, student writing, or parent details there risks compliance and trust.
</Callout>
That's where Jan comes in:
- [Download Jan](/download)
- You get the same time-saving help
- Your data never leaves your device.
<video controls>
<source src="/assets/images/general/jan-ai-for-teacher.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
*See how teachers use Jan for AI-powered lesson planning and grading*
<Callout type="info">
**Why use Jan for teaching**
- Runs locally, no cloud servers
- Keeps lesson plans and student data private
- Works offline once installed
- Lets you build assistants for your daily teaching tasks
</Callout>
---
## Create your assistant
Once Jan is installed, click **Create assistant** and add one of the prompts below. Each assistant is for a specific classroom task.
![Create your first AI assistant in Jan](/assets/images/general/assistants-ai-for-teachers.jpeg)
---
## Lesson planning assistant
AI can draft lesson outlines in minutes. You adapt and refine them for your students.
**Prompt for Jan:**
> You are a lesson planning assistant.
> When I give you a topic or subject:
> - Suggest a lesson outline with objectives, activities, and discussion questions
> - Adjust for different grade levels if I specify
> - Keep plans practical and realistic for a classroom setting
Example ask: For Grade 6 science on ecosystems. Objectives: define food chains, explain producer/consumer roles. Activity: group poster on an ecosystem. Questions: How would removing one species affect the whole system?
---
## Grading support assistant
AI won't replace your judgment, but it can make feedback faster and more consistent.
**Prompt for Jan:**
> You are a grading support assistant.
> When I paste student writing or answers:
> - Highlight strengths and areas for improvement
> - Suggest short, constructive feedback I can reuse
> - Keep tone supportive and professional
> Do not assign final grades.
Example: For a history essay. Strength: clear thesis. Improvement: weak evidence. Feedback: "Great thesis and structure. Next time, support your points with specific historical examples."
---
## Parent communication assistant
Writing parent emails is important but time-consuming.
**Prompt for Jan:**
> You are a parent communication assistant.
> When I give you key points about a student:
> - Draft a polite and empathetic email to parents
> - Use clear and professional language
> - Keep tone supportive, not overly formal
> Only include details I provide.
Example: Notes: “Student is falling behind on homework, otherwise engaged in class.” - Output: a short, encouraging message suggesting a check-in at home.
---
## Classroom resources assistant
Generate quizzes, worksheets, or practice activities at short notice.
**Prompt for Jan:**
> You are a classroom resource assistant.
> When I provide a topic or subject:
> - Generate sample quiz questions (multiple choice and short answer)
> - Suggest short practice activities
> - Provide answer keys separately
> Keep material age-appropriate for the level I specify.
Example: For Grade 4 fractions. 5 multiple-choice questions with answer key, plus a quick worksheet with 3 practice problems.
---
## Getting started
1. [Download Jan](/download).
2. Install the Jan model (guided in-app)
3. Create your first assistant using one of the prompts above
4. Test with non-sensitive examples first
5. Use it in real classroom tasks once you're comfortable
---
## Final note
AI isn't here to replace teachers. It's here to take repetitive tasks off your plate so you can focus on teaching. With Jan, you can use AI confidently without risking student privacy.
<CTABlog />

View File

@ -18,7 +18,7 @@ import { OpenAIStatusChecker } from '@/components/OpenAIStatusChecker'
# If ChatGPT is down, switch to AI that never goes down # If ChatGPT is down, switch to AI that never goes down
If you're seeing ChatGPT is down, it could a good signal to switch to [Jan](https://www.jan.ai/), AI that never goes down. If you're seeing ChatGPT is down, it could be a good signal to switch to [Jan](https://www.jan.ai/), AI that never goes down.
## 🔴 Realtime Status: Is ChatGPT down? ## 🔴 Realtime Status: Is ChatGPT down?
<Callout> <Callout>
@ -108,17 +108,17 @@ When ChatGPT experiences issues, you might see these error messages:
## Quick answers about ChatGPT status ## Quick answers about ChatGPT status
### Is ChatGPT down today? ### Is ChatGPT down?
Check the ChatGPT realtime status above. If ChatGPT is down, you'll see it here. Check the ChatGPT realtime status above. [See if ChatGPT is down right now.](http://localhost:3000/post/is-chatgpt-down-use-jan#-realtime-status-is-chatgpt-down)
### Why is ChatGPT down? ### Why is ChatGPT down?
Usually server overload, maintenance, or outages at OpenAI. Usually server overload, maintenance, or outages at OpenAI.
### What does “ChatGPT is at capacity” mean? ### What does "ChatGPT is at capacity" mean?
Too many users are online at the same time. Youll need to wait or switch to Jan instead. Too many users are online at the same time. Youll need to wait or switch to Jan instead.
### Is ChatGPT shutting down? ### Is ChatGPT shutting down?
No, ChatGPT isnt shutting down. Outages are temporary. No, ChatGPT isn't shutting down. Outages are temporary.
### Can I use ChatGPT offline? ### Can I use ChatGPT offline?
No. ChatGPT always requires internet. For [offline AI](https://www.jan.ai/post/offline-chatgpt-alternative), use [Jan](https://jan.ai). No. ChatGPT always requires internet. For [offline AI](https://www.jan.ai/post/offline-chatgpt-alternative), use [Jan](https://jan.ai).

View File

@ -22,6 +22,9 @@
}, },
"devDependencies": { "devDependencies": {
"@janhq/core": "workspace:*", "@janhq/core": "workspace:*",
"@tabler/icons-react": "^3.34.0",
"@types/react": "19.1.2",
"react": "19.0.0",
"typescript": "5.9.2", "typescript": "5.9.2",
"vite": "5.4.20", "vite": "5.4.20",
"vitest": "2.1.9", "vitest": "2.1.9",
@ -29,6 +32,8 @@
}, },
"peerDependencies": { "peerDependencies": {
"@janhq/core": "*", "@janhq/core": "*",
"@tabler/icons-react": "*",
"react": "19.0.0",
"zustand": "5.0.3" "zustand": "5.0.3"
}, },
"dependencies": { "dependencies": {

View File

@ -14,4 +14,4 @@ export const DEFAULT_ASSISTANT = {
name: 'Jan', name: 'Jan',
avatar: '👋', avatar: '👋',
created_at: 1747029866.542, created_at: 1747029866.542,
} }

View File

@ -268,4 +268,4 @@ export class JanApiClient {
} }
} }
export const janApiClient = JanApiClient.getInstance() export const janApiClient = JanApiClient.getInstance()

View File

@ -1 +1 @@
export { default } from './provider' export { default } from './provider'

View File

@ -92,4 +92,4 @@ export const janProviderStore = {
useJanProviderStore.getState().clearError(), useJanProviderStore.getState().clearError(),
reset: () => reset: () =>
useJanProviderStore.getState().reset(), useJanProviderStore.getState().reset(),
} }

View File

@ -0,0 +1,54 @@
import { useMemo, useCallback } from 'react'
import { IconWorld } from '@tabler/icons-react'
import { MCPToolComponentProps } from '@janhq/core'
// List of tool names considered as web search tools
const WEB_SEARCH_TOOL_NAMES = ['google_search', 'scrape'];
export const WebSearchButton = ({
tools,
isToolEnabled,
onToolToggle,
}: MCPToolComponentProps) => {
const webSearchTools = useMemo(
() => tools.filter((tool) => WEB_SEARCH_TOOL_NAMES.includes(tool.name)),
[tools]
)
// Early return if no web search tools available
if (webSearchTools.length === 0) {
return null
}
// Check if all web search tools are enabled
const isEnabled = useMemo(
() => webSearchTools.every((tool) => isToolEnabled(tool.name)),
[webSearchTools, isToolEnabled]
)
const handleToggle = useCallback(() => {
// Toggle all web search tools at once
const newState = !isEnabled
webSearchTools.forEach((tool) => {
onToolToggle(tool.name, newState)
})
}, [isEnabled, webSearchTools, onToolToggle])
return (
<button
onClick={handleToggle}
className={`h-7 px-2 py-1 flex items-center justify-center rounded-md transition-all duration-200 ease-in-out gap-1 cursor-pointer ml-0.5 border-0 ${
isEnabled
? 'bg-accent/20 text-accent'
: 'bg-transparent text-main-view-fg/70 hover:bg-main-view-fg/5'
}`}
title={isEnabled ? 'Disable Web Search' : 'Enable Web Search'}
>
<IconWorld
size={16}
className={isEnabled ? 'text-accent' : 'text-main-view-fg/70'}
/>
<span className={`text-sm font-medium ${isEnabled ? 'text-accent' : ''}`}>Search</span>
</button>
)
}

View File

@ -0,0 +1 @@
export { WebSearchButton } from './WebSearchButton'

View File

@ -4,11 +4,13 @@
* Uses official MCP TypeScript SDK with proper session handling * Uses official MCP TypeScript SDK with proper session handling
*/ */
import { MCPExtension, MCPTool, MCPToolCallResult } from '@janhq/core' import { MCPExtension, MCPTool, MCPToolCallResult, MCPToolComponentProps } from '@janhq/core'
import { getSharedAuthService, JanAuthService } from '../shared' import { getSharedAuthService, JanAuthService } from '../shared'
import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { JanMCPOAuthProvider } from './oauth-provider' import { JanMCPOAuthProvider } from './oauth-provider'
import { WebSearchButton } from './components'
import type { ComponentType } from 'react'
// JAN_API_BASE is defined in vite.config.ts (defaults to 'https://api-dev.jan.ai/jan/v1') // JAN_API_BASE is defined in vite.config.ts (defaults to 'https://api-dev.jan.ai/jan/v1')
declare const JAN_API_BASE: string declare const JAN_API_BASE: string
@ -232,4 +234,27 @@ export default class MCPExtensionWeb extends MCPExtension {
throw error throw error
} }
} }
}
/**
* Provides a custom UI component for web search tools
* @returns The WebSearchButton component
*/
getToolComponent(): ComponentType<MCPToolComponentProps> | null {
return WebSearchButton
}
/**
* Returns the list of tool names that should be disabled by default for new users
* All MCP web tools are disabled by default to prevent accidental API usage
* @returns Array of tool names to disable by default
*/
async getDefaultDisabledTools(): Promise<string[]> {
try {
const tools = await this.getTools()
return tools.map(tool => tool.name)
} catch (error) {
console.error('Failed to get default disabled tools:', error)
return []
}
}
}

View File

@ -57,4 +57,4 @@ export class JanMCPOAuthProvider implements OAuthClientProvider {
async codeVerifier(): Promise<string> { async codeVerifier(): Promise<string> {
throw new Error('Code verifier not supported') throw new Error('Code verifier not supported')
} }
} }

View File

@ -47,4 +47,4 @@ export class ApiError extends Error {
isServerError(): boolean { isServerError(): boolean {
return this.status >= 500 && this.status < 600 return this.status >= 500 && this.status < 600
} }
} }

View File

@ -38,4 +38,4 @@ export interface IndexedDBConfig {
keyPath: string keyPath: string
indexes?: { name: string; keyPath: string | string[]; unique?: boolean }[] indexes?: { name: string; keyPath: string | string[]; unique?: boolean }[]
}[] }[]
} }

View File

@ -2,4 +2,4 @@ export {}
declare global { declare global {
declare const JAN_API_BASE: string declare const JAN_API_BASE: string
} }

View File

@ -1 +1 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />

View File

@ -3,6 +3,7 @@
"target": "ES2020", "target": "ES2020",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,

View File

@ -9,11 +9,11 @@ export default defineConfig({
fileName: 'index' fileName: 'index'
}, },
rollupOptions: { rollupOptions: {
external: ['@janhq/core', 'zustand'] external: ['@janhq/core', 'zustand', 'react', 'react-dom', 'react/jsx-runtime', '@tabler/icons-react']
}, },
emptyOutDir: false // Don't clean the output directory emptyOutDir: false // Don't clean the output directory
}, },
define: { define: {
JAN_API_BASE: JSON.stringify(process.env.JAN_API_BASE || 'https://api-dev.jan.ai/v1'), JAN_API_BASE: JSON.stringify(process.env.JAN_API_BASE || 'https://api-dev.jan.ai/v1'),
} }
}) })

View File

@ -106,13 +106,11 @@ async function main() {
} }
// Adjust these URLs based on latest releases // Adjust these URLs based on latest releases
const bunVersion = '1.2.10' // Example Bun version const bunUrl = `https://github.com/oven-sh/bun/releases/latest/download/bun-${bunPlatform}.zip`
const bunUrl = `https://github.com/oven-sh/bun/releases/download/bun-v${bunVersion}/bun-${bunPlatform}.zip`
const uvVersion = '0.6.17' // Example UV version let uvUrl = `https://github.com/astral-sh/uv/releases/latest/download/uv-${uvPlatform}.tar.gz`
let uvUrl = `https://github.com/astral-sh/uv/releases/download/${uvVersion}/uv-${uvPlatform}.tar.gz`
if (platform === 'win32') { if (platform === 'win32') {
uvUrl = `https://github.com/astral-sh/uv/releases/download/${uvVersion}/uv-${uvPlatform}.zip` uvUrl = `https://github.com/astral-sh/uv/releases/latest/download/uv-${uvPlatform}.zip`
} }
console.log(`Downloading Bun for ${bunPlatform}...`) console.log(`Downloading Bun for ${bunPlatform}...`)

View File

@ -62,6 +62,7 @@ pub async fn estimate_kv_cache_internal(
ctx_size: Option<u64>, ctx_size: Option<u64>,
) -> Result<KVCacheEstimate, KVCacheError> { ) -> Result<KVCacheEstimate, KVCacheError> {
log::info!("Received ctx_size parameter: {:?}", ctx_size); log::info!("Received ctx_size parameter: {:?}", ctx_size);
log::info!("Received model metadata:\n{:?}", &meta);
let arch = meta let arch = meta
.get("general.architecture") .get("general.architecture")
.ok_or(KVCacheError::ArchitectureNotFound)?; .ok_or(KVCacheError::ArchitectureNotFound)?;
@ -94,15 +95,43 @@ pub async fn estimate_kv_cache_internal(
let key_len_key = format!("{}.attention.key_length", arch); let key_len_key = format!("{}.attention.key_length", arch);
let val_len_key = format!("{}.attention.value_length", arch); let val_len_key = format!("{}.attention.value_length", arch);
let key_len = meta let mut key_len = meta
.get(&key_len_key) .get(&key_len_key)
.and_then(|s| s.parse::<u64>().ok()) .and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0); .unwrap_or(0);
let val_len = meta let mut val_len = meta
.get(&val_len_key) .get(&val_len_key)
.and_then(|s| s.parse::<u64>().ok()) .and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0); .unwrap_or(0);
// Fallback: calculate from embedding_length if key/val lengths not found
if key_len == 0 || val_len == 0 {
let emb_len_key = format!("{}.embedding_length", arch);
let emb_len = meta
.get(&emb_len_key)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if emb_len > 0 && n_head > 0 {
// For most transformers: head_dim = embedding_length / total_heads
let total_heads = meta
.get(&n_head_key)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(n_head);
let head_dim = emb_len / total_heads;
key_len = head_dim;
val_len = head_dim;
log::info!(
"Calculated key_len and val_len from embedding_length: {} / {} heads = {} per head",
emb_len,
total_heads,
head_dim
);
}
}
if key_len == 0 || val_len == 0 { if key_len == 0 || val_len == 0 {
return Err(KVCacheError::EmbeddingLengthInvalid); return Err(KVCacheError::EmbeddingLengthInvalid);
} }

View File

@ -627,17 +627,28 @@ async fn schedule_mcp_start_task<R: Runtime>(
} }
} else { } else {
let mut cmd = Command::new(config_params.command.clone()); let mut cmd = Command::new(config_params.command.clone());
let bun_x_path = format!("{}/bun", bin_path.display()); let bun_x_path = if cfg!(windows) {
if config_params.command.clone() == "npx" && can_override_npx(bun_x_path.clone()) { bin_path.join("bun.exe")
} else {
bin_path.join("bun")
};
if config_params.command.clone() == "npx"
&& can_override_npx(bun_x_path.display().to_string())
{
let mut cache_dir = app_path.clone(); let mut cache_dir = app_path.clone();
cache_dir.push(".npx"); cache_dir.push(".npx");
cmd = Command::new(bun_x_path); cmd = Command::new(bun_x_path.display().to_string());
cmd.arg("x"); cmd.arg("x");
cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string()); cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string());
} }
let uv_path = format!("{}/uv", bin_path.display()); let uv_path = if cfg!(windows) {
if config_params.command.clone() == "uvx" && can_override_uvx(uv_path.clone()) { bin_path.join("uv.exe")
} else {
bin_path.join("uv")
};
if config_params.command.clone() == "uvx" && can_override_uvx(uv_path.display().to_string())
{
let mut cache_dir = app_path.clone(); let mut cache_dir = app_path.clone();
cache_dir.push(".uvx"); cache_dir.push(".uvx");
cmd = Command::new(uv_path); cmd = Command::new(uv_path);
@ -935,3 +946,47 @@ pub async fn should_restart_server(
} }
} }
} }
// Add a new server configuration to the MCP config file
pub fn add_server_config<R: Runtime>(
app_handle: tauri::AppHandle<R>,
server_key: String,
server_value: Value,
) -> Result<(), String> {
add_server_config_with_path(app_handle, server_key, server_value, None)
}
// Add a new server configuration to the MCP config file with custom path support
pub fn add_server_config_with_path<R: Runtime>(
app_handle: tauri::AppHandle<R>,
server_key: String,
server_value: Value,
config_filename: Option<&str>,
) -> Result<(), String> {
let config_filename = config_filename.unwrap_or("mcp_config.json");
let config_path = get_jan_data_folder_path(app_handle).join(config_filename);
let mut config: Value = serde_json::from_str(
&std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file: {e}"))?,
)
.map_err(|e| format!("Failed to parse config: {e}"))?;
config
.as_object_mut()
.ok_or("Config root is not an object")?
.entry("mcpServers")
.or_insert_with(|| Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or("mcpServers is not an object")?
.insert(server_key, server_value);
std::fs::write(
&config_path,
serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {e}"))?,
)
.map_err(|e| format!("Failed to write config file: {e}"))?;
Ok(())
}

View File

@ -1,9 +1,10 @@
use super::helpers::run_mcp_commands; use super::helpers::{add_server_config, add_server_config_with_path, run_mcp_commands};
use crate::core::app::commands::get_jan_data_folder_path; use crate::core::app::commands::get_jan_data_folder_path;
use crate::core::state::SharedMcpServers; use crate::core::state::SharedMcpServers;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tauri::test::mock_app; use tauri::test::mock_app;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@ -27,8 +28,7 @@ async fn test_run_mcp_commands() {
.expect("Failed to write to config file"); .expect("Failed to write to config file");
// Call the run_mcp_commands function // Call the run_mcp_commands function
let servers_state: SharedMcpServers = let servers_state: SharedMcpServers = Arc::new(Mutex::new(HashMap::new()));
Arc::new(Mutex::new(HashMap::new()));
let result = run_mcp_commands(app.handle(), servers_state).await; let result = run_mcp_commands(app.handle(), servers_state).await;
// Assert that the function returns Ok(()) // Assert that the function returns Ok(())
@ -37,3 +37,188 @@ async fn test_run_mcp_commands() {
// Clean up the mock config file // Clean up the mock config file
std::fs::remove_file(&config_path).expect("Failed to remove config file"); std::fs::remove_file(&config_path).expect("Failed to remove config file");
} }
#[test]
fn test_add_server_config_new_file() {
let app = mock_app();
let app_path = get_jan_data_folder_path(app.handle().clone());
let config_path = app_path.join("mcp_config_test_new.json");
// Ensure the directory exists
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).expect("Failed to create parent directory");
}
// Create initial config file with empty mcpServers
let mut file = File::create(&config_path).expect("Failed to create config file");
file.write_all(b"{\"mcpServers\":{}}")
.expect("Failed to write to config file");
drop(file);
// Test adding a new server config
let server_value = serde_json::json!({
"command": "npx",
"args": ["-y", "test-server"],
"env": { "TEST_API_KEY": "test_key" },
"active": false
});
let result = add_server_config_with_path(
app.handle().clone(),
"test_server".to_string(),
server_value.clone(),
Some("mcp_config_test_new.json"),
);
assert!(result.is_ok(), "Failed to add server config: {:?}", result);
// Verify the config was added correctly
let config_content = std::fs::read_to_string(&config_path)
.expect("Failed to read config file");
let config: serde_json::Value = serde_json::from_str(&config_content)
.expect("Failed to parse config");
assert!(config["mcpServers"]["test_server"].is_object());
assert_eq!(config["mcpServers"]["test_server"]["command"], "npx");
assert_eq!(config["mcpServers"]["test_server"]["args"][0], "-y");
assert_eq!(config["mcpServers"]["test_server"]["args"][1], "test-server");
// Clean up
std::fs::remove_file(&config_path).expect("Failed to remove config file");
}
#[test]
fn test_add_server_config_existing_servers() {
let app = mock_app();
let app_path = get_jan_data_folder_path(app.handle().clone());
let config_path = app_path.join("mcp_config_test_existing.json");
// Ensure the directory exists
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).expect("Failed to create parent directory");
}
// Create config file with existing server
let initial_config = serde_json::json!({
"mcpServers": {
"existing_server": {
"command": "existing_command",
"args": ["arg1"],
"active": true
}
}
});
let mut file = File::create(&config_path).expect("Failed to create config file");
file.write_all(serde_json::to_string_pretty(&initial_config).unwrap().as_bytes())
.expect("Failed to write to config file");
drop(file);
// Add new server
let new_server_value = serde_json::json!({
"command": "new_command",
"args": ["new_arg"],
"active": false
});
let result = add_server_config_with_path(
app.handle().clone(),
"new_server".to_string(),
new_server_value,
Some("mcp_config_test_existing.json"),
);
assert!(result.is_ok(), "Failed to add server config: {:?}", result);
// Verify both servers exist
let config_content = std::fs::read_to_string(&config_path)
.expect("Failed to read config file");
let config: serde_json::Value = serde_json::from_str(&config_content)
.expect("Failed to parse config");
// Check existing server is still there
assert!(config["mcpServers"]["existing_server"].is_object());
assert_eq!(config["mcpServers"]["existing_server"]["command"], "existing_command");
// Check new server was added
assert!(config["mcpServers"]["new_server"].is_object());
assert_eq!(config["mcpServers"]["new_server"]["command"], "new_command");
// Clean up
std::fs::remove_file(&config_path).expect("Failed to remove config file");
}
#[test]
fn test_add_server_config_missing_config_file() {
let app = mock_app();
let app_path = get_jan_data_folder_path(app.handle().clone());
// Ensure the directory exists
if let Some(parent) = app_path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::create_dir_all(&app_path).ok();
let config_path = app_path.join("mcp_config.json");
// Ensure the file doesn't exist
if config_path.exists() {
std::fs::remove_file(&config_path).ok();
}
let server_value = serde_json::json!({
"command": "test",
"args": [],
"active": false
});
let result = add_server_config(
app.handle().clone(),
"test".to_string(),
server_value,
);
assert!(result.is_err(), "Expected error when config file doesn't exist");
assert!(result.unwrap_err().contains("Failed to read config file"));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn test_bin_path_construction_with_join() {
// Test that PathBuf::join properly constructs paths
let bin_path = PathBuf::from("/usr/local/bin");
let bun_path = bin_path.join("bun");
assert_eq!(bun_path.to_string_lossy(), "/usr/local/bin/bun");
// Test conversion to String via display()
let bun_path_str = bun_path.display().to_string();
assert_eq!(bun_path_str, "/usr/local/bin/bun");
}
#[cfg(not(target_os = "windows"))]
#[test]
fn test_uv_path_construction_with_join() {
// Test that PathBuf::join properly constructs paths for uv
let bin_path = PathBuf::from("/usr/local/bin");
let uv_path = bin_path.join("uv");
assert_eq!(uv_path.to_string_lossy(), "/usr/local/bin/uv");
// Test conversion to String via display()
let uv_path_str = uv_path.display().to_string();
assert_eq!(uv_path_str, "/usr/local/bin/uv");
}
#[cfg(target_os = "windows")]
#[test]
fn test_bin_path_construction_windows() {
// Test Windows-style paths
let bin_path = PathBuf::from(r"C:\Program Files\bin");
let bun_path = bin_path.join("bun.exe");
assert_eq!(bun_path.to_string_lossy(), r"C:\Program Files\bin\bun.exe");
let bun_path_str = bun_path.display().to_string();
assert_eq!(bun_path_str, r"C:\Program Files\bin\bun.exe");
}

View File

@ -3,10 +3,11 @@ use std::{
fs::{self, File}, fs::{self, File},
io::Read, io::Read,
path::PathBuf, path::PathBuf,
sync::Arc,
}; };
use tar::Archive; use tar::Archive;
use tauri::{ use tauri::{
App, Emitter, Manager, Runtime, App, Emitter, Manager, Runtime, Wry
}; };
#[cfg(desktop)] #[cfg(desktop)]
@ -14,32 +15,15 @@ use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem}, menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
}; };
use tauri_plugin_store::StoreExt; use tauri_plugin_store::Store;
// use tokio::sync::Mutex;
// use tokio::time::{sleep, Duration}; // Using tokio::sync::Mutex use crate::core::mcp::helpers::add_server_config;
// // MCP
// MCP
use super::{ use super::{
app::commands::get_jan_data_folder_path, extensions::commands::get_jan_extensions_path, extensions::commands::get_jan_extensions_path, mcp::helpers::run_mcp_commands, state::AppState,
mcp::helpers::run_mcp_commands, state::AppState,
}; };
pub fn install_extensions<R: Runtime>(app: tauri::AppHandle<R>, force: bool) -> Result<(), String> { pub fn install_extensions<R: Runtime>(app: tauri::AppHandle<R>, force: bool) -> Result<(), String> {
let mut store_path = get_jan_data_folder_path(app.clone());
store_path.push("store.json");
let store = app.store(store_path).expect("Store not initialized");
let stored_version = store
.get("version")
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
let app_version = app
.config()
.version
.clone()
.unwrap_or_else(|| "".to_string());
let extensions_path = get_jan_extensions_path(app.clone()); let extensions_path = get_jan_extensions_path(app.clone());
let pre_install_path = app let pre_install_path = app
.path() .path()
@ -54,13 +38,8 @@ pub fn install_extensions<R: Runtime>(app: tauri::AppHandle<R>, force: bool) ->
if std::env::var("IS_CLEAN").is_ok() { if std::env::var("IS_CLEAN").is_ok() {
clean_up = true; clean_up = true;
} }
log::info!( log::info!("Installing extensions. Clean up: {}", clean_up);
"Installing extensions. Clean up: {}, Stored version: {}, App version: {}", if !clean_up && extensions_path.exists() {
clean_up,
stored_version,
app_version
);
if !clean_up && stored_version == app_version && extensions_path.exists() {
return Ok(()); return Ok(());
} }
@ -164,10 +143,36 @@ pub fn install_extensions<R: Runtime>(app: tauri::AppHandle<R>, force: bool) ->
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Store the new app version Ok(())
store.set("version", serde_json::json!(app_version)); }
store.save().expect("Failed to save store");
// Migrate MCP servers configuration
pub fn migrate_mcp_servers(
app_handle: tauri::AppHandle,
store: Arc<Store<Wry>>,
) -> Result<(), String> {
let mcp_version = store
.get("mcp_version")
.and_then(|v| v.as_i64())
.unwrap_or_else(|| 0);
if mcp_version < 1 {
log::info!("Migrating MCP schema version 1");
let result = add_server_config(
app_handle,
"exa".to_string(),
serde_json::json!({
"command": "npx",
"args": ["-y", "exa-mcp-server"],
"env": { "EXA_API_KEY": "YOUR_EXA_API_KEY_HERE" },
"active": false
}),
);
if let Err(e) = result {
log::error!("Failed to add server config: {}", e);
}
}
store.set("mcp_version", 1);
store.save().expect("Failed to save store");
Ok(()) Ok(())
} }

View File

@ -150,6 +150,9 @@ pub async fn create_message<R: Runtime>(
let data = serde_json::to_string(&message).map_err(|e| e.to_string())?; let data = serde_json::to_string(&message).map_err(|e| e.to_string())?;
writeln!(file, "{}", data).map_err(|e| e.to_string())?; writeln!(file, "{}", data).map_err(|e| e.to_string())?;
// Explicitly flush to ensure data is written before returning
file.flush().map_err(|e| e.to_string())?;
} }
Ok(message) Ok(message)

View File

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

View File

@ -1,103 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Thread {
pub id: String,
pub object: String,
pub title: String,
pub assistants: Vec<ThreadAssistantInfo>,
pub created: i64,
pub updated: i64,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ThreadMessage {
pub id: String,
pub object: String,
pub thread_id: String,
pub assistant_id: Option<String>,
pub attachments: Option<Vec<Attachment>>,
pub role: String,
pub content: Vec<ThreadContent>,
pub status: String,
pub created_at: i64,
pub completed_at: i64,
pub metadata: Option<serde_json::Value>,
pub type_: Option<String>,
pub error_code: Option<String>,
pub tool_call_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Attachment {
pub file_id: Option<String>,
pub tools: Option<Vec<Tool>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum Tool {
#[serde(rename = "file_search")]
FileSearch,
#[serde(rename = "code_interpreter")]
CodeInterpreter,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ThreadContent {
pub type_: String,
pub text: Option<ContentValue>,
pub image_url: Option<ImageContentValue>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentValue {
pub value: String,
pub annotations: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ImageContentValue {
pub detail: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ThreadAssistantInfo {
pub id: String,
pub name: String,
pub model: ModelInfo,
pub instructions: Option<String>,
pub tools: Option<Vec<AssistantTool>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub settings: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum AssistantTool {
#[serde(rename = "code_interpreter")]
CodeInterpreter,
#[serde(rename = "retrieval")]
Retrieval,
#[serde(rename = "function")]
Function {
name: String,
description: Option<String>,
parameters: Option<serde_json::Value>,
},
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ThreadState {
pub has_more: bool,
pub waiting_for_response: bool,
pub error: Option<String>,
pub last_message: Option<String>,
}

View File

@ -88,7 +88,7 @@ async fn test_create_and_list_messages() {
let messages = list_messages(app.handle().clone(), thread_id.clone()) let messages = list_messages(app.handle().clone(), thread_id.clone())
.await .await
.unwrap(); .unwrap();
assert!(messages.len() > 0); assert!(messages.len() > 0, "Expected at least one message, but got none. Thread ID: {}", thread_id);
assert_eq!(messages[0]["role"], "user"); assert_eq!(messages[0]["role"], "user");
// Clean up // Clean up

View File

@ -10,6 +10,7 @@ use jan_utils::generate_app_token;
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use tauri::{Emitter, Manager, RunEvent}; use tauri::{Emitter, Manager, RunEvent};
use tauri_plugin_llamacpp::cleanup_llama_processes; use tauri_plugin_llamacpp::cleanup_llama_processes;
use tauri_plugin_store::StoreExt;
use tokio::sync::Mutex; use tokio::sync::Mutex;
#[cfg_attr(all(mobile, any(target_os = "android", target_os = "ios")), tauri::mobile_entry_point)] #[cfg_attr(all(mobile, any(target_os = "android", target_os = "ios")), tauri::mobile_entry_point)]
@ -134,11 +135,46 @@ pub fn run() {
)?; )?;
#[cfg(not(any(target_os = "ios", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "android")))]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Install extensions
if let Err(e) = setup::install_extensions(app.handle().clone(), false) { // Start migration
let mut store_path = get_jan_data_folder_path(app.handle().clone());
store_path.push("store.json");
let store = app
.handle()
.store(store_path)
.expect("Store not initialized");
let stored_version = store
.get("version")
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
let app_version = app
.config()
.version
.clone()
.unwrap_or_else(|| "".to_string());
// Migrate extensions
if let Err(e) =
setup::install_extensions(app.handle().clone(), stored_version != app_version)
{
log::error!("Failed to install extensions: {}", e); log::error!("Failed to install extensions: {}", e);
} }
// Migrate MCP servers
if let Err(e) = setup::migrate_mcp_servers(app.handle().clone(), store.clone()) {
log::error!("Failed to migrate MCP servers: {}", e);
}
// Store the new app version
store.set("version", serde_json::json!(app_version));
store.save().expect("Failed to save store");
// Migration completed
#[cfg(desktop)]
if option_env!("ENABLE_SYSTEM_TRAY_ICON").unwrap_or("false") == "true" {
log::info!("Enabling system tray icon");
let _ = setup::setup_tray(app);
}
#[cfg(all(feature = "deep-link", any(windows, target_os = "linux")))] #[cfg(all(feature = "deep-link", any(windows, target_os = "linux")))]
{ {
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;

View File

@ -76,6 +76,7 @@
} }
}, },
"bundle": { "bundle": {
"publisher": "Menlo Research Pte. Ltd.",
"active": true, "active": true,
"createUpdaterArtifacts": false, "createUpdaterArtifacts": false,
"icon": [ "icon": [

View File

@ -49,4 +49,4 @@ describe('i18n module', () => {
expect(i18nModule[exportName]).toBeDefined() expect(i18nModule[exportName]).toBeDefined()
}) })
}) })
}) })

View File

@ -76,4 +76,4 @@ describe('main.tsx', () => {
await import('../main') await import('../main')
}).rejects.toThrow() }).rejects.toThrow()
}) })
}) })

View File

@ -416,4 +416,4 @@ describe('Dialog Components', () => {
expect(screen.getByText('Dialog description')).toHaveAttribute('data-slot', 'dialog-description') expect(screen.getByText('Dialog description')).toHaveAttribute('data-slot', 'dialog-description')
expect(screen.getByText('Footer button').closest('div')).toHaveAttribute('data-slot', 'dialog-footer') expect(screen.getByText('Footer button').closest('div')).toHaveAttribute('data-slot', 'dialog-footer')
}) })
}) })

View File

@ -853,4 +853,4 @@ describe('DropdownMenu Components', () => {
expect(handleItemClick).toHaveBeenCalledTimes(1) expect(handleItemClick).toHaveBeenCalledTimes(1)
}) })
}) })
}) })

View File

@ -530,4 +530,4 @@ describe('DropDrawer Component', () => {
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog') expect(trigger).toHaveAttribute('aria-haspopup', 'dialog')
}) })
}) })
}) })

View File

@ -165,4 +165,4 @@ describe('HoverCard Components', () => {
expect(screen.getByText('Hover content')).toBeDefined() expect(screen.getByText('Hover content')).toBeDefined()
}) })
}) })
}) })

View File

@ -93,4 +93,4 @@ describe('Input', () => {
fireEvent.blur(input) fireEvent.blur(input)
expect(handleBlur).toHaveBeenCalledTimes(1) expect(handleBlur).toHaveBeenCalledTimes(1)
}) })
}) })

View File

@ -436,4 +436,4 @@ describe('Popover Components', () => {
}) })
}) })
}) })
}) })

View File

@ -84,4 +84,4 @@ describe('Progress', () => {
// For values over 100, the transform should be positive // For values over 100, the transform should be positive
expect(indicator?.style.transform).toContain('translateX(--50%)') expect(indicator?.style.transform).toContain('translateX(--50%)')
}) })
}) })

View File

@ -59,4 +59,4 @@ describe('RadioGroup', () => {
expect(screen.getByLabelText('HTTP')).toBeChecked() expect(screen.getByLabelText('HTTP')).toBeChecked()
expect(screen.getByLabelText('SSE')).not.toBeChecked() expect(screen.getByLabelText('SSE')).not.toBeChecked()
}) })
}) })

View File

@ -260,4 +260,4 @@ describe('Sheet Components', () => {
expect(screen.getByText('Main Content')).toBeInTheDocument() expect(screen.getByText('Main Content')).toBeInTheDocument()
expect(screen.getByText('Close')).toBeInTheDocument() expect(screen.getByText('Close')).toBeInTheDocument()
}) })
}) })

View File

@ -61,4 +61,4 @@ describe('Skeleton', () => {
expect(skeleton).toHaveClass('w-full') expect(skeleton).toHaveClass('w-full')
expect(skeleton).toHaveClass('bg-red-500') expect(skeleton).toHaveClass('bg-red-500')
}) })
}) })

View File

@ -190,4 +190,4 @@ describe('Slider', () => {
expect(thumb).toHaveClass('border-accent', 'bg-main-view', 'rounded-full') expect(thumb).toHaveClass('border-accent', 'bg-main-view', 'rounded-full')
}) })
}) })
}) })

View File

@ -90,4 +90,4 @@ describe('Toaster Component', () => {
expect(toaster).toHaveAttribute('data-rich-colors', 'true') expect(toaster).toHaveAttribute('data-rich-colors', 'true')
expect(toaster).toHaveAttribute('data-close-button', 'true') expect(toaster).toHaveAttribute('data-close-button', 'true')
}) })
}) })

View File

@ -189,4 +189,4 @@ describe('Switch', () => {
const switchElement = document.querySelector('[data-slot="switch"]') const switchElement = document.querySelector('[data-slot="switch"]')
expect(switchElement).toHaveClass('data-[state=unchecked]:bg-main-view-fg/20') expect(switchElement).toHaveClass('data-[state=unchecked]:bg-main-view-fg/20')
}) })
}) })

View File

@ -113,4 +113,4 @@ describe('Textarea', () => {
const textarea = screen.getByRole('textbox') const textarea = screen.getByRole('textbox')
expect(textarea).toHaveAttribute('cols', '50') expect(textarea).toHaveAttribute('cols', '50')
}) })
}) })

View File

@ -111,4 +111,4 @@ describe('Tooltip Components', () => {
expect(screen.getByText('First')).toBeInTheDocument() expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument() expect(screen.getByText('Second')).toBeInTheDocument()
}) })
}) })

View File

@ -39,4 +39,4 @@ const RadioGroupItem = React.forwardRef<
}) })
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem } export { RadioGroup, RadioGroupItem }

View File

@ -33,4 +33,4 @@ describe('windows constants', () => {
expect(value.length).toBeGreaterThan(0) expect(value.length).toBeGreaterThan(0)
}) })
}) })
}) })

View File

@ -3,4 +3,4 @@
*/ */
export const TEMPORARY_CHAT_ID = 'temporary-chat' export const TEMPORARY_CHAT_ID = 'temporary-chat'
export const TEMPORARY_CHAT_QUERY_ID = 'temporary-chat' export const TEMPORARY_CHAT_QUERY_ID = 'temporary-chat'

View File

@ -38,6 +38,9 @@ import { useTools } from '@/hooks/useTools'
import { TokenCounter } from '@/components/TokenCounter' import { TokenCounter } from '@/components/TokenCounter'
import { useMessages } from '@/hooks/useMessages' import { useMessages } from '@/hooks/useMessages'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { McpExtensionToolLoader } from './McpExtensionToolLoader'
import { ExtensionTypeEnum, MCPExtension } from '@janhq/core'
import { ExtensionManager } from '@/lib/extension'
type ChatInputProps = { type ChatInputProps = {
className?: string className?: string
@ -171,6 +174,12 @@ const ChatInput = ({
// Check if there are active MCP servers // Check if there are active MCP servers
const hasActiveMCPServers = connectedServers.length > 0 || tools.length > 0 const hasActiveMCPServers = connectedServers.length > 0 || tools.length > 0
// Get MCP extension and its custom component
const extensionManager = ExtensionManager.getInstance()
const mcpExtension = extensionManager.get<MCPExtension>(ExtensionTypeEnum.MCP)
const MCPToolComponent = mcpExtension?.getToolComponent?.()
const handleSendMesage = (prompt: string) => { const handleSendMesage = (prompt: string) => {
if (!selectedModel) { if (!selectedModel) {
setMessage('Please select a model to start chatting.') setMessage('Please select a model to start chatting.')
@ -719,60 +728,72 @@ const ChatInput = ({
{selectedModel?.capabilities?.includes('tools') && {selectedModel?.capabilities?.includes('tools') &&
hasActiveMCPServers && ( hasActiveMCPServers && (
<TooltipProvider> MCPToolComponent ? (
<Tooltip // Use custom MCP component
open={tooltipToolsAvailable} <McpExtensionToolLoader
onOpenChange={setTooltipToolsAvailable} tools={tools}
> hasActiveMCPServers={hasActiveMCPServers}
<TooltipTrigger selectedModelHasTools={selectedModel?.capabilities?.includes('tools') ?? false}
asChild initialMessage={initialMessage}
disabled={dropdownToolsAvailable} MCPToolComponent={MCPToolComponent}
/>
) : (
// Use default tools dropdown
<TooltipProvider>
<Tooltip
open={tooltipToolsAvailable}
onOpenChange={setTooltipToolsAvailable}
> >
<div <TooltipTrigger
onClick={(e) => { asChild
setDropdownToolsAvailable(false) disabled={dropdownToolsAvailable}
e.stopPropagation()
}}
> >
<DropdownToolsAvailable <div
initialMessage={initialMessage} onClick={(e) => {
onOpenChange={(isOpen) => { setDropdownToolsAvailable(false)
setDropdownToolsAvailable(isOpen) e.stopPropagation()
if (isOpen) {
setTooltipToolsAvailable(false)
}
}} }}
> >
{(isOpen, toolsCount) => { <DropdownToolsAvailable
return ( initialMessage={initialMessage}
<div onOpenChange={(isOpen) => {
className={cn( setDropdownToolsAvailable(isOpen)
'h-7 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1 cursor-pointer relative', if (isOpen) {
isOpen && 'bg-main-view-fg/10' setTooltipToolsAvailable(false)
)} }
> }}
<IconTool >
size={18} {(isOpen, toolsCount) => {
className="text-main-view-fg/50" return (
/> <div
{toolsCount > 0 && ( className={cn(
<div className="absolute -top-2 -right-2 bg-accent text-accent-fg text-xs rounded-full size-5 flex items-center justify-center font-medium"> 'h-7 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1 cursor-pointer relative',
<span className="leading-0 text-xs"> isOpen && 'bg-main-view-fg/10'
{toolsCount > 99 ? '99+' : toolsCount} )}
</span> >
</div> <IconTool
)} size={18}
</div> className="text-main-view-fg/50"
) />
}} {toolsCount > 0 && (
</DropdownToolsAvailable> <div className="absolute -top-2 -right-2 bg-accent text-accent-fg text-xs rounded-full size-5 flex items-center justify-center font-medium">
</div> <span className="leading-0 text-xs">
</TooltipTrigger> {toolsCount > 99 ? '99+' : toolsCount}
<TooltipContent> </span>
<p>{t('tools')}</p> </div>
</TooltipContent> )}
</Tooltip> </div>
</TooltipProvider> )
}}
</DropdownToolsAvailable>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('tools')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
)} )}
{selectedModel?.capabilities?.includes('web_search') && ( {selectedModel?.capabilities?.includes('web_search') && (
<TooltipProvider> <TooltipProvider>

View File

@ -1,4 +1,4 @@
import { Link, useRouterState } from '@tanstack/react-router' import { Link, useRouterState, useNavigate } from '@tanstack/react-router'
import { useLeftPanel } from '@/hooks/useLeftPanel' import { useLeftPanel } from '@/hooks/useLeftPanel'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
@ -58,6 +58,9 @@ const mainMenus = [
route: route.project, route: route.project,
isEnabled: true, isEnabled: true,
}, },
]
const secondaryMenus = [
{ {
title: 'common:assistants', title: 'common:assistants',
icon: IconClipboardSmile, icon: IconClipboardSmile,
@ -82,6 +85,7 @@ const LeftPanel = () => {
const open = useLeftPanel((state) => state.open) const open = useLeftPanel((state) => state.open)
const setLeftPanel = useLeftPanel((state) => state.setLeftPanel) const setLeftPanel = useLeftPanel((state) => state.setLeftPanel)
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth()
@ -213,7 +217,12 @@ const LeftPanel = () => {
if (editingProjectKey) { if (editingProjectKey) {
updateFolder(editingProjectKey, name) updateFolder(editingProjectKey, name)
} else { } else {
addFolder(name) const newProject = addFolder(name)
// Navigate to the newly created project
navigate({
to: '/project/$projectId',
params: { projectId: newProject.id },
})
} }
setProjectDialogOpen(false) setProjectDialogOpen(false)
setEditingProjectKey(null) setEditingProjectKey(null)
@ -488,7 +497,7 @@ const LeftPanel = () => {
)} )}
<div className="flex flex-col h-full overflow-y-scroll w-[calc(100%+6px)]"> <div className="flex flex-col h-full overflow-y-scroll w-[calc(100%+6px)]">
<div className="flex flex-col w-full h-full overflow-y-auto overflow-x-hidden"> <div className="flex flex-col w-full h-full overflow-y-auto overflow-x-hidden mb-3">
<div className="h-full w-full overflow-y-auto"> <div className="h-full w-full overflow-y-auto">
{favoritedThreads.length > 0 && ( {favoritedThreads.length > 0 && (
<> <>
@ -608,6 +617,44 @@ const LeftPanel = () => {
</div> </div>
</div> </div>
</div> </div>
{secondaryMenus.map((menu) => {
if (!menu.isEnabled) {
return null
}
// Regular menu items must have route and icon
if (!menu.route || !menu.icon) return null
const isActive = (() => {
// Settings routes
if (menu.route.includes(route.settings.index)) {
return currentPath.includes(route.settings.index)
}
// Default exact match for other routes
return currentPath === menu.route
})()
return (
<Link
key={menu.title}
to={menu.route}
onClick={() => isSmallScreen && setLeftPanel(false)}
data-test-id={`menu-${menu.title}`}
activeOptions={{ exact: true }}
className={cn(
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded',
isActive && 'bg-left-panel-fg/10'
)}
>
<menu.icon size={18} className="text-left-panel-fg/70" />
<span className="font-medium text-left-panel-fg/90">
{t(menu.title)}
</span>
</Link>
)
})}
{PlatformFeatures[PlatformFeature.AUTHENTICATION] && ( {PlatformFeatures[PlatformFeature.AUTHENTICATION] && (
<div className="space-y-1 shrink-0 py-1"> <div className="space-y-1 shrink-0 py-1">
<div> <div>

View File

@ -0,0 +1,61 @@
import { ComponentType } from 'react'
import { MCPTool, MCPToolComponentProps } from '@janhq/core'
import { useToolAvailable } from '@/hooks/useToolAvailable'
import { useThreads } from '@/hooks/useThreads'
interface McpExtensionToolLoaderProps {
tools: MCPTool[]
hasActiveMCPServers: boolean
selectedModelHasTools: boolean
initialMessage?: boolean
MCPToolComponent?: ComponentType<MCPToolComponentProps> | null
}
export const McpExtensionToolLoader = ({
tools,
hasActiveMCPServers,
selectedModelHasTools,
initialMessage,
MCPToolComponent,
}: McpExtensionToolLoaderProps) => {
// Get tool management hooks
const { isToolDisabled, setToolDisabledForThread, setDefaultDisabledTools, getDefaultDisabledTools } = useToolAvailable()
const { getCurrentThread } = useThreads()
const currentThread = getCurrentThread()
// Handle tool toggle for custom component
const handleToolToggle = (toolName: string, enabled: boolean) => {
if (initialMessage) {
const currentDefaults = getDefaultDisabledTools()
if (enabled) {
setDefaultDisabledTools(currentDefaults.filter((name) => name !== toolName))
} else {
setDefaultDisabledTools([...currentDefaults, toolName])
}
} else if (currentThread?.id) {
setToolDisabledForThread(currentThread.id, toolName, enabled)
}
}
const isToolEnabled = (toolName: string): boolean => {
if (initialMessage) {
return !getDefaultDisabledTools().includes(toolName)
} else if (currentThread?.id) {
return !isToolDisabled(currentThread.id, toolName)
}
return false
}
// Only render if we have the custom MCP component and conditions are met
if (!selectedModelHasTools || !hasActiveMCPServers || !MCPToolComponent) {
return null
}
return (
<MCPToolComponent
tools={tools}
isToolEnabled={isToolEnabled}
onToolToggle={handleToolToggle}
/>
)
}

View File

@ -1,4 +1,3 @@
/* eslint-disable react-hooks/exhaustive-deps */
import ReactMarkdown, { Components } from 'react-markdown' import ReactMarkdown, { Components } from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkEmoji from 'remark-emoji' import remarkEmoji from 'remark-emoji'

View File

@ -3,6 +3,7 @@ import { create } from 'zustand'
import { RenderMarkdown } from './RenderMarkdown' import { RenderMarkdown } from './RenderMarkdown'
import { useAppState } from '@/hooks/useAppState' import { useAppState } from '@/hooks/useAppState'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { extractThinkingContent } from '@/lib/utils'
interface Props { interface Props {
text: string text: string
@ -43,19 +44,6 @@ const ThinkingBlock = ({ id, text }: Props) => {
setThinkingState(id, newExpandedState) setThinkingState(id, newExpandedState)
} }
// Extract thinking content from either format
const extractThinkingContent = (text: string) => {
return text
.replace(/<\/?think>/g, '')
.replace(/<\|channel\|>analysis<\|message\|>/g, '')
.replace(/<\|start\|>assistant<\|channel\|>final<\|message\|>/g, '')
.replace(/assistant<\|channel\|>final<\|message\|>/g, '')
.replace(/<\|channel\|>/g, '') // remove any remaining channel markers
.replace(/<\|message\|>/g, '') // remove any remaining message markers
.replace(/<\|start\|>/g, '') // remove any remaining start markers
.trim()
}
const thinkingContent = extractThinkingContent(text) const thinkingContent = extractThinkingContent(text)
if (!thinkingContent) return null if (!thinkingContent) return null

View File

@ -23,7 +23,7 @@ import { useThreads } from '@/hooks/useThreads'
import { useThreadManagement } from '@/hooks/useThreadManagement' import { useThreadManagement } from '@/hooks/useThreadManagement'
import { useLeftPanel } from '@/hooks/useLeftPanel' import { useLeftPanel } from '@/hooks/useLeftPanel'
import { useMessages } from '@/hooks/useMessages' import { useMessages } from '@/hooks/useMessages'
import { cn } from '@/lib/utils' import { cn, extractThinkingContent } from '@/lib/utils'
import { useSmallScreen } from '@/hooks/useMediaQuery' import { useSmallScreen } from '@/hooks/useMediaQuery'
import { import {
@ -167,14 +167,10 @@ const SortableItem = memo(
)} )}
> >
<span>{thread.title || t('common:newThread')}</span> <span>{thread.title || t('common:newThread')}</span>
{variant === 'project' && ( {variant === 'project' && getLastMessageInfo?.content && (
<> <span className="block text-sm text-main-view-fg/60 mt-0.5 truncate">
{variant === 'project' && getLastMessageInfo?.content && ( {extractThinkingContent(getLastMessageInfo.content)}
<div className="text-sm text-main-view-fg/60 mt-0.5 line-clamp-2"> </span>
{getLastMessageInfo.content}
</div>
)}
</>
)} )}
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
@ -185,7 +181,10 @@ const SortableItem = memo(
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<IconDots <IconDots
size={14} size={14}
className="text-left-panel-fg/60 shrink-0 cursor-pointer px-0.5 -mr-1 data-[state=open]:bg-left-panel-fg/10 rounded group-hover/thread-list:data-[state=closed]:size-5 size-5 data-[state=closed]:size-0" className={cn(
'text-left-panel-fg/60 shrink-0 cursor-pointer px-0.5 -mr-1 data-[state=open]:bg-left-panel-fg/10 rounded group-hover/thread-list:data-[state=closed]:size-5 size-5 data-[state=closed]:size-0',
variant === 'project' && 'text-main-view-fg/60'
)}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()

View File

@ -0,0 +1,19 @@
import { useThreadScrolling } from '@/hooks/useThreadScrolling'
export const ThreadPadding = ({
threadId,
scrollContainerRef,
}: {
threadId: string
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}) => {
// Get padding height for ChatGPT-style message positioning
const { paddingHeight } = useThreadScrolling(threadId, scrollContainerRef)
return (
<div
style={{ height: paddingHeight }}
className="flex-shrink-0"
data-testid="chat-padding"
/>
)
}

View File

@ -121,4 +121,4 @@ describe('AvatarEmoji Component', () => {
const img = screen.getByRole('img') const img = screen.getByRole('img')
expect(img).toHaveAttribute('alt', 'Custom avatar') expect(img).toHaveAttribute('alt', 'Custom avatar')
}) })
}) })

View File

@ -36,4 +36,4 @@ describe('ChatInput Simple Tests', () => {
const sendButton = screen.getByTestId('send-message-button') const sendButton = screen.getByTestId('send-message-button')
expect(sendButton).toHaveTextContent('Send') expect(sendButton).toHaveTextContent('Send')
}) })
}) })

View File

@ -446,4 +446,4 @@ describe('ChatInput', () => {
expect(() => renderWithRouter()).not.toThrow() expect(() => renderWithRouter()).not.toThrow()
}) })
}) })
}) })

View File

@ -274,4 +274,4 @@ describe('DropdownModelProvider - Display Name Integration', () => {
// Both models are still visible in the dropdown, so we can't test for absence // Both models are still visible in the dropdown, so we can't test for absence
expect(screen.getAllByText('Short Name')).toHaveLength(2) // trigger + dropdown expect(screen.getAllByText('Short Name')).toHaveLength(2) // trigger + dropdown
}) })
}) })

View File

@ -181,4 +181,4 @@ describe('DialogEditModel - Basic Component Tests', () => {
expect(mockUpdateProvider).toBeDefined() expect(mockUpdateProvider).toBeDefined()
expect(mockSetProviders).toBeDefined() expect(mockSetProviders).toBeDefined()
}) })
}) })

View File

@ -266,4 +266,4 @@ describe('LeftPanel', () => {
const toggleButton = document.querySelector('svg.tabler-icon-layout-sidebar') const toggleButton = document.querySelector('svg.tabler-icon-layout-sidebar')
expect(toggleButton).not.toBeNull() expect(toggleButton).not.toBeNull()
}) })
}) })

View File

@ -168,4 +168,4 @@ describe('SetupScreen', () => {
// Component should handle model installation process // Component should handle model installation process
expect(screen.getByTestId('setup-screen')).toBeInTheDocument() expect(screen.getByTestId('setup-screen')).toBeInTheDocument()
}) })
}) })

View File

@ -17,6 +17,7 @@ import { getProviderTitle } from '@/lib/utils'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { ModelCapabilities } from '@/types/models' import { ModelCapabilities } from '@/types/models'
import { models as providerModels } from 'token.js' import { models as providerModels } from 'token.js'
import { toast } from 'sonner'
type DialogAddModelProps = { type DialogAddModelProps = {
provider: ModelProvider provider: ModelProvider
@ -37,8 +38,13 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => {
// Handle form submission // Handle form submission
const handleSubmit = () => { const handleSubmit = () => {
if (!modelId.trim()) { if (!modelId.trim()) return // Don't submit if model ID is empty
return // Don't submit if model ID is empty
if (provider.models.some((e) => e.id === modelId)) {
toast.error(t('providers:addModel.modelExists'), {
description: t('providers:addModel.modelExistsDesc'),
})
return // Don't submit if model ID already exists
} }
// Create the new model // Create the new model

View File

@ -75,4 +75,4 @@ export function DeleteAssistantDialog({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
} }

View File

@ -77,4 +77,4 @@ export function FactoryResetDialog({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
} }

View File

@ -142,4 +142,4 @@ describe('useAnalytic', () => {
expect(result.current.productAnalytic).toBe(false) expect(result.current.productAnalytic).toBe(false)
}) })
}) })
}) })

View File

@ -201,4 +201,4 @@ describe('useAppState', () => {
expect(result.current.tokenSpeed).toBeUndefined() expect(result.current.tokenSpeed).toBeUndefined()
}) })
}) })

View File

@ -406,4 +406,4 @@ describe('useAppUpdater', () => {
expect(mockEvents.emit).toHaveBeenCalledWith('onAppUpdateDownloadSuccess', {}) expect(mockEvents.emit).toHaveBeenCalledWith('onAppUpdateDownloadSuccess', {})
}) })
}) })
}) })

View File

@ -285,4 +285,4 @@ describe('useAppearance', () => {
expect(result.current.chatWidth).toBe('compact') expect(result.current.chatWidth).toBe('compact')
}) })
}) })
}) })

View File

@ -225,4 +225,4 @@ describe('useChat', () => {
expect(result.current).toBeDefined() expect(result.current).toBeDefined()
}) })
}) })

View File

@ -171,4 +171,4 @@ describe('useClickOutside', () => {
addEventListenerSpy.mockRestore() addEventListenerSpy.mockRestore()
removeEventListenerSpy.mockRestore() removeEventListenerSpy.mockRestore()
}) })
}) })

View File

@ -147,4 +147,4 @@ describe('useCodeblock', () => {
expect(result.current.codeBlockStyle).toBe('preserved-theme') expect(result.current.codeBlockStyle).toBe('preserved-theme')
expect(result.current.showLineNumbers).toBe(false) expect(result.current.showLineNumbers).toBe(false)
}) })
}) })

View File

@ -259,4 +259,4 @@ describe('useDownloadStore', () => {
expect(result.current.localDownloadingModels.has('model-1')).toBe(true) expect(result.current.localDownloadingModels.has('model-1')).toBe(true)
}) })
}) })
}) })

View File

@ -468,4 +468,4 @@ describe('useKeyboardShortcut', () => {
expect(mockCallback).toHaveBeenCalledTimes(1) expect(mockCallback).toHaveBeenCalledTimes(1)
}) })
}) })

View File

@ -143,4 +143,4 @@ describe('useLeftPanel', () => {
expect(result.current.open).toBe(true) expect(result.current.open).toBe(true)
}) })
}) })
}) })

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