Merge pull request #5715 from menloresearch/release/v0.6.6

Merge Release/v0.6.6 into release 0.6.5
This commit is contained in:
Louis 2025-07-07 11:43:04 +07:00 committed by GitHub
commit a2c59e9934
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 2915 additions and 908 deletions

View File

@ -0,0 +1,24 @@
#!/usr/bin/env bash
make clean
# To reproduce https://github.com/menloresearch/jan/pull/5463
TAURI_TOOLKIT_PATH="${XDG_CACHE_HOME:-$HOME/.cache}/tauri"
mkdir -p "$TAURI_TOOLKIT_PATH"
wget https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage -O "$TAURI_TOOLKIT_PATH/linuxdeploy-x86_64.AppImage"
chmod +x "$TAURI_TOOLKIT_PATH/linuxdeploy-x86_64.AppImage"
jq '.bundle.resources = ["resources/pre-install/**/*"] | .bundle.externalBin = ["binaries/cortex-server", "resources/bin/uv"]' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json
mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json
make build-tauri
cp ./src-tauri/resources/bin/bun ./src-tauri/target/release/bundle/appimage/Jan.AppDir/usr/bin/bun
mkdir -p ./src-tauri/target/release/bundle/appimage/Jan.AppDir/usr/lib/Jan/binaries/engines
cp -f ./src-tauri/binaries/deps/*.so* ./src-tauri/target/release/bundle/appimage/Jan.AppDir/usr/lib/Jan/binaries/
cp -f ./src-tauri/binaries/*.so* ./src-tauri/target/release/bundle/appimage/Jan.AppDir/usr/lib/Jan/binaries/
cp -rf ./src-tauri/binaries/engines ./src-tauri/target/release/bundle/appimage/Jan.AppDir/usr/lib/Jan/binaries/
APP_IMAGE=./src-tauri/target/release/bundle/appimage/$(ls ./src-tauri/target/release/bundle/appimage/ | grep AppImage | head -1)
echo $APP_IMAGE
rm -f $APP_IMAGE
/opt/bin/appimagetool ./src-tauri/target/release/bundle/appimage/Jan.AppDir $APP_IMAGE

View File

@ -1,4 +1,20 @@
{
"name": "jan",
"image": "node:20"
"name": "Jan",
"image": "mcr.microsoft.com/devcontainers/base:jammy",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/rust:1": {},
"ghcr.io/devcontainers-extra/features/corepack:1": {}
},
"postCreateCommand": "./.devcontainer/postCreateCommand.sh",
// appimagekit requires fuse to package appimage, to use fuse in the container you need to enable it on the host
"runArgs": [
"--device", "/dev/fuse",
"--cap-add=SYS_ADMIN",
"--security-opt", "apparmor:unconfined"
]
}

View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# install tauri prerequisites + xdg-utils for xdg-open + libfuse2 for using appimagekit
sudo apt update
sudo apt install -yqq libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
xdg-utils \
libfuse2
sudo mkdir -p /opt/bin
sudo wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /opt/bin/appimagetool
sudo chmod +x /opt/bin/appimagetool

24
.github/ISSUE_TEMPLATE/1-bug-report.md vendored Normal file
View File

@ -0,0 +1,24 @@
---
name: 🐛 Bug Report
about: If something isn't working as expected 🤔
title: 'bug: '
type: Bug
---
**Version:** e.g. 0.5.x-xxx
## Describe the Bug
<!-- A clear & concise description of the bug -->
## Steps to Reproduce
1.
## Screenshots / Logs
<!-- You can find logs in: Setting -> General -> Data Folder -> App Logs -->
## Operating System
- [ ] MacOS
- [ ] Windows
- [ ] Linux

View File

@ -0,0 +1,11 @@
---
name: 🚀 Feature Request
about: Suggest an idea for this project 😻!
title: 'idea: '
---
## Problem Statement
<!-- Describe the problem you're facing -->
## Feature Idea
<!-- Describe what you want instead. Examples are welcome! -->

12
.github/ISSUE_TEMPLATE/3-epic.md vendored Normal file
View File

@ -0,0 +1,12 @@
---
name: 🌟 Epic
about: Major building block that advances Jan's goals
title: 'epic: '
type: Epic
---
## Goal
## Tasklist
## Out of scope

13
.github/ISSUE_TEMPLATE/4-goal.md vendored Normal file
View File

@ -0,0 +1,13 @@
---
name: 🎯 Goal
about: External communication of Jan's roadmap and objectives
title: 'goal: '
type: Goal
---
## Goal
## Tasklist
## Out of scope

View File

@ -1,43 +0,0 @@
name: "\U0001F41B Bug Report"
description: "If something isn't working as expected \U0001F914"
labels: [ "type: bug" ]
title: 'bug: [DESCRIPTION]'
body:
- type: input
validations:
required: true
attributes:
label: "Jan version"
description: "**Tip:** The version is in the app's bottom right corner"
placeholder: "e.g. 0.5.x-xxx"
- type: textarea
validations:
required: true
attributes:
label: "Describe the Bug"
description: "A clear & concise description of the bug"
- type: textarea
attributes:
label: "Steps to Reproduce"
description: |
Please list out steps to reproduce the issue
placeholder: |
1. Go to '...'
2. Click on '...'
- type: textarea
attributes:
label: "Screenshots / Logs"
description: |
You can find logs in: ~/jan/logs/app.logs
- type: checkboxes
attributes:
label: "What is your OS?"
options:
- label: MacOS
- label: Windows
- label: Linux

View File

@ -1,7 +1,5 @@
## To encourage contributors to use issue templates, we don't allow blank issues
blank_issues_enabled: true
contact_links:
- name: "\1F4AC Jan Discussions"
url: "https://github.com/orgs/menloresearch/discussions/categories/q-a"
about: "Get help, discuss features & roadmap, and share your projects"
- name: Jan Discussions
url: https://github.com/orgs/menloresearch/discussions/categories/q-a
about: Get help, discuss features & roadmap, and share your projects

View File

@ -1,20 +0,0 @@
name: "\U0001F680 Feature Request"
description: "Suggest an idea for this project \U0001F63B!"
title: 'idea: [DESCRIPTION]'
labels: 'type: feature request'
body:
- type: textarea
validations:
required: true
attributes:
label: "Problem Statement"
description: "Describe the problem you're facing"
placeholder: |
I'm always frustrated when ...
- type: textarea
validations:
required: true
attributes:
label: "Feature Idea"
description: "Describe what you want instead. Examples are welcome!"

View File

@ -1,21 +0,0 @@
name: "\U0001F929 Model Request"
description: "Request a new model to be compiled"
title: 'feat: [DESCRIPTION]'
labels: 'type: model request'
body:
- type: markdown
attributes:
value: "**Tip:** Download any HuggingFace model in app ([see guides](https://jan.ai/docs/models/manage-models#add-models)). Use this form for unsupported models only."
- type: textarea
validations:
required: true
attributes:
label: "Model Requests"
description: "If applicable, include the source URL, licenses, and any other relevant information"
- type: checkboxes
attributes:
label: "Which formats?"
options:
- label: GGUF (llama.cpp)
- label: TensorRT (TensorRT-LLM)
- label: ONNX (Onnx Runtime)

View File

@ -1,35 +0,0 @@
---
name: Roadmap
about: Plan Roadmap items with subtasks
title: 'roadmap: '
labels: 'type: planning'
assignees: ''
---
## Goal
## Tasklist
### Frontend
- [ ] link to janhq/jan epics
**Bugs**
- [ ] link to bugs
### Backend
- [ ] link to janhq/cortex.cpp epics
**Bugs**
- [ ] link to bug issues
### Infra
- [ ] link to infra issues
### Administrative / Management
- [ ] link to infra issues
### Marketing
-------
## Resources

16
.github/workflows/issues.yaml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Adds all issues to project board
on:
issues:
types:
- opened
jobs:
add-to-project:
name: Add issue to project
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v1.0.2
with:
project-url: https://github.com/orgs/${{ vars.ORG_NAME }}/projects/${{ vars.JAN_PROJECT_NUMBER }}
github-token: ${{ secrets.AUTO_ADD_TICKET_PAT }}

View File

@ -154,6 +154,10 @@ jobs:
with:
node-version: 20
- name: Install tauri-driver dependencies
run: |
cargo install tauri-driver --locked
# Clean cache, continue on error
- name: 'Cleanup cache'
shell: powershell
@ -192,11 +196,25 @@ jobs:
with:
fetch-depth: 0
- uses: actions/cache@v4 # v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('**/yarn.lock') }}
- name: Installing node
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install tauri-driver dependencies
run: |
cargo install tauri-driver --locked
# Clean cache, continue on error
- name: 'Cleanup cache'
shell: powershell
@ -221,6 +239,20 @@ jobs:
# run: |
# make update-playwright-config REPORT_PORTAL_URL=${{ secrets.REPORT_PORTAL_URL }} REPORT_PORTAL_API_KEY=${{ secrets.REPORT_PORTAL_API_KEY }} REPORT_PORTAL_PROJECT_NAME=${{ secrets.REPORT_PORTAL_PROJECT_NAME }} REPORT_PORTAL_LAUNCH_NAME="Jan App Windows" REPORT_PORTAL_DESCRIPTION="${{env.REPORT_PORTAL_DESCRIPTION}}"
- name: Install Prerequisites
shell: 'powershell'
# https://github.com/actions/runner-images/issues/9538
# https://github.com/microsoft/playwright/pull/30009/files
# https://github.com/tauri-apps/wry/issues/1268
# Evergreen Bootstrapper
# The Bootstrapper is a tiny installer that downloads
# the Evergreen Runtime matching device architecture
# and installs it locally.
# https://developer.microsoft.com/en-us/microsoft-edge/webview2/consumer/?form=MA13LH
run: |
Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' -OutFile 'setup.exe'
Start-Process -FilePath setup.exe -Verb RunAs -Wait
- name: Linter and test
shell: powershell
run: |
@ -240,6 +272,10 @@ jobs:
with:
node-version: 20
- name: Install tauri-driver dependencies
run: |
cargo install tauri-driver --locked
# Clean cache, continue on error
- name: 'Cleanup cache'
shell: powershell
@ -272,6 +308,15 @@ jobs:
with:
node-version: 20
- name: Install Tauri dependencies
run: |
sudo apt update
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2 webkit2gtk-driver
- name: Install tauri-driver dependencies
run: |
cargo install tauri-driver --locked
- name: 'Cleanup cache'
continue-on-error: true
run: |
@ -368,6 +413,15 @@ jobs:
with:
node-version: 20
- name: Install Tauri dependencies
run: |
sudo apt update
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2 webkit2gtk-driver
- name: Install tauri-driver dependencies
run: |
cargo install tauri-driver --locked
- name: 'Cleanup cache'
continue-on-error: true
run: |

View File

@ -6,7 +6,6 @@ on:
workflow_dispatch:
jobs:
build-and-publish-plugins:
environment: production
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

View File

@ -43,7 +43,6 @@ jobs:
build-linux-x64:
if: inputs.public_provider == 'github' || inputs.public_provider == 'none'
runs-on: ubuntu-latest
environment: production
permissions:
contents: write
steps:
@ -76,7 +75,6 @@ jobs:
cp electron/icons_dev/jan-nightly-tray@2x.png electron/icons/icon-tray@2x.png
cp electron/icons_dev/jan-nightly-tray.png electron/icons/icon-tray.png
- name: Installing node
uses: actions/setup-node@v1
with:
@ -131,7 +129,7 @@ jobs:
env:
VERSION_TAG: ${{ inputs.new_version }}
- name: Build and publish app to aws s3 r2 or github artifactory
- name: Build and publish app to aws s3 r2 or github artifactory
if: inputs.public_provider != 'github'
run: |
# check public_provider is true or not
@ -185,4 +183,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: jan-electron-linux-amd64-${{ inputs.new_version }}-AppImage
path: ./electron/dist/*.AppImage
path: ./electron/dist/*.AppImage

View File

@ -53,7 +53,6 @@ jobs:
build-macos:
if: inputs.public_provider == 'github' || inputs.public_provider == 'none'
runs-on: macos-latest
environment: production
permissions:
contents: write
steps:
@ -61,7 +60,7 @@ jobs:
uses: actions/checkout@v3
with:
ref: ${{ inputs.ref }}
- name: Replace Icons for Beta Build
if: inputs.beta == true && inputs.nightly != true
shell: bash
@ -161,7 +160,7 @@ jobs:
p12-file-base64: ${{ secrets.CODE_SIGN_P12_BASE64 }}
p12-password: ${{ secrets.CODE_SIGN_P12_PASSWORD }}
- name: Build and publish app to aws s3 r2 or github artifactory
- name: Build and publish app to aws s3 r2 or github artifactory
if: inputs.public_provider != 'github'
run: |
# check public_provider is true or not
@ -231,4 +230,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: jan-electron-mac-universal-${{ inputs.new_version }}
path: ./electron/dist/*.dmg
path: ./electron/dist/*.dmg

View File

@ -9,7 +9,6 @@ on:
jobs:
get-update-version:
runs-on: ubuntu-latest
environment: production
outputs:
new_version: ${{ steps.version_update.outputs.new_version }}
steps:

View File

@ -26,7 +26,6 @@ on:
jobs:
noti-discord-and-update-url-readme:
environment: production
runs-on: ubuntu-latest
permissions:
contents: write

View File

@ -55,7 +55,6 @@ jobs:
DEB_SIG: ${{ steps.packageinfo.outputs.DEB_SIG }}
APPIMAGE_SIG: ${{ steps.packageinfo.outputs.APPIMAGE_SIG }}
APPIMAGE_FILE_NAME: ${{ steps.packageinfo.outputs.APPIMAGE_FILE_NAME }}
environment: production
permissions:
contents: write
steps:
@ -96,7 +95,7 @@ jobs:
run: |
cargo install ctoml
- name: Install Tauri dependecies
- name: Install Tauri dependencies
run: |
sudo apt update
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2

View File

@ -63,7 +63,6 @@ jobs:
outputs:
MAC_UNIVERSAL_SIG: ${{ steps.metadata.outputs.MAC_UNIVERSAL_SIG }}
TAR_NAME: ${{ steps.metadata.outputs.TAR_NAME }}
environment: production
permissions:
contents: write
steps:

View File

@ -1,4 +1,7 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
}
}

View File

@ -33,23 +33,14 @@ dev: install-and-build
yarn copy:lib
yarn dev
# Deprecated soon
dev-tauri: install-and-build
yarn install:cortex
yarn download:bin
yarn copy:lib
yarn dev:tauri
# Linting
lint: install-and-build
yarn lint
# Testing
test: lint
# yarn build:test
# yarn test:coverage
# Need e2e setup for tauri backend
yarn test
yarn test:e2e
# Builds and publishes the app
build-and-publish: install-and-build

View File

@ -23,24 +23,24 @@
},
"devDependencies": {
"@npmcli/arborist": "^7.1.0",
"@types/jest": "^29.5.14",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.0",
"@types/pacote": "^11.1.7",
"@types/request": "^2.48.12",
"electron": "33.2.1",
"eslint": "8.57.0",
"eslint-plugin-jest": "^27.9.0",
"jest": "^29.7.0",
"jest": "^30.0.3",
"jest-junit": "^16.0.0",
"jest-runner": "^29.7.0",
"pacote": "^21.0.0",
"request": "^2.88.2",
"request-progress": "^3.0.0",
"rimraf": "^3.0.2",
"rimraf": "^6.0.1",
"rolldown": "1.0.0-beta.1",
"ts-jest": "^29.2.5",
"tslib": "^2.6.2",
"typescript": "^5.3.3"
"typescript": "^5.8.3"
},
"dependencies": {
"rxjs": "^7.8.1",

View File

@ -29,7 +29,7 @@ describe('validationRules', () => {
expect(validationRules.top_k(1)).toBe(true)
expect(validationRules.top_k(0)).toBe(true)
expect(validationRules.top_k(-0.1)).toBe(false)
expect(validationRules.top_k(1.1)).toBe(false)
expect(validationRules.top_k(1.1)).toBe(true)
expect(validationRules.top_k('0.5')).toBe(false)
})
@ -68,8 +68,8 @@ describe('validationRules', () => {
expect(validationRules.frequency_penalty(0.5)).toBe(true)
expect(validationRules.frequency_penalty(1)).toBe(true)
expect(validationRules.frequency_penalty(0)).toBe(true)
expect(validationRules.frequency_penalty(-0.1)).toBe(false)
expect(validationRules.frequency_penalty(1.1)).toBe(false)
expect(validationRules.frequency_penalty(-0.1)).toBe(true)
expect(validationRules.frequency_penalty(1.1)).toBe(true)
expect(validationRules.frequency_penalty('0.5')).toBe(false)
})
@ -77,8 +77,8 @@ describe('validationRules', () => {
expect(validationRules.presence_penalty(0.5)).toBe(true)
expect(validationRules.presence_penalty(1)).toBe(true)
expect(validationRules.presence_penalty(0)).toBe(true)
expect(validationRules.presence_penalty(-0.1)).toBe(false)
expect(validationRules.presence_penalty(1.1)).toBe(false)
expect(validationRules.presence_penalty(-0.1)).toBe(true)
expect(validationRules.presence_penalty(1.1)).toBe(true)
expect(validationRules.presence_penalty('0.5')).toBe(false)
})
@ -255,7 +255,7 @@ describe('extractInferenceParams', () => {
top_p: 0.9,
stream: true,
max_tokens: 50.3,
invalid_param: 'should_be_ignored'
invalid_param: 'should_be_ignored',
}
const result = extractInferenceParams(modelParams as any)
@ -264,7 +264,7 @@ describe('extractInferenceParams', () => {
token_limit: 100,
top_p: 0.9,
stream: true,
max_tokens: 50
max_tokens: 50,
})
})
@ -296,9 +296,9 @@ describe('extractModelLoadParams', () => {
prompt_template: 'template',
llama_model_path: '/path/to/model',
vision_model: false,
invalid_param: 'should_be_ignored'
invalid_param: 'should_be_ignored',
}
const result = extractModelLoadParams(modelParams as any)
expect(result).toEqual({
ctx_len: 2048,
@ -308,23 +308,23 @@ describe('extractModelLoadParams', () => {
cpu_threads: 8,
prompt_template: 'template',
llama_model_path: '/path/to/model',
vision_model: false
vision_model: false,
})
})
it('should handle parameters without validation rules', () => {
const modelParams = {
const modelParams = {
engine: 'llama',
pre_prompt: 'System:',
system_prompt: 'You are helpful',
model_path: '/path'
model_path: '/path',
}
const result = extractModelLoadParams(modelParams as any)
expect(result).toEqual({
engine: 'llama',
pre_prompt: 'System:',
system_prompt: 'You are helpful',
model_path: '/path'
model_path: '/path',
})
})

View File

@ -8,13 +8,13 @@ import { ModelParams, ModelRuntimeParams, ModelSettingParams } from '../../types
export const validationRules: { [key: string]: (value: any) => boolean } = {
temperature: (value: any) => typeof value === 'number' && value >= 0 && value <= 2,
token_limit: (value: any) => Number.isInteger(value) && value >= 0,
top_k: (value: any) => typeof value === 'number' && value >= 0 && value <= 1,
top_k: (value: any) => typeof value === 'number' && value >= 0,
top_p: (value: any) => typeof value === 'number' && value >= 0 && value <= 1,
stream: (value: any) => typeof value === 'boolean',
max_tokens: (value: any) => Number.isInteger(value) && value >= 0,
stop: (value: any) => Array.isArray(value) && value.every((v) => typeof v === 'string'),
frequency_penalty: (value: any) => typeof value === 'number' && value >= 0 && value <= 1,
presence_penalty: (value: any) => typeof value === 'number' && value >= 0 && value <= 1,
frequency_penalty: (value: any) => typeof value === 'number' && value >= -2 && value <= 2,
presence_penalty: (value: any) => typeof value === 'number' && value >= -2 && value <= 2,
repeat_last_n: (value: any) => typeof value === 'number',
repeat_penalty: (value: any) => typeof value === 'number',
min_p: (value: any) => typeof value === 'number',
@ -50,6 +50,22 @@ export const normalizeValue = (key: string, value: any) => {
// Convert to integer
return Math.floor(Number(value))
}
if (
key === 'temperature' ||
key === 'top_k' ||
key === 'top_p' ||
key === 'min_p' ||
key === 'repeat_penalty' ||
key === 'frequency_penalty' ||
key === 'presence_penalty' ||
key === 'repeat_last_n'
) {
// Convert to float
const newValue = parseFloat(value)
if (newValue !== null && !isNaN(newValue)) {
return newValue
}
}
return value
}

View File

@ -27,7 +27,7 @@
"embla-carousel-react": "^8.0.0",
"fs": "^0.0.1-security",
"gray-matter": "^4.0.3",
"lucide-react": "^0.372.0",
"lucide-react": "^0.522.0",
"next": "^14.1.4",
"next-seo": "^6.5.0",
"next-sitemap": "^4.2.3",

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

View File

@ -56,36 +56,37 @@ cd ~/.config/Jan/data # Default install
<Callout type="info">
Root directory: `~/jan`
</Callout>
```sh
/assistants
/jan
/assistants/
/jan/
assistant.json
/extensions
/engines/
/llama.cpp/
/extensions/
extensions.json
/@janhq
/extension_A
package.json
/logs
/app.txt
/models
/model_A
model.yaml
model_A.yaml
/settings
settings.json
/@janhq
/extension_A_Settings
settings.json
/themes
/dark-dimmed
/joi-dark
/joi-light
/night-blue
/threads
/jan_thread_A
messages.jsonl
thread.json
messages.jsonl
/@janhq/
/assistant-extension/
/conversational-extension/
/download-extension/
/engine-management-extension/
/hardware-management-extension/
/inference-cortex-extension/
/model-extension/
/files/
/logs/
app.log
/models/
/huggingface.co/
/Model_Provider_A/
/Model_A
model_A.gguf
model_A.yaml
/threads/
/thread_A/
messages.jsonl
thread.json
```
### `assistants/`
@ -93,14 +94,28 @@ Where AI personalities live. The default one (`/assistants/jan/`):
```json
{
"avatar": "",
"avatar": "👋",
"id": "jan",
"object": "assistant",
"created_at": 1715132389207,
"created_at": 1750945742.536,
"name": "Jan",
"description": "A default assistant that can use all downloaded models",
"description": "Jan is a helpful AI assistant that can use tools and help complete tasks for its users.",
"model": "*",
"instructions": ""
"instructions": "You have access to a set of tools to help you answer the users question. You can use only one tool per message, and youll receive the result of that tool in the users next response. To complete a task, use tools step by step—each step should be guided by the outcome of the previous one.\nTool Usage Rules:\n1. Always provide the correct values as arguments when using tools. Do not pass variable names—use actual values instead.\n2. You may perform multiple tool steps to complete a task.\n3. Avoid repeating a tool call with exactly the same parameters to prevent infinite loops.",
"tools": [
{
"type": "retrieval",
"enabled": false,
"useTimeWeightedRetriever": false,
"settings": {
"top_k": 2,
"chunk_size": 1024,
"chunk_overlap": 64,
"retrieval_template": "Use the following pieces of context to answer the question at the end.\n----------------\nCONTEXT: {CONTEXT}\n----------------\nQUESTION: {QUESTION}\n----------------\nHelpful Answer:"
}
}
],
"file_ids": []
}
```
@ -140,88 +155,65 @@ Debugging headquarters (`/logs/app.txt`):
The silicon brain collection. Each model has its own `model.json`.
<Callout type="info">
Full parameters: [here](/docs/models/model-parameters)
Full parameters: [here](/docs/model-parameters)
</Callout>
### `settings/`
Control panel. Extension settings in `/settings/@janhq/`:
| Parameter | Description |
|----------------|----------------------------------------------------|
| key | Setting identifier |
| title | Display name |
| description | Setting explanation |
| controllerType | UI component type |
| controllerProps| Component properties |
| extensionName | Parent extension link |
GPU settings (`settings.json`):
| Parameter | Description |
|----------------------|--------------------------------------------|
| notify | Notification status |
| run_mode | Operating mode |
| nvidia_driver.exist | NVIDIA driver presence |
| nvidia_driver.version| Driver version |
| cuda.exist | CUDA availability |
| cuda.version | CUDA version |
| gpus[0].id | GPU identifier |
| gpus[0].vram | GPU memory (MB) |
| gpus[0].name | GPU model |
| gpus[0].arch | GPU architecture |
| gpu_highest_vram | Most capable GPU |
| gpus_in_use | Active GPUs |
| is_initial | First run flag |
| vulkan | Vulkan support |
### `themes/`
Visual wardrobe. Each theme's `theme.json`:
| Parameter | Description |
|------------------|-------------------------------------------|
| id | Theme identifier |
| displayName | UI name |
| reduceTransparent| Transparency control |
| nativeTheme | OS theme sync |
| variables | Component settings |
### `threads/`
Chat archive. Each thread (`/threads/jan_unixstamp/`) contains:
- `messages.jsonl`:
```json
{
"id":"01J6Y6FH8PFTHQB5PNJTHEN27C",
"thread_id":"jan_1725437954",
"type":"Thread",
"role":"assistant",
"content":
[
{
"type": "text",
"text": {
"value": "Hello! Is there something I can help you with or would you like to chat?",
"annotations": []
}
}
],
"status": "ready",
"created": 1725442802966,
"updated": 1725442802966,
"object": "thread.message"
}
{
"completed_at": 0,
"content": [
{
"text": {
"annotations": [],
"value": "Hello! I can help you with various tasks. I can search for information on the internet, including news, videos, images, shopping, and more. I can also scrape webpages to extract specific information. Let me know what you need!"
},
"type": "text"
}
],
"created_at": 1751012639307,
"id": "01JYR7S0JB5ZBGMJV52KWMW5VW",
"metadata": {
"assistant": {
"avatar": "👋",
"id": "jan",
"instructions": "You have access to a set of tools to help you answer the user's question. You can use only one tool per message, and you'll receive the result of that tool in the user's next response. To complete a task, use tools step by step—each step should be guided by the outcome of the previous one.\nTool Usage Rules:\n1. Always provide the correct values as arguments when using tools. Do not pass variable names—use actual values instead.\n2. You may perform multiple tool steps to complete a task.\n3. Avoid repeating a tool call with exactly the same parameters to prevent infinite loops.",
"name": "Jan",
"parameters": ""
},
"tokenSpeed": {
"lastTimestamp": 1751012637097,
"message": "01JYR7S0GW5M9PSHMRE7T8VQJM",
"tokenCount": 49,
"tokenSpeed": 22.653721682847895
}
},
"object": "thread.message",
"role": "assistant",
"status": "ready",
"thread_id": "8f2c9922-db49-4d1e-8620-279c05baf2d0",
"type": "text"
}
```
- `thread.json`:
| Parameter | Description |
|------------|------------------------------------------------|
| assistants | Assistant configuration clone |
| created | Creation timestamp |
| id | Thread identifier |
| metadata | Additional thread data |
| model | Active model settings |
| object | OpenAI compatibility marker |
| title | Thread name |
| assistants | Assistant configuration clone |
| model | Active model settings |
| metadata | Additional thread data |
| updated | Updated timestamp |
## Delete Jan Data
Uninstall guides: [Mac](/docs/desktop/mac#step-2-clean-up-data-optional),

View File

@ -33,7 +33,7 @@ import { Settings, EllipsisVertical, Plus, FolderOpen, Pencil } from 'lucide-rea
Jan uses **llama.cpp** for running local AI models. You can find its settings in **Settings** (<Settings width={16} height={16} style={{display:"inline"}}/>) > **Local Engine** > **llama.cpp**:
<br/>
![llama.cpp](./_assets/llama.cpp-01.png)
![llama.cpp](./_assets/llama.cpp-01-updated.png)
<br/>
These settings are for advanced users, you would want to check these settings when:
@ -151,6 +151,7 @@ For detailed hardware compatibility, please visit our guide for [Mac](/docs/desk
| **Caching** | - Enable to store recent prompts and responses<br></br>- Improves response time for repeated prompts | Enabled |
| **KV Cache Type** | - KV cache implementation type; controls memory usage and precision trade-off<br></br>- Options:<br></br>• f16 (most stable)<br></br>• q8_0 (balanced)<br></br>• q4_0 (lowest memory) | f16 |
| **mmap** | - Enables memory-mapped model loading<br></br>- Reduces memory usage<br></br>- Recommended for large models | Enabled |
| **Context Shift** | - Automatically shifts the context window when the model is unable to process the entire prompt<br/> - Ensures that the most relevant information is always included <br/> - Recommended for long conversations and multiple tool calls | Disabled |
## Best Practices

View File

@ -38,8 +38,6 @@ These settings are available in the model settings modal:
| **Repeat Last N** | Number of tokens to consider for repeat penalty. |
| **Repeat Penalty** | Penalize repeating token sequences. |
| **Presence Penalty**| Penalize alpha presence (encourages new topics). |
| **Max Tokens** | Maximum length of the model's response. |
| **Stop Sequences** | Tokens or phrases that will end the model's response. |
| **Frequency Penalty** | Reduces word repetition. |
<br/>

View File

@ -36,11 +36,15 @@ Follow this [guide](https://continue.dev/docs/quickstart) to install the Continu
To set up Continue for use with Jan's Local Server, you must activate the Jan API Server with your chosen model.
1. Press the `<>` button. Jan will take you to the **Local API Server** section.
1. Press the `⚙️ Settings` button.
2. Setup the server, which includes the **IP Port**, **Cross-Origin-Resource-Sharing (CORS)** and **Verbose Server Logs**.
2. Locate `Local API Server`.
3. Press the **Start Server** button
3. Setup the server, which includes the **IP Port**, **Cross-Origin-Resource-Sharing (CORS)** and **Verbose Server Logs**.
4. Include your user-defined API Key.
5. Press the **Start Server** button
### Step 3: Configure Continue to Use Jan's Local Server
@ -64,30 +68,35 @@ To set up Continue for use with Jan's Local Server, you must activate the Jan AP
</Tabs.Tab>
</Tabs>
```json title="~/.continue/config.json"
{
"models": [
{
"title": "Jan",
"provider": "openai",
"model": "mistral-ins-7b-q4",
"apiKey": "EMPTY",
"apiBase": "http://localhost:1337/v1"
}
]
}
```yaml title="~/.continue/config.yaml"
name: Local Assistant
version: 1.0.0
schema: v1
models:
- name: Jan
provider: openai
model: #MODEL_NAME (e.g. qwen3:0.6b)
apiKey: #YOUR_USER_DEFINED_API_KEY_HERE (e.g. hello)
apiBase: http://localhost:1337/v1
context:
- provider: code
- provider: docs
- provider: diff
- provider: terminal
- provider: problems
- provider: folder
- provider: codebase
```
2. Ensure the file has the following configurations:
- Ensure `openai` is selected as the `provider`.
- Match the `model` with the one enabled in the Jan API Server.
- Set `apiBase` to `http://localhost:1337`.
- Leave the `apiKey` field to `EMPTY`.
- Set `apiBase` to `http://localhost:1337/v1`.
### Step 4: Ensure the Using Model Is Activated in Jan
1. Navigate to `Settings` > `My Models`.
2. Click the **three dots (⋮)** button.
1. Navigate to `Settings` > `Model Providers`.
2. Under Llama.cpp, find the model that you would want to use.
3. Select the **Start Model** button to activate the model.
</Steps>

View File

@ -5503,10 +5503,10 @@ lru-cache@^4.0.1:
pseudomap "^1.0.2"
yallist "^2.1.2"
lucide-react@^0.372.0:
version "0.372.0"
resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.372.0.tgz"
integrity sha512-0cKdqmilHXWUwWAWnf6CrrjHD8YaqPMtLrmEHXolZusNTr9epULCsiJwIOHk2q1yFxdEwd96D4zShlAj67UJdA==
lucide-react@^0.522.0:
version "0.522.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.522.0.tgz#c0951dd32936b6a7bcc474a829a251fede0bdfbd"
integrity sha512-jnJbw974yZ7rQHHEFKJOlWAefG3ATSCZHANZxIdx8Rk/16siuwjgA4fBULpXEAWx/RlTs3FzmKW/udWUuO0aRw==
lz-string@^1.5.0:
version "1.5.0"

View File

@ -13,7 +13,7 @@
},
"devDependencies": {
"cpx": "^1.5.0",
"rimraf": "^3.0.2",
"rimraf": "^6.0.1",
"rolldown": "1.0.0-beta.1",
"run-script-os": "^1.1.6",
"typescript": "^5.3.3"

View File

@ -17,7 +17,7 @@
},
"devDependencies": {
"cpx": "^1.5.0",
"rimraf": "^3.0.2",
"rimraf": "^6.0.1",
"rolldown": "1.0.0-beta.1",
"ts-loader": "^9.5.0",
"typescript": "^5.7.2"

View File

@ -13,7 +13,7 @@
},
"devDependencies": {
"cpx": "^1.5.0",
"rimraf": "^3.0.2",
"rimraf": "^6.0.1",
"rolldown": "1.0.0-beta.1",
"run-script-os": "^1.1.6",
"typescript": "5.8.3",

View File

@ -24,13 +24,7 @@ run = [
[tasks.install]
description = "Install dependencies"
depends = ["config-yarn"]
run = '''
#!/usr/bin/env bash
# Skip install on Windows per Makefile logic
if [[ "$OSTYPE" != "msys" && "$OSTYPE" != "win32" ]]; then
yarn install
fi
'''
run = "yarn install"
sources = ['package.json', 'yarn.lock']
outputs = ['node_modules']

View File

@ -4,7 +4,8 @@
"workspaces": {
"packages": [
"core",
"web-app"
"web-app",
"tests-e2e-js"
]
},
"scripts": {
@ -13,6 +14,11 @@
"build": "yarn build:web && yarn build:tauri",
"test": "yarn workspace @janhq/web-app test",
"test:coverage": "yarn workspace @janhq/web-app test",
"test:prepare": "yarn build:icon && yarn copy:lib && yarn copy:assets:tauri && yarn build --no-bundle ",
"test:e2e:linux": "yarn test:prepare && xvfb-run yarn workspace tests-e2-js test",
"test:e2e:win32": "yarn test:prepare && yarn workspace tests-e2-js test",
"test:e2e:darwin": "echo 'E2E tests are not supported on macOS yet due to WebDriver limitations'",
"test:e2e": "run-script-os",
"dev:web": "yarn workspace @janhq/web-app dev",
"dev:tauri": "CLEAN=true yarn build:icon && yarn copy:assets:tauri && tauri dev",
"install:cortex:linux:darwin": "cd src-tauri/binaries && ./download.sh",
@ -39,7 +45,7 @@
"cpx": "^1.5.0",
"cross-env": "^7.0.3",
"husky": "^9.1.5",
"jest": "^29.7.0",
"jest": "^30.0.3",
"jest-environment-jsdom": "^29.7.0",
"rimraf": "^3.0.2",
"run-script-os": "^1.1.6",

View File

@ -19,8 +19,8 @@ tauri-build = { version = "2.0.2", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.4.0", features = [ "protocol-asset", "macos-private-api",
"test",
tauri = { version = "2.5.0", features = [ "protocol-asset", "macos-private-api",
"test"
] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.2.0"

View File

@ -277,7 +277,12 @@ pub fn setup_sidecar(app: &App) -> Result<(), String> {
]);
#[cfg(target_os = "windows")]
{
let resource_dir = app_handle_for_spawn.path().resource_dir().unwrap();
let mut resource_dir = app_handle_for_spawn.path().resource_dir().unwrap();
// If debug
#[cfg(debug_assertions)]
{
resource_dir = resource_dir.join("binaries");
}
let normalized_path = resource_dir.to_string_lossy().replace(r"\\?\", "");
let normalized_pathbuf = PathBuf::from(normalized_path);
cmd = cmd.current_dir(normalized_pathbuf);
@ -286,12 +291,12 @@ pub fn setup_sidecar(app: &App) -> Result<(), String> {
#[cfg(not(target_os = "windows"))]
{
cmd = cmd.env("LD_LIBRARY_PATH", {
let current_app_data_dir = app_handle_for_spawn
.path()
.resource_dir()
.unwrap()
.join("binaries");
let dest = current_app_data_dir.to_str().unwrap();
let mut resource_dir = app_handle_for_spawn.path().resource_dir().unwrap();
#[cfg(not(debug_assertions))]
{
resource_dir = resource_dir.join("binaries");
}
let dest = resource_dir.to_str().unwrap();
let ld_path_env = std::env::var("LD_LIBRARY_PATH").unwrap_or_default();
format!("{}{}{}", ld_path_env, ":", dest)
});

View File

@ -33,7 +33,7 @@ ${StrLoc}
!define VERSION "jan_version"
!define VERSIONWITHBUILD "jan_build"
!define HOMEPAGE ""
!define INSTALLMODE "currentUser"
!define INSTALLMODE "both"
!define LICENSE ""
!define INSTALLERICON "D:\a\jan\jan\src-tauri\icons\icon.ico"
!define SIDEBARIMAGE ""

1
tests-e2e-js/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
dist

23
tests-e2e-js/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "tests-e2-js",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "src/main.ts",
"scripts": {
"build": "tsc",
"test": "node --test --test-force-exit --loader ts-node/esm ./src/main.ts"
},
"dependencies": {
"@tauri-e2e/selenium": "0.2.2",
"log4js": "^6.9.1",
"selenium-webdriver": "^4.22.0",
"ts-node": "^10.9.2"
},
"devDependencies": {
"@types/node": "^20.14.9",
"@types/selenium-webdriver": "^4.1.28",
"tsimp": "^2.0.11",
"typescript": "^5.5.2"
}
}

51
tests-e2e-js/src/main.ts Normal file
View File

@ -0,0 +1,51 @@
import assert from 'node:assert'
import { ChildProcess } from 'node:child_process'
import { afterEach, beforeEach, describe, test } from 'node:test'
import { By, until, WebDriver } from 'selenium-webdriver'
import * as e2e from '@tauri-e2e/selenium'
import { default as log4js } from 'log4js'
let logger = log4js.getLogger()
logger.level = 'debug'
process.env.TAURI_WEBDRIVER_LOGLEVEL = 'debug'
process.env.TAURI_WEBDRIVER_BINARY = await e2e.install.PlatformDriver()
process.env.TAURI_SELENIUM_BINARY = '../src-tauri/target/release/Jan.exe'
process.env.SELENIUM_REMOTE_URL = 'http://127.0.0.1:6655'
//@ts-ignore fuck you javascript
e2e.setLogger(logger)
describe('Tauri E2E tests', async () => {
let driver: WebDriver
let webDriver: ChildProcess
beforeEach(async () => {
// Spawn WebDriver process.
webDriver = await e2e.launch.spawnWebDriver()
// wait 1 second
await new Promise((r) => setTimeout(r, 1000))
// Create driver session.
driver = new e2e.selenium.Builder().build()
// Wait for the body element to be present
// await driver.wait(until.elementLocated({ css: 'body' }))
})
afterEach(async () => {
await e2e.selenium.cleanupSession(driver)
e2e.launch.killWebDriver(webDriver)
})
test('Find hub', async () => {
const hub = until.elementLocated(By.css('[data-test-id="menu-common:hub"'))
// console.log('GG', hub)
// @ts-ignore
await driver.wait(hub.fn, 120000)
const menuElement = await driver.findElement({
css: '[data-test-id="menu-common:hub"]',
})
assert(menuElement !== null, 'Hub menu element should be available')
await menuElement.isDisplayed()
})
})

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"declaration": true,
"declarationMap": true
},
"include": [
"src/*.ts"
],
"exclude": [
"node_modules",
"dist"
],
}

View File

@ -46,7 +46,7 @@
"i18next": "^25.0.1",
"katex": "^0.16.22",
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.503.0",
"lucide-react": "^0.522.0",
"motion": "^12.10.5",
"next-themes": "^0.4.6",
"posthog-js": "^1.246.0",
@ -56,6 +56,7 @@
"react-i18next": "^15.5.1",
"react-joyride": "^2.9.3",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.3",
"react-syntax-highlighter": "^15.6.1",
"react-syntax-highlighter-virtualized-renderer": "^1.1.0",
"react-textarea-autosize": "^8.5.9",
@ -88,7 +89,7 @@
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"tailwind-merge": "^3.2.0",
"typescript": "~5.7.2",
"typescript": "~5.8.3",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.0",
"vite-plugin-node-polyfills": "^0.23.0",

View File

@ -0,0 +1,54 @@
import * as React from 'react'
import { GripVerticalIcon } from 'lucide-react'
import * as ResizablePrimitive from 'react-resizable-panels'
import { cn } from '@/lib/utils'
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && (
<div className="bg-main-view z-10 flex h-4 w-3 items-center justify-center rounded-xs border border-main-view-fg/10 relative left-0.5">
<GripVerticalIcon className="size-2.5 text-main-view-fg" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@ -5,6 +5,7 @@ export const route = {
assistant: '/assistant',
settings: {
index: '/settings',
model_providers: '/settings/providers',
providers: '/settings/providers/$providerName',
general: '/settings/general',
appearance: '/settings/appearance',

View File

@ -10,43 +10,63 @@ type CardProps = {
type CardItemProps = {
title?: string | ReactNode
description?: string | ReactNode
descriptionOutside?: string | ReactNode
align?: 'start' | 'center' | 'end'
actions?: ReactNode
column?: boolean
className?: string
classNameWrapperAction?: string
}
export function CardItem({
title,
description,
descriptionOutside,
className,
classNameWrapperAction,
align = 'center',
column,
actions,
}: CardItemProps) {
return (
<div
className={cn(
'flex justify-between mt-2 first:mt-0 border-b border-main-view-fg/5 pb-3 last:border-none last:pb-0 gap-8',
className,
align === 'start' && 'items-start',
align === 'center' && 'items-center',
align === 'end' && 'items-end',
column && 'flex-col gap-y-0 items-start'
)}
>
<div className="space-y-1.5">
<h1 className="font-medium">{title}</h1>
{description && (
<span className="text-main-view-fg/70 leading-normal">
{description}
</span>
<>
<div
className={cn(
'flex justify-between mt-2 first:mt-0 border-b border-main-view-fg/5 pb-3 last:border-none last:pb-0 gap-8',
descriptionOutside && 'border-0',
align === 'start' && 'items-start',
align === 'center' && 'items-center',
align === 'end' && 'items-end',
column && 'flex-col gap-y-0 items-start',
className
)}
>
<div className="space-y-1.5">
<h1 className="font-medium">{title}</h1>
{description && (
<span className="text-main-view-fg/70 leading-normal">
{description}
</span>
)}
</div>
{actions && (
<div
className={cn(
'shrink-0',
classNameWrapperAction,
column && 'w-full'
)}
>
{actions}
</div>
)}
</div>
{actions && (
<div className={cn('shrink-0', column && 'w-full')}>{actions}</div>
{descriptionOutside && (
<span className="text-main-view-fg/70 leading-normal">
{descriptionOutside}
</span>
)}
</div>
</>
)
}

View File

@ -365,6 +365,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
rows={1}
maxRows={10}
value={prompt}
data-test-id={'chat-input'}
onChange={(e) => {
setPrompt(e.target.value)
// Count the number of newlines to estimate rows
@ -567,6 +568,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
variant={!prompt.trim() ? null : 'default'}
size="icon"
disabled={!prompt.trim()}
data-test-id="send-message-button"
onClick={() => handleSendMesage(prompt)}
>
{streamingContent ? (

View File

@ -9,27 +9,31 @@ export function ChatWidthSwitcher() {
const { t } = useTranslation()
return (
<div className="flex gap-4">
<div className="flex flex-col sm:flex-row sm:gap-4">
<button
className={cn(
'w-full overflow-hidden border border-main-view-fg/10 rounded-md my-2 pb-2 cursor-pointer',
'w-full overflow-hidden border border-main-view-fg/10 rounded-md my-2 pb-2 cursor-pointer ',
chatWidth === 'compact' && 'border-accent'
)}
onClick={() => setChatWidth('compact')}
>
<div className="flex items-center justify-between px-4 py-2 bg-main-view-fg/10">
<span className="font-medium text-xs font-sans">{t('common:compactWidth')}</span>
<span className="font-medium text-xs font-sans">
{t('common:compactWidth')}
</span>
{chatWidth === 'compact' && (
<IconCircleCheckFilled className="size-4 text-accent" />
)}
</div>
<div className="overflow-auto p-2">
<div className="flex flex-col px-10 gap-2 mt-2">
<div className="flex flex-col px-6 gap-2 mt-2">
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
<div className="bg-main-view-fg/10 h-8 px-4 w-full flex-shrink-0 border-none resize-none outline-0 rounded-2xl flex items-center">
<span className="text-main-view-fg/50">{t('common:placeholder.chatInput')}</span>
<div className="bg-main-view-fg/10 h-8 px-4 w-full flex-shrink-0 border-none resize-none outline-0 rounded-sm flex items-center truncate">
<span className="text-main-view-fg/50 line-clamp-1">
{t('common:placeholder.chatInput')}
</span>
</div>
</div>
</div>
@ -42,7 +46,9 @@ export function ChatWidthSwitcher() {
onClick={() => setChatWidth('full')}
>
<div className="flex items-center justify-between px-4 py-2 bg-main-view-fg/10">
<span className="font-medium text-xs font-sans">{t('common:fullWidth')}</span>
<span className="font-medium text-xs font-sans">
{t('common:fullWidth')}
</span>
{chatWidth === 'full' && (
<IconCircleCheckFilled className="size-4 text-accent" />
)}
@ -52,8 +58,10 @@ export function ChatWidthSwitcher() {
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
<div className="bg-main-view-fg/10 h-8 px-4 w-full flex-shrink-0 border-none resize-none outline-0 rounded-2xl flex items-center">
<span className="text-main-view-fg/50">{t('common:placeholder.chatInput')}</span>
<div className="bg-main-view-fg/10 h-8 px-4 w-full flex-shrink-0 border-none resize-none outline-0 rounded-sm flex items-center">
<span className="text-main-view-fg/50">
{t('common:placeholder.chatInput')}
</span>
</div>
</div>
</div>

View File

@ -182,7 +182,9 @@ export function DownloadManagement() {
getProviders().then(setProviders)
toast.success(t('common:toast.downloadComplete.title'), {
id: 'download-complete',
description: t('common:toast.downloadComplete.description', { modelId: state.modelId }),
description: t('common:toast.downloadComplete.description', {
modelId: state.modelId,
}),
})
},
[removeDownload, removeLocalDownloadingModel, setProviders, t]
@ -237,10 +239,14 @@ export function DownloadManagement() {
<PopoverTrigger asChild>
{isLeftPanelOpen ? (
<div className="bg-left-panel-fg/10 hover:bg-left-panel-fg/12 p-2 rounded-md my-1 relative border border-left-panel-fg/10 cursor-pointer text-left">
<div className="bg-primary font-bold size-5 rounded-full absolute -top-2 -right-1 flex items-center justify-center text-primary-fg">
{downloadCount}
<div className="text-left-panel-fg/80 font-medium flex gap-2">
<span>{t('downloads')}</span>
<span>
<div className="bg-primary font-bold size-5 rounded-full flex items-center justify-center text-primary-fg">
{downloadCount}
</div>
</span>
</div>
<p className="text-left-panel-fg/80 font-medium">{t('downloads')}</p>
<div className="mt-2 flex items-center justify-between space-x-2">
<Progress value={overallProgress * 100} />
<span className="text-xs font-medium text-left-panel-fg/80 shrink-0">
@ -272,7 +278,9 @@ export function DownloadManagement() {
>
<div className="flex flex-col">
<div className="p-2 py-1.5 bg-main-view-fg/5 border-b border-main-view-fg/6">
<p className="text-xs text-main-view-fg/70">{t('downloading')}</p>
<p className="text-xs text-main-view-fg/70">
{t('downloading')}
</p>
</div>
<div className="p-2 max-h-[300px] overflow-y-auto space-y-2">
{appUpdateState.isDownloading && (
@ -309,10 +317,15 @@ export function DownloadManagement() {
title="Cancel download"
onClick={() => {
abortDownload(download.name).then(() => {
toast.info(t('common:toast.downloadCancelled.title'), {
id: 'cancel-download',
description: t('common:toast.downloadCancelled.description'),
})
toast.info(
t('common:toast.downloadCancelled.title'),
{
id: 'cancel-download',
description: t(
'common:toast.downloadCancelled.description'
),
}
)
if (downloadProcesses.length === 0) {
setIsPopoverOpen(false)
}

View File

@ -79,6 +79,9 @@ const LeftPanel = () => {
const searchContainerRef = useRef<HTMLDivElement>(null)
const searchContainerMacRef = useRef<HTMLDivElement>(null)
// Determine if we're in a resizable context (large screen with panel open)
const isResizableContext = !isSmallScreen && open
// Use click outside hook for panel with debugging
useClickOutside(
() => {
@ -189,9 +192,17 @@ const LeftPanel = () => {
<aside
ref={panelRef}
className={cn(
'w-48 shrink-0 rounded-lg m-1.5 mr-0 text-left-panel-fg overflow-hidden',
'text-left-panel-fg overflow-hidden',
// Resizable context: full height and width, no margins
isResizableContext && 'h-full w-full',
// Small screen context: fixed positioning and styling
isSmallScreen &&
'fixed h-[calc(100%-16px)] bg-main-view z-40 rounded-sm border border-left-panel-fg/10 m-2 px-1',
'fixed h-[calc(100%-16px)] bg-main-view z-40 rounded-sm border border-left-panel-fg/10 m-2 px-1 w-48',
// Default context: original styling
!isResizableContext &&
!isSmallScreen &&
'w-48 shrink-0 rounded-lg m-1.5 mr-0',
// Visibility controls
open
? 'opacity-100 visibility-visible'
: 'w-0 absolute -top-100 -left-100 visibility-hidden'
@ -209,7 +220,12 @@ const LeftPanel = () => {
{!IS_MACOS && (
<div
ref={searchContainerRef}
className="relative top-1.5 mb-4 mx-1 mt-1 w-[calc(100%-32px)] z-50"
className={cn(
'relative top-1.5 mb-4 mt-1 z-50',
isResizableContext
? 'mx-2 w-[calc(100%-48px)]'
: 'mx-1 w-[calc(100%-32px)]'
)}
data-ignore-outside-clicks
>
<IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" />
@ -241,7 +257,10 @@ const LeftPanel = () => {
{IS_MACOS && (
<div
ref={searchContainerMacRef}
className="relative mb-4 mx-1 mt-1"
className={cn(
'relative mb-4 mt-1',
isResizableContext ? 'mx-2' : 'mx-1'
)}
data-ignore-outside-clicks
>
<IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" />
@ -452,6 +471,7 @@ const LeftPanel = () => {
key={menu.title}
to={menu.route}
onClick={() => isSmallScreen && setLeftPanel(false)}
data-test-id={`menu-${menu.title}`}
className={cn(
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded',
isActive

View File

@ -1,152 +0,0 @@
import { route } from '@/constants/routes'
import { useModelProvider } from '@/hooks/useModelProvider'
import { cn, getProviderTitle } from '@/lib/utils'
import { useNavigate, useMatches, Link } from '@tanstack/react-router'
import { IconArrowLeft, IconCirclePlus } from '@tabler/icons-react'
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useCallback, useState } from 'react'
import { openAIProviderSettings } from '@/mock/data'
import ProvidersAvatar from '@/containers/ProvidersAvatar'
import cloneDeep from 'lodash/cloneDeep'
import { toast } from 'sonner'
import { useTranslation } from '@/i18n/react-i18next-compat'
const ProvidersMenu = ({
stepSetupRemoteProvider,
}: {
stepSetupRemoteProvider: boolean
}) => {
const { providers, addProvider } = useModelProvider()
const navigate = useNavigate()
const matches = useMatches()
const [name, setName] = useState('')
const { t } = useTranslation()
const createProvider = useCallback(() => {
if (providers.some((e) => e.provider === name)) {
toast.error(t('providerAlreadyExists', { name }))
return
}
const newProvider = {
provider: name,
active: true,
models: [],
settings: cloneDeep(openAIProviderSettings) as ProviderSetting[],
api_key: '',
base_url: 'https://api.openai.com/v1',
}
addProvider(newProvider)
setTimeout(() => {
navigate({
to: route.settings.providers,
params: {
providerName: name,
},
})
}, 0)
}, [providers, name, addProvider, t, navigate])
return (
<div className="w-44 py-2 border-r border-main-view-fg/5 pb-10 overflow-y-auto">
<Link to={route.settings.general}>
<div className="flex items-center gap-0.5 ml-3 mb-4 mt-1">
<IconArrowLeft size={16} className="text-main-view-fg/70" />
<span className="text-main-view-fg/80">{t('common:back')}</span>
</div>
</Link>
<div className="first-step-setup-remote-provider">
{providers.map((provider, index) => {
const isActive = matches.some(
(match) =>
match.routeId === '/settings/providers/$providerName' &&
'providerName' in match.params &&
match.params.providerName === provider.provider
)
return (
<div key={index} className="flex flex-col px-2 my-1.5 ">
<div
className={cn(
'flex px-2 items-center gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded [&.active]:bg-main-view-fg/5 text-main-view-fg/80',
isActive && 'bg-main-view-fg/5',
// hidden for llama.cpp provider for setup remote provider
provider.provider === 'llama.cpp' &&
stepSetupRemoteProvider &&
'hidden'
)}
onClick={() =>
navigate({
to: route.settings.providers,
params: {
providerName: provider.provider,
},
...(stepSetupRemoteProvider
? { search: { step: 'setup_remote_provider' } }
: {}),
})
}
>
<ProvidersAvatar provider={provider} />
<div className="truncate">
<span>{getProviderTitle(provider.provider)}</span>
</div>
</div>
</div>
)
})}
<Dialog>
<DialogTrigger asChild>
<div className="flex cursor-pointer px-4 my-1.5 items-center gap-1.5 text-main-view-fg/80">
<IconCirclePlus size={18} />
<span>{t('provider:addProvider')}</span>
</div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('provider:addOpenAIProvider')}</DialogTitle>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-2"
placeholder={t('provider:enterNameForProvider')}
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button disabled={!name} onClick={createProvider}>
{t('common:create')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
</div>
</div>
)
}
export default ProvidersMenu

View File

@ -1,20 +1,56 @@
import { Link, useMatches } from '@tanstack/react-router'
import { Link } from '@tanstack/react-router'
import { route } from '@/constants/routes'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useModelProvider } from '@/hooks/useModelProvider'
import { useState, useEffect } from 'react'
import {
IconChevronDown,
IconChevronRight,
IconMenu2,
IconX,
} from '@tabler/icons-react'
import { useMatches, useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { useGeneralSetting } from '@/hooks/useGeneralSetting'
import { useModelProvider } from '@/hooks/useModelProvider'
import { getProviderTitle } from '@/lib/utils'
import ProvidersAvatar from '@/containers/ProvidersAvatar'
const SettingsMenu = () => {
const { t } = useTranslation()
const { providers } = useModelProvider()
const { experimentalFeatures } = useGeneralSetting()
const firstItemProvider =
providers.length > 0 ? providers[0].provider : 'llama.cpp'
const [expandedProviders, setExpandedProviders] = useState(false)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const matches = useMatches()
const isActive = matches.some(
const navigate = useNavigate()
const { experimentalFeatures } = useGeneralSetting()
const { providers } = useModelProvider()
// Filter providers that have active API keys (or are llama.cpp which doesn't need one)
const activeProviders = providers.filter((provider) => provider.active)
// Check if current route has a providerName parameter and expand providers submenu
useEffect(() => {
const hasProviderName = matches.some(
(match) =>
match.routeId === '/settings/providers/$providerName' &&
'providerName' in match.params
)
const isProvidersRoute = matches.some(
(match) => match.routeId === '/settings/providers/'
)
if (hasProviderName || isProvidersRoute) {
setExpandedProviders(true)
}
}, [matches])
// Check if we're in the setup remote provider step
const stepSetupRemoteProvider = matches.some(
(match) =>
match.routeId === '/settings/providers/$providerName' &&
'providerName' in match.params
match.search &&
typeof match.search === 'object' &&
'step' in match.search &&
match.search.step === 'setup_remote_provider'
)
const menuSettings = [
@ -30,6 +66,11 @@ const SettingsMenu = () => {
title: 'common:privacy',
route: route.settings.privacy,
},
{
title: 'common:modelProviders',
route: route.settings.model_providers,
hasSubMenu: activeProviders.length > 0,
},
{
title: 'common:keyboardShortcuts',
route: route.settings.shortcuts,
@ -61,52 +102,113 @@ const SettingsMenu = () => {
},
]
const toggleProvidersExpansion = () => {
setExpandedProviders(!expandedProviders)
}
const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen)
}
return (
<div className="flex h-full w-44 shrink-0 px-1.5 pt-3 border-r border-main-view-fg/5">
<div className="flex flex-col gap-1 w-full text-main-view-fg/90 font-medium">
{menuSettings.map((menu, index) => {
// Render the menu item
const menuItem = (
<Link
key={menu.title}
to={menu.route}
className="block px-2 gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded [&.active]:bg-main-view-fg/5"
>
<span className="text-main-view-fg/80">{t(menu.title)}</span>
</Link>
)
<>
<button
className="fixed top-4 right-4 sm:hidden size-5 cursor-pointer items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-main-view-fg/10 z-20"
onClick={toggleMenu}
aria-label="Toggle settings menu"
>
{isMenuOpen ? (
<IconX size={18} className="text-main-view-fg relative z-20" />
) : (
<IconMenu2 size={18} className="text-main-view-fg relative z-20" />
)}
</button>
<div
className={cn(
'h-full w-44 shrink-0 px-1.5 pt-3 border-r border-main-view-fg/5 bg-main-view',
'sm:flex',
isMenuOpen
? 'flex fixed sm:hidden top-0 z-10 m-1 h-[calc(100%-8px)] border-r-0 border-l bg-main-view right-0 py-8 rounded-tr-lg rounded-br-lg'
: 'hidden'
)}
>
<div className="flex flex-col gap-1 w-full text-main-view-fg/90 font-medium">
{menuSettings.map((menu) => (
<div key={menu.title}>
<Link
to={menu.route}
className="block px-2 gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded [&.active]:bg-main-view-fg/5"
>
<div className="flex items-center justify-between">
<span className="text-main-view-fg/80">{t(menu.title)}</span>
{menu.hasSubMenu && (
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toggleProvidersExpansion()
}}
className="text-main-view-fg/60 hover:text-main-view-fg/80"
>
{expandedProviders ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)}
</button>
)}
</div>
</Link>
if (index === 2) {
return (
<div key={menu.title}>
<span className="mb-1 block">{menuItem}</span>
{/* Sub-menu for model providers */}
{menu.hasSubMenu && expandedProviders && (
<div className="ml-2 mt-1 space-y-1 first-step-setup-remote-provider">
{activeProviders.map((provider) => {
const isActive = matches.some(
(match) =>
match.routeId === '/settings/providers/$providerName' &&
'providerName' in match.params &&
match.params.providerName === provider.provider
)
{/* Model Providers Link with default parameter */}
{isActive ? (
<div className="block px-2 mt-1 gap-1.5 py-1 w-full rounded bg-main-view-fg/5 cursor-pointer">
<span>{t('common:modelProviders')}</span>
</div>
) : (
<Link
key="common.modelProviders"
to={route.settings.providers}
params={{ providerName: firstItemProvider }}
className="block px-2 gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded"
>
<span className="text-main-view-fg/80">
{t('common:modelProviders')}
</span>
</Link>
)}
</div>
)
}
// For other menu items, just render them normally
return menuItem
})}
return (
<div key={provider.provider}>
<div
className={cn(
'flex px-2 items-center gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded [&.active]:bg-main-view-fg/5 text-main-view-fg/80',
isActive && 'bg-main-view-fg/5',
// hidden for llama.cpp provider for setup remote provider
provider.provider === 'llama.cpp' &&
stepSetupRemoteProvider &&
'hidden'
)}
onClick={() =>
navigate({
to: route.settings.providers,
params: {
providerName: provider.provider,
},
...(stepSetupRemoteProvider
? { search: { step: 'setup_remote_provider' } }
: {}),
})
}
>
<ProvidersAvatar provider={provider} />
<div className="truncate">
<span>{getProviderTitle(provider.provider)}</span>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
))}
</div>
</div>
</div>
</>
)
}

View File

@ -105,8 +105,13 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
{...attributes}
{...listeners}
onClick={handleClick}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setOpenDropdown(true)
}}
className={cn(
'mb-1 rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/thread-list transition-all',
'mb-1 rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 transition-all',
isDragging ? 'cursor-move' : 'cursor-pointer',
isActive && 'bg-left-panel-fg/10'
)}
@ -122,7 +127,7 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
<DropdownMenuTrigger asChild>
<IconDots
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="text-left-panel-fg/60 shrink-0 cursor-pointer px-0.5 -mr-1 data-[state=open]:bg-left-panel-fg/10 rounded size-5"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()

View File

@ -4,14 +4,18 @@ import { localStorageKey } from '@/constants/localStorage'
type LeftPanelStoreState = {
open: boolean
size: number
setLeftPanel: (value: boolean) => void
setLeftPanelSize: (value: number) => void
}
export const useLeftPanel = create<LeftPanelStoreState>()(
persist(
(set) => ({
open: true,
size: 20, // Default size of 20%
setLeftPanel: (value) => set({ open: value }),
setLeftPanelSize: (value) => set({ size: value }),
}),
{
name: localStorageKey.LeftPanel,

View File

@ -27,6 +27,7 @@ import { Route as SettingsGeneralImport } from './routes/settings/general'
import { Route as SettingsExtensionsImport } from './routes/settings/extensions'
import { Route as SettingsAppearanceImport } from './routes/settings/appearance'
import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs'
import { Route as SettingsProvidersIndexImport } from './routes/settings/providers/index'
import { Route as SettingsProvidersProviderNameImport } from './routes/settings/providers/$providerName'
// Create/Update Routes
@ -127,6 +128,12 @@ const LocalApiServerLogsRoute = LocalApiServerLogsImport.update({
getParentRoute: () => rootRoute,
} as any)
const SettingsProvidersIndexRoute = SettingsProvidersIndexImport.update({
id: '/settings/providers/',
path: '/settings/providers/',
getParentRoute: () => rootRoute,
} as any)
const SettingsProvidersProviderNameRoute =
SettingsProvidersProviderNameImport.update({
id: '/settings/providers/$providerName',
@ -257,6 +264,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsProvidersProviderNameImport
parentRoute: typeof rootRoute
}
'/settings/providers/': {
id: '/settings/providers/'
path: '/settings/providers'
fullPath: '/settings/providers'
preLoaderRoute: typeof SettingsProvidersIndexImport
parentRoute: typeof rootRoute
}
}
}
@ -280,6 +294,7 @@ export interface FileRoutesByFullPath {
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers': typeof SettingsProvidersIndexRoute
}
export interface FileRoutesByTo {
@ -300,6 +315,7 @@ export interface FileRoutesByTo {
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers': typeof SettingsProvidersIndexRoute
}
export interface FileRoutesById {
@ -321,6 +337,7 @@ export interface FileRoutesById {
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers/': typeof SettingsProvidersIndexRoute
}
export interface FileRouteTypes {
@ -343,6 +360,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts'
| '/threads/$threadId'
| '/settings/providers/$providerName'
| '/settings/providers'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -362,6 +380,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts'
| '/threads/$threadId'
| '/settings/providers/$providerName'
| '/settings/providers'
id:
| '__root__'
| '/'
@ -381,6 +400,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts'
| '/threads/$threadId'
| '/settings/providers/$providerName'
| '/settings/providers/'
fileRoutesById: FileRoutesById
}
@ -402,6 +422,7 @@ export interface RootRouteChildren {
SettingsShortcutsRoute: typeof SettingsShortcutsRoute
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute
SettingsProvidersIndexRoute: typeof SettingsProvidersIndexRoute
}
const rootRouteChildren: RootRouteChildren = {
@ -422,6 +443,7 @@ const rootRouteChildren: RootRouteChildren = {
SettingsShortcutsRoute: SettingsShortcutsRoute,
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute,
SettingsProvidersIndexRoute: SettingsProvidersIndexRoute,
}
export const routeTree = rootRoute
@ -450,7 +472,8 @@ export const routeTree = rootRoute
"/settings/privacy",
"/settings/shortcuts",
"/threads/$threadId",
"/settings/providers/$providerName"
"/settings/providers/$providerName",
"/settings/providers/"
]
},
"/": {
@ -503,6 +526,9 @@ export const routeTree = rootRoute
},
"/settings/providers/$providerName": {
"filePath": "settings/providers/$providerName.tsx"
},
"/settings/providers/": {
"filePath": "settings/providers/index.tsx"
}
}
}

View File

@ -20,6 +20,13 @@ import { cn } from '@/lib/utils'
import ToolApproval from '@/containers/dialogs/ToolApproval'
import { TranslationProvider } from '@/i18n/TranslationContext'
import OutOfContextPromiseModal from '@/containers/dialogs/OutOfContextDialog'
import { useSmallScreen } from '@/hooks/useMediaQuery'
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from '@/components/ui/resizable'
import { useCallback } from 'react'
export const Route = createRootRoute({
component: RootLayout,
@ -27,7 +34,33 @@ export const Route = createRootRoute({
const AppLayout = () => {
const { productAnalyticPrompt } = useAnalytic()
const { open: isLeftPanelOpen } = useLeftPanel()
const {
open: isLeftPanelOpen,
setLeftPanel,
size: leftPanelSize,
setLeftPanelSize,
} = useLeftPanel()
const isSmallScreen = useSmallScreen()
// Minimum width threshold for auto-close (10% of screen width)
const MIN_PANEL_WIDTH_THRESHOLD = 14
// Handle panel size changes
const handlePanelLayout = useCallback(
(sizes: number[]) => {
if (sizes.length > 0) {
const newSize = sizes[0]
// Close panel if resized below minimum threshold
if (newSize < MIN_PANEL_WIDTH_THRESHOLD) {
setLeftPanel(false)
} else {
setLeftPanelSize(newSize)
}
}
},
[setLeftPanelSize, setLeftPanel]
)
return (
<Fragment>
@ -37,22 +70,56 @@ const AppLayout = () => {
{/* Fake absolute panel top to enable window drag */}
<div className="absolute w-full h-10 z-10" data-tauri-drag-region />
<DialogAppUpdater />
<div className="flex h-full">
{/* left content panel - only show if not logs route */}
<LeftPanel />
{/* Main content panel */}
<div
className={cn(
'h-full flex w-full p-1 ',
isLeftPanelOpen && 'w-full md:w-[calc(100%-198px)]'
)}
{/* Use ResizablePanelGroup only on larger screens */}
{!isSmallScreen && isLeftPanelOpen ? (
<ResizablePanelGroup
direction="horizontal"
className="h-full"
onLayout={handlePanelLayout}
>
<div className="bg-main-view text-main-view-fg border border-main-view-fg/5 w-full rounded-lg overflow-hidden">
<Outlet />
{/* Left Panel */}
<ResizablePanel
defaultSize={leftPanelSize}
minSize={MIN_PANEL_WIDTH_THRESHOLD}
maxSize={40}
collapsible
>
<div className="h-full p-1">
<LeftPanel />
</div>
</ResizablePanel>
{/* Resize Handle */}
<ResizableHandle withHandle />
{/* Main Content Panel */}
<ResizablePanel defaultSize={100 - leftPanelSize} minSize={60}>
<div className="h-full p-1 pl-0">
<div className="bg-main-view text-main-view-fg border border-main-view-fg/5 w-full h-full rounded-lg overflow-hidden">
<Outlet />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="flex h-full">
{/* left content panel - only show if not logs route */}
<LeftPanel />
{/* Main content panel */}
<div
className={cn(
'h-full flex w-full p-1 ',
isLeftPanelOpen && 'w-full md:w-[calc(100%-198px)]'
)}
>
<div className="bg-main-view text-main-view-fg border border-main-view-fg/5 w-full rounded-lg overflow-hidden">
<Outlet />
</div>
</div>
</div>
</div>
)}
</main>
{productAnalyticPrompt && <PromptAnalytic />}
</Fragment>

View File

@ -259,11 +259,16 @@ function Hub() {
</div>
)}
{isDownloaded ? (
<Button size="sm" onClick={() => handleUseModel(modelId)}>
<Button
size="sm"
onClick={() => handleUseModel(modelId)}
data-test-id={`hub-model-${modelId}`}
>
{t('hub:use')}
</Button>
) : (
<Button
data-test-id={`hub-model-${modelId}`}
size="sm"
onClick={handleDownload}
className={cn(isDownloading && 'hidden')}

View File

@ -35,7 +35,7 @@ function Appareances() {
<HeaderPage>
<h1 className="font-medium">{t('common:settings')}</h1>
</HeaderPage>
<div className="flex h-full w-full">
<div className="flex h-full w-full flex-col sm:flex-row">
<SettingsMenu />
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
@ -55,26 +55,31 @@ function Appareances() {
<CardItem
title={t('settings:appearance.windowBackground')}
description={t('settings:appearance.windowBackgroundDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppBgColor />}
/>
<CardItem
title={t('settings:appearance.appMainView')}
description={t('settings:appearance.appMainViewDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppMainView />}
/>
<CardItem
title={t('settings:appearance.primary')}
description={t('settings:appearance.primaryDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppPrimaryColor />}
/>
<CardItem
title={t('settings:appearance.accent')}
description={t('settings:appearance.accentDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppAccentColor />}
/>
<CardItem
title={t('settings:appearance.destructive')}
description={t('settings:appearance.destructiveDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppDestructiveColor />}
/>
<CardItem

View File

@ -205,7 +205,7 @@ function General() {
<HeaderPage>
<h1 className="font-medium">{t('common:settings')}</h1>
</HeaderPage>
<div className="flex h-full w-full">
<div className="flex h-full w-full flex-col sm:flex-row">
<SettingsMenu />
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
@ -222,6 +222,7 @@ function General() {
<CardItem
title={t('settings:general.checkForUpdates')}
description={t('settings:general.checkForUpdatesDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={
<Button
variant="link"
@ -265,6 +266,7 @@ function General() {
ns: 'settings',
})}
align="start"
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
description={
<>
<span>
@ -273,13 +275,15 @@ function General() {
})}
&nbsp;
</span>
<div className="flex items-center gap-2 mt-1">
<span
title={janDataFolder}
className="bg-main-view-fg/10 text-xs px-1 py-0.5 rounded-sm text-main-view-fg/80"
>
{janDataFolder}
</span>
<div className="flex items-center gap-2 mt-1 ">
<div className="">
<span
title={janDataFolder}
className="bg-main-view-fg/10 text-xs px-1 py-0.5 rounded-sm text-main-view-fg/80 line-clamp-1 w-fit"
>
{janDataFolder}
</span>
</div>
<button
onClick={() =>
janDataFolder && copyToClipboard(janDataFolder)
@ -349,6 +353,7 @@ function General() {
ns: 'settings',
})}
description={t('settings:dataFolder.appLogsDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={
<div className="flex items-center gap-2">
<Button

View File

@ -229,9 +229,11 @@ function LocalAPIServer() {
title={t('settings:localApiServer.apiKey')}
description={t('settings:localApiServer.apiKeyDesc')}
className={cn(
'flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2',
isServerRunning && 'opacity-50 pointer-events-none',
isApiKeyEmpty && showApiKeyError && 'pb-6'
)}
classNameWrapperAction="w-full sm:w-auto"
actions={
<ApiKeyInput
showError={showApiKeyError}
@ -243,8 +245,10 @@ function LocalAPIServer() {
title={t('settings:localApiServer.trustedHosts')}
description={t('settings:localApiServer.trustedHostsDesc')}
className={cn(
'flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2',
isServerRunning && 'opacity-50 pointer-events-none'
)}
classNameWrapperAction="w-full sm:w-auto"
actions={<TrustedHostsInput />}
/>
</Card>

View File

@ -320,7 +320,7 @@ function MCPServers() {
</h1>
</div>
}
description={
descriptionOutside={
<div className="text-sm text-main-view-fg/70">
<div>
{t('mcp-servers:command')}: {config.command}

View File

@ -1,9 +1,8 @@
import { Card, CardItem } from '@/containers/Card'
import HeaderPage from '@/containers/HeaderPage'
import ProvidersMenu from '@/containers/ProvidersMenu'
import SettingsMenu from '@/containers/SettingsMenu'
import { useModelProvider } from '@/hooks/useModelProvider'
import { cn, getProviderTitle } from '@/lib/utils'
import { Switch } from '@/components/ui/switch'
import { open } from '@tauri-apps/plugin-dialog'
import {
getActiveModels,
@ -228,23 +227,13 @@ function ProviderDetail() {
<h1 className="font-medium">{t('common:settings')}</h1>
</HeaderPage>
<div className="flex h-full w-full">
<div className="flex">
<ProvidersMenu stepSetupRemoteProvider={isSetup} />
</div>
<SettingsMenu />
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
<div className="flex items-center justify-between">
<h1 className="font-medium text-base">
{getProviderTitle(providerName)}
</h1>
<Switch
checked={provider?.active}
onCheckedChange={(e) => {
if (provider) {
updateProvider(providerName, { ...provider, active: e })
}
}}
/>
</div>
<div
@ -461,7 +450,12 @@ function ProviderDetail() {
key={modelIndex}
title={
<div className="flex items-center gap-2">
<h1 className="font-medium">{model.id}</h1>
<h1
className="font-medium line-clamp-1"
title={model.id}
>
{model.id}
</h1>
<Capabilities capabilities={capabilities} />
</div>
}

View File

@ -0,0 +1,187 @@
import { createFileRoute } from '@tanstack/react-router'
import { route } from '@/constants/routes'
import SettingsMenu from '@/containers/SettingsMenu'
import HeaderPage from '@/containers/HeaderPage'
import { Button } from '@/components/ui/button'
import { Card, CardItem } from '@/containers/Card'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useModelProvider } from '@/hooks/useModelProvider'
import { useNavigate } from '@tanstack/react-router'
import { IconCirclePlus, IconSettings } from '@tabler/icons-react'
import { getProviderTitle } from '@/lib/utils'
import ProvidersAvatar from '@/containers/ProvidersAvatar'
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { useCallback, useState } from 'react'
import { openAIProviderSettings } from '@/mock/data'
import cloneDeep from 'lodash/cloneDeep'
import { toast } from 'sonner'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.model_providers as any)({
component: ModelProviders,
})
function ModelProviders() {
const { t } = useTranslation()
const { providers, addProvider, updateProvider } = useModelProvider()
const navigate = useNavigate()
const [name, setName] = useState('')
const createProvider = useCallback(() => {
if (providers.some((e) => e.provider.toLowerCase() === name.toLowerCase())) {
toast.error(t('providerAlreadyExists', { name }))
return
}
const newProvider = {
provider: name,
active: true,
models: [],
settings: cloneDeep(openAIProviderSettings) as ProviderSetting[],
api_key: '',
base_url: 'https://api.openai.com/v1',
}
addProvider(newProvider)
setTimeout(() => {
navigate({
to: route.settings.providers,
params: {
providerName: name,
},
})
}, 0)
}, [providers, name, addProvider, t, navigate])
return (
<div className="flex flex-col h-full">
<HeaderPage>
<h1 className="font-medium">{t('common:settings')}</h1>
</HeaderPage>
<div className="flex h-full w-full flex-col sm:flex-row">
<SettingsMenu />
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
{/* Model Providers */}
<Card
header={
<div className="flex items-center justify-between w-full mb-6">
<span className="text-main-view-fg font-medium text-base">
{t('common:modelProviders')}
</span>
<Dialog>
<DialogTrigger asChild>
<Button
variant="link"
size="sm"
className="flex items-center gap-2"
>
<div className="cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out p-1.5 py-1 gap-1 -mr-2">
<IconCirclePlus size={16} />
<span>{t('provider:addProvider')}</span>
</div>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('provider:addOpenAIProvider')}
</DialogTitle>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-2"
placeholder={t('provider:enterNameForProvider')}
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button disabled={!name} onClick={createProvider}>
{t('common:create')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
</div>
}
>
{providers.map((provider, index) => (
<CardItem
key={index}
title={
<div className="flex items-center gap-3">
<ProvidersAvatar provider={provider} />
<div>
<h3 className="font-medium">
{getProviderTitle(provider.provider)}
</h3>
<p className="text-xs text-main-view-fg/70">
{provider.models.length} Models
</p>
</div>
</div>
}
actions={
<div className="flex items-center gap-2">
{provider.active && (
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0 bg-transparent hover:bg-main-view-fg/10 border-none shadow-none"
onClick={() => {
navigate({
to: route.settings.providers,
params: {
providerName: provider.provider,
},
})
}}
>
<IconSettings
className="text-main-view-fg/60"
size={16}
/>
</Button>
)}
<Switch
checked={provider.active}
onCheckedChange={(e) => {
updateProvider(provider.provider, {
...provider,
active: e,
})
}}
/>
</div>
}
/>
))}
</Card>
</div>
</div>
</div>
</div>
)
}

View File

@ -226,7 +226,7 @@ function ThreadDetail() {
return (
<div
key={item.id}
data-test-id={`message-${item.id}`}
data-test-id={`message-${item.role}-${item.id}`}
data-message-author-role={item.role}
className="mb-4"
>
@ -247,7 +247,10 @@ function ThreadDetail() {
</div>
)
})}
<StreamingContent threadId={threadId} />
<StreamingContent
threadId={threadId}
data-test-id="thread-content-text"
/>
</div>
</div>
<div

2065
yarn.lock

File diff suppressed because it is too large Load Diff