Merge branch 'dev' into dev-web

This commit is contained in:
Dinh Long Nguyen 2025-10-24 01:33:31 +07:00
commit 22645549ce
214 changed files with 11752 additions and 2352 deletions

View File

@ -49,6 +49,8 @@ jobs:
# Update tauri.conf.json
jq --arg version "${{ inputs.new_version }}" '.version = $version | .bundle.createUpdaterArtifacts = false' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json
mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json
jq '.bundle.windows.nsis.template = "tauri.bundle.windows.nsis.template"' ./src-tauri/tauri.windows.conf.json > /tmp/tauri.windows.conf.json
mv /tmp/tauri.windows.conf.json ./src-tauri/tauri.windows.conf.json
jq '.bundle.windows.signCommand = "echo External build - skipping signature: %1"' ./src-tauri/tauri.windows.conf.json > /tmp/tauri.windows.conf.json
mv /tmp/tauri.windows.conf.json ./src-tauri/tauri.windows.conf.json
jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json
@ -80,6 +82,36 @@ jobs:
echo "---------./src-tauri/Cargo.toml---------"
cat ./src-tauri/Cargo.toml
generate_build_version() {
### Examble
### input 0.5.6 output will be 0.5.6 and 0.5.6.0
### input 0.5.6-rc2-beta output will be 0.5.6 and 0.5.6.2
### input 0.5.6-1213 output will be 0.5.6 and and 0.5.6.1213
local new_version="$1"
local base_version
local t_value
# Check if it has a "-"
if [[ "$new_version" == *-* ]]; then
base_version="${new_version%%-*}" # part before -
suffix="${new_version#*-}" # part after -
# Check if it is rcX-beta
if [[ "$suffix" =~ ^rc([0-9]+)-beta$ ]]; then
t_value="${BASH_REMATCH[1]}"
else
t_value="$suffix"
fi
else
base_version="$new_version"
t_value="0"
fi
# Export two values
new_base_version="$base_version"
new_build_version="${base_version}.${t_value}"
}
generate_build_version ${{ inputs.new_version }}
sed -i "s/jan_version/$new_base_version/g" ./src-tauri/tauri.bundle.windows.nsis.template
sed -i "s/jan_build/$new_build_version/g" ./src-tauri/tauri.bundle.windows.nsis.template
if [ "${{ inputs.channel }}" != "stable" ]; then
jq '.plugins.updater.endpoints = ["https://delta.jan.ai/${{ inputs.channel }}/latest.json"]' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json
mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json
@ -103,7 +135,14 @@ jobs:
chmod +x .github/scripts/rename-workspace.sh
.github/scripts/rename-workspace.sh ./package.json ${{ inputs.channel }}
cat ./package.json
sed -i "s/jan_productname/Jan-${{ inputs.channel }}/g" ./src-tauri/tauri.bundle.windows.nsis.template
sed -i "s/jan_mainbinaryname/jan-${{ inputs.channel }}/g" ./src-tauri/tauri.bundle.windows.nsis.template
else
sed -i "s/jan_productname/Jan/g" ./src-tauri/tauri.bundle.windows.nsis.template
sed -i "s/jan_mainbinaryname/jan/g" ./src-tauri/tauri.bundle.windows.nsis.template
fi
echo "---------nsis.template---------"
cat ./src-tauri/tauri.bundle.windows.nsis.template
- name: Build app
shell: bash
run: |

View File

@ -98,9 +98,15 @@ jobs:
# Update tauri.conf.json
jq --arg version "${{ inputs.new_version }}" '.version = $version | .bundle.createUpdaterArtifacts = true' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json
mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json
jq '.bundle.windows.nsis.template = "tauri.bundle.windows.nsis.template"' ./src-tauri/tauri.windows.conf.json > /tmp/tauri.windows.conf.json
mv /tmp/tauri.windows.conf.json ./src-tauri/tauri.windows.conf.json
jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json
mv /tmp/package.json web-app/package.json
# Add sign commands to tauri.windows.conf.json
jq '.bundle.windows.signCommand = "powershell -ExecutionPolicy Bypass -File ./sign.ps1 %1"' ./src-tauri/tauri.windows.conf.json > /tmp/tauri.windows.conf.json
mv /tmp/tauri.windows.conf.json ./src-tauri/tauri.windows.conf.json
# Update tauri plugin versions
jq --arg version "${{ inputs.new_version }}" '.version = $version' ./src-tauri/plugins/tauri-plugin-hardware/package.json > /tmp/package.json
@ -127,9 +133,35 @@ jobs:
echo "---------./src-tauri/Cargo.toml---------"
cat ./src-tauri/Cargo.toml
# Add sign commands to tauri.windows.conf.json
jq '.bundle.windows.signCommand = "powershell -ExecutionPolicy Bypass -File ./sign.ps1 %1"' ./src-tauri/tauri.windows.conf.json > /tmp/tauri.windows.conf.json
mv /tmp/tauri.windows.conf.json ./src-tauri/tauri.windows.conf.json
generate_build_version() {
### Example
### input 0.5.6 output will be 0.5.6 and 0.5.6.0
### input 0.5.6-rc2-beta output will be 0.5.6 and 0.5.6.2
### input 0.5.6-1213 output will be 0.5.6 and and 0.5.6.1213
local new_version="$1"
local base_version
local t_value
# Check if it has a "-"
if [[ "$new_version" == *-* ]]; then
base_version="${new_version%%-*}" # part before -
suffix="${new_version#*-}" # part after -
# Check if it is rcX-beta
if [[ "$suffix" =~ ^rc([0-9]+)-beta$ ]]; then
t_value="${BASH_REMATCH[1]}"
else
t_value="$suffix"
fi
else
base_version="$new_version"
t_value="0"
fi
# Export two values
new_base_version="$base_version"
new_build_version="${base_version}.${t_value}"
}
generate_build_version ${{ inputs.new_version }}
sed -i "s/jan_version/$new_base_version/g" ./src-tauri/tauri.bundle.windows.nsis.template
sed -i "s/jan_build/$new_build_version/g" ./src-tauri/tauri.bundle.windows.nsis.template
echo "---------tauri.windows.conf.json---------"
cat ./src-tauri/tauri.windows.conf.json
@ -163,7 +195,14 @@ jobs:
chmod +x .github/scripts/rename-workspace.sh
.github/scripts/rename-workspace.sh ./package.json ${{ inputs.channel }}
cat ./package.json
sed -i "s/jan_productname/Jan-${{ inputs.channel }}/g" ./src-tauri/tauri.bundle.windows.nsis.template
sed -i "s/jan_mainbinaryname/jan-${{ inputs.channel }}/g" ./src-tauri/tauri.bundle.windows.nsis.template
else
sed -i "s/jan_productname/Jan/g" ./src-tauri/tauri.bundle.windows.nsis.template
sed -i "s/jan_mainbinaryname/jan/g" ./src-tauri/tauri.bundle.windows.nsis.template
fi
echo "---------nsis.template---------"
cat ./src-tauri/tauri.bundle.windows.nsis.template
- name: Install AzureSignTool
run: |
@ -234,8 +273,6 @@ jobs:
# 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 }}.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:
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
@ -252,13 +289,3 @@ jobs:
asset_path: ./src-tauri/target/release/bundle/nsis/${{ steps.metadata.outputs.FILE_NAME }}
asset_name: ${{ steps.metadata.outputs.FILE_NAME }}
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

@ -117,7 +117,6 @@ lint: install-and-build
test: lint
yarn download:bin
ifeq ($(OS),Windows_NT)
yarn download:windows-installer
endif
yarn test
yarn copy:assets:tauri

View File

@ -25,8 +25,8 @@ export RANLIB_aarch64_linux_android="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x
# Additional environment variables for Rust cross-compilation
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android21-clang"
# Only set global CC and AR for Android builds (when TAURI_ANDROID_BUILD is set)
if [ "$TAURI_ANDROID_BUILD" = "true" ]; then
# Only set global CC and AR for Android builds (when IS_ANDROID is set)
if [ "$IS_ANDROID" = "true" ]; then
export CC="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android21-clang"
export AR="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-ar"
echo "Global CC and AR set for Android build"

View File

@ -31,7 +31,7 @@
"@vitest/coverage-v8": "^2.1.8",
"@vitest/ui": "^2.1.8",
"eslint": "8.57.0",
"happy-dom": "^15.11.6",
"happy-dom": "^20.0.0",
"pacote": "^21.0.0",
"react": "19.0.0",
"request": "^2.88.2",

View File

@ -11,6 +11,8 @@ export enum ExtensionTypeEnum {
HuggingFace = 'huggingFace',
Engine = 'engine',
Hardware = 'hardware',
RAG = 'rag',
VectorDB = 'vectorDB',
}
export interface ExtensionType {

View File

@ -182,6 +182,7 @@ export interface SessionInfo {
port: number // llama-server output port (corrected from portid)
model_id: string //name of the model
model_path: string // path of the loaded model
is_embedding: boolean
api_key: string
mmproj_path?: string
}

View File

@ -23,3 +23,8 @@ export { MCPExtension } from './mcp'
* Base AI Engines.
*/
export * from './engines'
export { RAGExtension, RAG_INTERNAL_SERVER } from './rag'
export type { AttachmentInput, IngestAttachmentsResult } from './rag'
export { VectorDBExtension } from './vector-db'
export type { SearchMode, VectorDBStatus, VectorChunkInput, VectorSearchResult, AttachmentFileInfo, VectorDBFileInput, VectorDBIngestOptions } from './vector-db'

View File

@ -0,0 +1,36 @@
import { BaseExtension, ExtensionTypeEnum } from '../extension'
import type { MCPTool, MCPToolCallResult } from '../../types'
import type { AttachmentFileInfo } from './vector-db'
export interface AttachmentInput {
path: string
name?: string
type?: string
size?: number
}
export interface IngestAttachmentsResult {
filesProcessed: number
chunksInserted: number
files: AttachmentFileInfo[]
}
export const RAG_INTERNAL_SERVER = 'rag-internal'
/**
* RAG extension base: exposes RAG tools and orchestration API.
*/
export abstract class RAGExtension extends BaseExtension {
type(): ExtensionTypeEnum | undefined {
return ExtensionTypeEnum.RAG
}
abstract getTools(): Promise<MCPTool[]>
/**
* Lightweight list of tool names for quick routing/lookup.
*/
abstract getToolNames(): Promise<string[]>
abstract callTool(toolName: string, args: Record<string, unknown>): Promise<MCPToolCallResult>
abstract ingestAttachments(threadId: string, files: AttachmentInput[]): Promise<IngestAttachmentsResult>
}

View File

@ -0,0 +1,82 @@
import { BaseExtension, ExtensionTypeEnum } from '../extension'
export type SearchMode = 'auto' | 'ann' | 'linear'
export interface VectorDBStatus {
ann_available: boolean
}
export interface VectorChunkInput {
text: string
embedding: number[]
}
export interface VectorSearchResult {
id: string
text: string
score?: number
file_id: string
chunk_file_order: number
}
export interface AttachmentFileInfo {
id: string
name?: string
path?: string
type?: string
size?: number
chunk_count: number
}
// High-level input types for file ingestion
export interface VectorDBFileInput {
path: string
name?: string
type?: string
size?: number
}
export interface VectorDBIngestOptions {
chunkSize: number
chunkOverlap: number
}
/**
* Vector DB extension base: abstraction over local vector storage and search.
*/
export abstract class VectorDBExtension extends BaseExtension {
type(): ExtensionTypeEnum | undefined {
return ExtensionTypeEnum.VectorDB
}
abstract getStatus(): Promise<VectorDBStatus>
abstract createCollection(threadId: string, dimension: number): Promise<void>
abstract insertChunks(
threadId: string,
fileId: string,
chunks: VectorChunkInput[]
): Promise<void>
abstract ingestFile(
threadId: string,
file: VectorDBFileInput,
opts: VectorDBIngestOptions
): Promise<AttachmentFileInfo>
abstract searchCollection(
threadId: string,
query_embedding: number[],
limit: number,
threshold: number,
mode?: SearchMode,
fileIds?: string[]
): Promise<VectorSearchResult[]>
abstract deleteChunks(threadId: string, ids: string[]): Promise<void>
abstract deleteFile(threadId: string, fileId: string): Promise<void>
abstract deleteCollection(threadId: string): Promise<void>
abstract listAttachments(threadId: string, limit?: number): Promise<AttachmentFileInfo[]>
abstract getChunks(
threadId: string,
fileId: string,
startOrder: number,
endOrder: number
): Promise<VectorSearchResult[]>
}

View File

@ -12,6 +12,8 @@ export type SettingComponentProps = {
extensionName?: string
requireModelReload?: boolean
configType?: ConfigType
titleKey?: string
descriptionKey?: string
}
export type ConfigType = 'runtime' | 'setting'

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

View File

@ -0,0 +1,28 @@
---
title: "Jan v0.7.0: Jan Projects"
version: 0.7.0
description: "Jan v0.7.0 introduces Projects, model renaming, llama.cpp auto-tuning, model stats, and Azure support."
date: 2025-10-02
ogImage: "/assets/images/changelog/jan-release-v0.7.0.jpeg"
---
import ChangelogHeader from "@/components/Changelog/ChangelogHeader"
import { Callout } from 'nextra/components'
<ChangelogHeader title="Jan v0.7.0" date="2025-10-01" ogImage="/assets/images/changelog/jan-release-v0.7.0.jpeg" />
## Jan v0.7.0: Jan Projects
Jan v0.7.0 is live! This release focuses on helping you organize your workspace and better understand how models run.
### Whats new
- **Projects**: Group related chats under one project for a cleaner workflow.
- **Rename models**: Give your models custom names for easier identification.
- **Model context stats**: See context usage when a model runs.
- **Auto-loaded cloud models**: Cloud model names now appear automatically.
---
Update your Jan or [download the latest version](https://jan.ai/).
For the complete list of changes, see the [GitHub release notes](https://github.com/janhq/jan/releases/tag/v0.7.0).

View File

@ -0,0 +1,26 @@
---
title: "Jan v0.7.1: Fixes Windows Version Revert & OpenRouter Models"
version: 0.7.1
description: "Jan v0.7.1 focuses on bug fixes, including a windows version revert and improvements to OpenRouter models."
date: 2025-10-03
---
import ChangelogHeader from "@/components/Changelog/ChangelogHeader"
import { Callout } from 'nextra/components'
<ChangelogHeader title="Jan v0.7.1" date="2025-10-03" />
### Bug Fixes: Windows Version Revert & OpenRouter Models
#### Two quick fixes:
- Jan no longer reverts to an older version on load
- OpenRouter can now add models again
- Add headers for anthropic request to fetch models
---
Update your Jan or [download the latest version](https://jan.ai/).
For the complete list of changes, see the [GitHub release notes](https://github.com/janhq/jan/releases/tag/v0.7.1).

View File

@ -0,0 +1,25 @@
---
title: "Jan v0.7.2: Security Update"
version: 0.7.2
description: "Jan v0.7.2 updates the happy-dom dependency to v20.0.0 to address a recently disclosed sandbox vulnerability."
date: 2025-10-16
---
import ChangelogHeader from "@/components/Changelog/ChangelogHeader"
import { Callout } from 'nextra/components'
<ChangelogHeader title="Jan v0.7.2" date="2025-10-16" />
## Jan v0.7.2: Security Update (happy-dom v20)
This release focuses on **security and stability improvements**.
It updates the `happy-dom` dependency to the latest version to address a recently disclosed vulnerability.
### Security Fix
- Updated `happy-dom` to **^20.0.0**, preventing untrusted JavaScript executed within HAPPY DOM from accessing process-level functions and executing arbitrary code outside the intended sandbox.
---
Update your Jan or [download the latest version](https://jan.ai/).
For the complete list of changes, see the [GitHub release notes](https://github.com/janhq/jan/releases/tag/v0.7.2).

View File

@ -41,7 +41,7 @@ Jan is an open-source replacement for ChatGPT:
Jan is a full [product suite](https://en.wikipedia.org/wiki/Software_suite) that offers an alternative to Big AI:
- [Jan Desktop](/docs/desktop/quickstart): macOS, Windows, and Linux apps with offline mode
- [Jan Web](https://chat.jan.ai): Jan on browser, a direct alternative to chatgpt.com
- [Jan Web](https://chat.menlo.ai): Jan on browser, a direct alternative to chatgpt.com
- Jan Mobile: iOS and Android apps (Coming Soon)
- [Jan Server](/docs/server): deploy locally, in your cloud, or on-prem
- [Jan Models](/docs/models): Open-source models optimized for deep research, tool use, and reasoning

View File

@ -4,8 +4,9 @@
*/
import { getSharedAuthService, JanAuthService } from '../shared'
import { JanModel, janProviderStore } from './store'
import { ApiError } from '../shared/types/errors'
import { JAN_API_ROUTES } from './const'
import { JanModel, janProviderStore } from './store'
// JAN_API_BASE is defined in vite.config.ts
@ -19,12 +20,7 @@ const TEMPORARY_CHAT_ID = 'temporary-chat'
*/
function getChatCompletionConfig(request: JanChatCompletionRequest, stream: boolean = false) {
const isTemporaryChat = request.conversation_id === TEMPORARY_CHAT_ID
// For temporary chats, use the stateless /chat/completions endpoint
// For regular conversations, use the stateful /conv/chat/completions endpoint
const endpoint = isTemporaryChat
? `${JAN_API_BASE}/chat/completions`
: `${JAN_API_BASE}/conv/chat/completions`
const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.CHAT_COMPLETIONS}`
const payload = {
...request,
@ -44,9 +40,30 @@ function getChatCompletionConfig(request: JanChatCompletionRequest, stream: bool
return { endpoint, payload, isTemporaryChat }
}
export interface JanModelsResponse {
interface JanModelSummary {
id: string
object: string
data: JanModel[]
owned_by: string
created?: number
}
interface JanModelsResponse {
object: string
data: JanModelSummary[]
}
interface JanModelCatalogResponse {
id: string
supported_parameters?: {
names?: string[]
default?: Record<string, unknown>
}
extras?: {
supported_parameters?: string[]
default_parameters?: Record<string, unknown>
[key: string]: unknown
}
[key: string]: unknown
}
export interface JanChatMessage {
@ -112,6 +129,8 @@ export interface JanChatCompletionChunk {
export class JanApiClient {
private static instance: JanApiClient
private authService: JanAuthService
private modelsCache: JanModel[] | null = null
private modelsFetchPromise: Promise<JanModel[]> | null = null
private constructor() {
this.authService = getSharedAuthService()
@ -124,25 +143,64 @@ export class JanApiClient {
return JanApiClient.instance
}
async getModels(): Promise<JanModel[]> {
async getModels(options?: { forceRefresh?: boolean }): Promise<JanModel[]> {
try {
const forceRefresh = options?.forceRefresh ?? false
if (forceRefresh) {
this.modelsCache = null
} else if (this.modelsCache) {
return this.modelsCache
}
if (this.modelsFetchPromise) {
return this.modelsFetchPromise
}
janProviderStore.setLoadingModels(true)
janProviderStore.clearError()
this.modelsFetchPromise = (async () => {
const response = await this.authService.makeAuthenticatedRequest<JanModelsResponse>(
`${JAN_API_BASE}/conv/models`
`${JAN_API_BASE}${JAN_API_ROUTES.MODELS}`
)
const models = response.data || []
const summaries = response.data || []
const models: JanModel[] = await Promise.all(
summaries.map(async (summary) => {
const supportedParameters = await this.fetchSupportedParameters(summary.id)
const capabilities = this.deriveCapabilitiesFromParameters(supportedParameters)
return {
id: summary.id,
object: summary.object,
owned_by: summary.owned_by,
created: summary.created,
capabilities,
supportedParameters,
}
})
)
this.modelsCache = models
janProviderStore.setModels(models)
return models
})()
return await this.modelsFetchPromise
} catch (error) {
this.modelsCache = null
this.modelsFetchPromise = null
const errorMessage = error instanceof ApiError ? error.message :
error instanceof Error ? error.message : 'Failed to fetch models'
janProviderStore.setError(errorMessage)
janProviderStore.setLoadingModels(false)
throw error
} finally {
this.modelsFetchPromise = null
}
}
@ -254,7 +312,7 @@ export class JanApiClient {
async initialize(): Promise<void> {
try {
janProviderStore.setAuthenticated(true)
// Fetch initial models
// Fetch initial models (cached for subsequent calls)
await this.getModels()
console.log('Jan API client initialized successfully')
} catch (error) {
@ -266,6 +324,52 @@ export class JanApiClient {
janProviderStore.setInitializing(false)
}
}
private async fetchSupportedParameters(modelId: string): Promise<string[]> {
try {
const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.MODEL_CATALOGS}/${this.encodeModelIdForCatalog(modelId)}`
const catalog = await this.authService.makeAuthenticatedRequest<JanModelCatalogResponse>(endpoint)
return this.extractSupportedParameters(catalog)
} catch (error) {
console.warn(`Failed to fetch catalog metadata for model "${modelId}":`, error)
return []
}
}
private encodeModelIdForCatalog(modelId: string): string {
return modelId
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/')
}
private extractSupportedParameters(catalog: JanModelCatalogResponse | null | undefined): string[] {
if (!catalog) {
return []
}
const primaryNames = catalog.supported_parameters?.names
if (Array.isArray(primaryNames) && primaryNames.length > 0) {
return [...new Set(primaryNames)]
}
const extraNames = catalog.extras?.supported_parameters
if (Array.isArray(extraNames) && extraNames.length > 0) {
return [...new Set(extraNames)]
}
return []
}
private deriveCapabilitiesFromParameters(parameters: string[]): string[] {
const capabilities = new Set<string>()
if (parameters.includes('tools')) {
capabilities.add('tools')
}
return Array.from(capabilities)
}
}
export const janApiClient = JanApiClient.getInstance()

View File

@ -0,0 +1,7 @@
export const JAN_API_ROUTES = {
MODELS: '/models',
CHAT_COMPLETIONS: '/chat/completions',
MODEL_CATALOGS: '/models/catalogs',
} as const
export const MODEL_PROVIDER_STORAGE_KEY = 'model-provider'

View File

@ -0,0 +1,122 @@
import type { JanModel } from './store'
import { MODEL_PROVIDER_STORAGE_KEY } from './const'
type StoredModel = {
id?: string
capabilities?: unknown
[key: string]: unknown
}
type StoredProvider = {
provider?: string
models?: StoredModel[]
[key: string]: unknown
}
type StoredState = {
state?: {
providers?: StoredProvider[]
[key: string]: unknown
}
version?: number
[key: string]: unknown
}
const normalizeCapabilities = (capabilities: unknown): string[] => {
if (!Array.isArray(capabilities)) {
return []
}
return [...new Set(capabilities.filter((item): item is string => typeof item === 'string'))].sort(
(a, b) => a.localeCompare(b)
)
}
/**
* Synchronize Jan models stored in localStorage with the latest server state.
* Returns true if the stored data was modified (including being cleared).
*/
export function syncJanModelsLocalStorage(
remoteModels: JanModel[],
storageKey: string = MODEL_PROVIDER_STORAGE_KEY
): boolean {
const rawStorage = localStorage.getItem(storageKey)
if (!rawStorage) {
return false
}
let storedState: StoredState
try {
storedState = JSON.parse(rawStorage) as StoredState
} catch (error) {
console.warn('Failed to parse Jan model storage; clearing entry.', error)
localStorage.removeItem(storageKey)
return true
}
const providers = storedState?.state?.providers
if (!Array.isArray(providers)) {
return false
}
const remoteModelMap = new Map(remoteModels.map((model) => [model.id, model]))
let storageUpdated = false
for (const provider of providers) {
if (provider.provider !== 'jan' || !Array.isArray(provider.models)) {
continue
}
const updatedModels: StoredModel[] = []
for (const model of provider.models) {
const modelId = typeof model.id === 'string' ? model.id : null
if (!modelId) {
storageUpdated = true
continue
}
const remoteModel = remoteModelMap.get(modelId)
if (!remoteModel) {
console.log(`Removing unknown Jan model from localStorage: ${modelId}`)
storageUpdated = true
continue
}
const storedCapabilities = normalizeCapabilities(model.capabilities)
const remoteCapabilities = normalizeCapabilities(remoteModel.capabilities)
const capabilitiesMatch =
storedCapabilities.length === remoteCapabilities.length &&
storedCapabilities.every((cap, index) => cap === remoteCapabilities[index])
if (!capabilitiesMatch) {
console.log(
`Updating capabilities for Jan model ${modelId}:`,
storedCapabilities,
'=>',
remoteCapabilities
)
updatedModels.push({
...model,
capabilities: remoteModel.capabilities,
})
storageUpdated = true
} else {
updatedModels.push(model)
}
}
if (updatedModels.length !== provider.models.length) {
storageUpdated = true
}
provider.models = updatedModels
}
if (storageUpdated) {
localStorage.setItem(storageKey, JSON.stringify(storedState))
}
return storageUpdated
}

View File

@ -14,12 +14,10 @@ import {
ImportOptions,
} from '@janhq/core' // cspell: disable-line
import { janApiClient, JanChatMessage } from './api'
import { syncJanModelsLocalStorage } from './helpers'
import { janProviderStore } from './store'
import { ApiError } from '../shared/types/errors'
// Jan models support tools via MCP
const JAN_MODEL_CAPABILITIES = ['tools'] as const
export default class JanProviderWeb extends AIEngine {
readonly provider = 'jan'
private activeSessions: Map<string, SessionInfo> = new Map()
@ -28,11 +26,11 @@ export default class JanProviderWeb extends AIEngine {
console.log('Loading Jan Provider Extension...')
try {
// Check and clear invalid Jan models (capabilities mismatch)
this.validateJanModelsLocalStorage()
// Initialize authentication and fetch models
// Initialize authentication
await janApiClient.initialize()
// Check and sync stored Jan models against latest catalog data
await this.validateJanModelsLocalStorage()
console.log('Jan Provider Extension loaded successfully')
} catch (error) {
console.error('Failed to load Jan Provider Extension:', error)
@ -43,46 +41,17 @@ export default class JanProviderWeb extends AIEngine {
}
// Verify Jan models capabilities in localStorage
private validateJanModelsLocalStorage() {
private async validateJanModelsLocalStorage(): Promise<void> {
try {
console.log("Validating Jan models in localStorage...")
const storageKey = 'model-provider'
const data = localStorage.getItem(storageKey)
if (!data) return
console.log('Validating Jan models in localStorage...')
const parsed = JSON.parse(data)
if (!parsed?.state?.providers) return
const remoteModels = await janApiClient.getModels()
const storageUpdated = syncJanModelsLocalStorage(remoteModels)
// Check if any Jan model has incorrect capabilities
let hasInvalidModel = false
for (const provider of parsed.state.providers) {
if (provider.provider === 'jan' && provider.models) {
for (const model of provider.models) {
console.log(`Checking Jan model: ${model.id}`, model.capabilities)
if (JSON.stringify(model.capabilities) !== JSON.stringify(JAN_MODEL_CAPABILITIES)) {
hasInvalidModel = true
console.log(`Found invalid Jan model: ${model.id}, clearing localStorage`)
break
}
}
}
if (hasInvalidModel) break
}
// If any invalid model found, just clear the storage
if (hasInvalidModel) {
// Force clear the storage
localStorage.removeItem(storageKey)
// Verify it's actually removed
const afterRemoval = localStorage.getItem(storageKey)
// If still present, try setting to empty state
if (afterRemoval) {
// Try alternative clearing method
localStorage.setItem(storageKey, JSON.stringify({ state: { providers: [] }, version: parsed.version || 3 }))
}
console.log('Cleared model-provider from localStorage due to invalid Jan capabilities')
// Force a page reload to ensure clean state
if (storageUpdated) {
console.log(
'Synchronized Jan models in localStorage with server capabilities; reloading...'
)
window.location.reload()
}
} catch (error) {
@ -119,7 +88,7 @@ export default class JanProviderWeb extends AIEngine {
path: undefined, // Remote model, no local path
owned_by: model.owned_by,
object: model.object,
capabilities: [...JAN_MODEL_CAPABILITIES],
capabilities: [...model.capabilities],
}
: undefined
)
@ -140,7 +109,7 @@ export default class JanProviderWeb extends AIEngine {
path: undefined, // Remote model, no local path
owned_by: model.owned_by,
object: model.object,
capabilities: [...JAN_MODEL_CAPABILITIES],
capabilities: [...model.capabilities],
}))
} catch (error) {
console.error('Failed to list Jan models:', error)
@ -159,6 +128,7 @@ export default class JanProviderWeb extends AIEngine {
port: 443, // HTTPS port
model_id: modelId,
model_path: `remote:${modelId}`, // Indicate this is a remote model
is_embedding: false, // assume false here, TODO: might need further implementation
api_key: '', // API key handled by auth service
}
@ -193,8 +163,12 @@ export default class JanProviderWeb extends AIEngine {
console.error(`Failed to unload Jan session ${sessionId}:`, error)
return {
success: false,
error: error instanceof ApiError ? error.message :
error instanceof Error ? error.message : 'Unknown error',
error:
error instanceof ApiError
? error.message
: error instanceof Error
? error.message
: 'Unknown error',
}
}
}

View File

@ -9,6 +9,9 @@ export interface JanModel {
id: string
object: string
owned_by: string
created?: number
capabilities: string[]
supportedParameters?: string[]
}
export interface JanProviderState {

View File

@ -5,7 +5,7 @@
declare const JAN_API_BASE: string
import { User, AuthState, AuthBroadcastMessage } from './types'
import { User, AuthState, AuthBroadcastMessage, AuthTokens } from './types'
import {
AUTH_STORAGE_KEYS,
AUTH_ENDPOINTS,
@ -115,7 +115,7 @@ export class JanAuthService {
// Store tokens and set authenticated state
this.accessToken = tokens.access_token
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
this.setAuthProvider(providerId)
this.authBroadcast.broadcastLogin()
@ -158,7 +158,7 @@ export class JanAuthService {
const tokens = await refreshToken()
this.accessToken = tokens.access_token
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
} catch (error) {
console.error('Failed to refresh access token:', error)
if (error instanceof ApiError && error.isStatus(401)) {
@ -343,6 +343,23 @@ export class JanAuthService {
localStorage.removeItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER)
}
private computeTokenExpiry(tokens: AuthTokens): number {
if (tokens.expires_at) {
const expiresAt = new Date(tokens.expires_at).getTime()
if (!Number.isNaN(expiresAt)) {
return expiresAt
}
console.warn('Invalid expires_at format in auth tokens:', tokens.expires_at)
}
if (typeof tokens.expires_in === 'number') {
return Date.now() + tokens.expires_in * 1000
}
console.warn('Auth tokens missing expiry information; defaulting to immediate expiry')
return Date.now()
}
/**
* Ensure guest access is available
*/
@ -352,7 +369,7 @@ export class JanAuthService {
if (!this.accessToken || Date.now() > this.tokenExpiryTime) {
const tokens = await guestLogin()
this.accessToken = tokens.access_token
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
}
} catch (error) {
console.error('Failed to ensure guest access:', error)
@ -387,7 +404,6 @@ export class JanAuthService {
case AUTH_EVENTS.LOGOUT:
// Another tab logged out, clear our state
this.clearAuthState()
this.ensureGuestAccess().catch(console.error)
break
}
})

View File

@ -16,7 +16,8 @@ export type AuthType = ProviderType | 'guest'
export interface AuthTokens {
access_token: string
expires_in: number
expires_in?: number
expires_at?: string
object: string
}

View File

@ -156,8 +156,13 @@ export async function listSupportedBackends(): Promise<
supportedBackends.push('macos-arm64')
}
// get latest backends from Github
const remoteBackendVersions =
let remoteBackendVersions = []
try {
remoteBackendVersions =
await fetchRemoteSupportedBackends(supportedBackends)
} catch (e) {
console.debug(`Not able to get remote backends, Jan might be offline or network problem: ${String(e)}`)
}
// Get locally installed versions
const localBackendVersions = await getLocalInstalledBackends()

View File

@ -39,7 +39,6 @@ import { getProxyConfig } from './util'
import { basename } from '@tauri-apps/api/path'
import {
readGgufMetadata,
estimateKVCacheSize,
getModelSize,
isModelSupported,
planModelLoadInternal,
@ -58,6 +57,8 @@ type LlamacppConfig = {
chat_template: string
n_gpu_layers: number
offload_mmproj: boolean
cpu_moe: boolean
n_cpu_moe: number
override_tensor_buffer_t: string
ctx_size: number
threads: number
@ -1527,6 +1528,7 @@ export default class llamacpp_extension extends AIEngine {
if (
this.autoUnload &&
!isEmbedding &&
(loadedModels.length > 0 || otherLoadingPromises.length > 0)
) {
// Wait for OTHER loading models to finish, then unload everything
@ -1534,10 +1536,33 @@ export default class llamacpp_extension extends AIEngine {
await Promise.all(otherLoadingPromises)
}
// Now unload all loaded models
// Now unload all loaded Text models excluding embedding models
const allLoadedModels = await this.getLoadedModels()
if (allLoadedModels.length > 0) {
await Promise.all(allLoadedModels.map((model) => this.unload(model)))
const sessionInfos: (SessionInfo | null)[] = await Promise.all(
allLoadedModels.map(async (modelId) => {
try {
return await this.findSessionByModel(modelId)
} catch (e) {
logger.warn(`Unable to find session for model "${modelId}": ${e}`)
return null // treat as “noteligible for unload”
}
})
)
logger.info(JSON.stringify(sessionInfos))
const nonEmbeddingModels: string[] = sessionInfos
.filter(
(s): s is SessionInfo => s !== null && s.is_embedding === false
)
.map((s) => s.model_id)
if (nonEmbeddingModels.length > 0) {
await Promise.all(
nonEmbeddingModels.map((modelId) => this.unload(modelId))
)
}
}
}
const args: string[] = []
@ -1581,6 +1606,10 @@ export default class llamacpp_extension extends AIEngine {
])
args.push('--jinja')
args.push('-m', modelPath)
if (cfg.cpu_moe) args.push('--cpu-moe')
if (cfg.n_cpu_moe && cfg.n_cpu_moe > 0) {
args.push('--n-cpu-moe', String(cfg.n_cpu_moe))
}
// For overriding tensor buffer type, useful where
// massive MOE models can be made faster by keeping attention on the GPU
// and offloading the expert FFNs to the CPU.
@ -1631,7 +1660,7 @@ export default class llamacpp_extension extends AIEngine {
if (cfg.no_kv_offload) args.push('--no-kv-offload')
if (isEmbedding) {
args.push('--embedding')
args.push('--pooling mean')
args.push('--pooling', 'mean')
} else {
if (cfg.ctx_size > 0) args.push('--ctx-size', String(cfg.ctx_size))
if (cfg.n_predict > 0) args.push('--n-predict', String(cfg.n_predict))
@ -1670,6 +1699,7 @@ export default class llamacpp_extension extends AIEngine {
libraryPath,
args,
envs,
isEmbedding,
}
)
return sInfo
@ -2005,6 +2035,69 @@ export default class llamacpp_extension extends AIEngine {
libraryPath,
envs,
})
// On Linux with AMD GPUs, llama.cpp via Vulkan may report UMA (shared) memory as device-local.
// For clearer UX, override with dedicated VRAM from the hardware plugin when available.
try {
const sysInfo = await getSystemInfo()
if (sysInfo?.os_type === 'linux' && Array.isArray(sysInfo.gpus)) {
const usage = await getSystemUsage()
if (usage && Array.isArray(usage.gpus)) {
const uuidToUsage: Record<string, { total_memory: number; used_memory: number }> = {}
for (const u of usage.gpus as any[]) {
if (u && typeof u.uuid === 'string') {
uuidToUsage[u.uuid] = u
}
}
const indexToAmdUuid = new Map<number, string>()
for (const gpu of sysInfo.gpus as any[]) {
const vendorStr =
typeof gpu?.vendor === 'string'
? gpu.vendor
: typeof gpu?.vendor === 'object' && gpu.vendor !== null
? String(gpu.vendor)
: ''
if (
vendorStr.toUpperCase().includes('AMD') &&
gpu?.vulkan_info &&
typeof gpu.vulkan_info.index === 'number' &&
typeof gpu.uuid === 'string'
) {
indexToAmdUuid.set(gpu.vulkan_info.index, gpu.uuid)
}
}
if (indexToAmdUuid.size > 0) {
const adjusted = dList.map((dev) => {
if (dev.id?.startsWith('Vulkan')) {
const match = /^Vulkan(\d+)/.exec(dev.id)
if (match) {
const vIdx = Number(match[1])
const uuid = indexToAmdUuid.get(vIdx)
if (uuid) {
const u = uuidToUsage[uuid]
if (
u &&
typeof u.total_memory === 'number' &&
typeof u.used_memory === 'number'
) {
const total = Math.max(0, Math.floor(u.total_memory))
const free = Math.max(0, Math.floor(u.total_memory - u.used_memory))
return { ...dev, mem: total, free }
}
}
}
}
return dev
})
return adjusted
}
}
}
} catch (e) {
logger.warn('Device memory override (AMD/Linux) failed:', e)
}
return dList
} catch (error) {
logger.error('Failed to query devices:\n', error)
@ -2013,6 +2106,7 @@ export default class llamacpp_extension extends AIEngine {
}
async embed(text: string[]): Promise<EmbeddingResponse> {
// Ensure the sentence-transformer model is present
let sInfo = await this.findSessionByModel('sentence-transformer-mini')
if (!sInfo) {
const downloadedModelList = await this.list()
@ -2026,16 +2120,19 @@ export default class llamacpp_extension extends AIEngine {
'https://huggingface.co/second-state/All-MiniLM-L6-v2-Embedding-GGUF/resolve/main/all-MiniLM-L6-v2-ggml-model-f16.gguf?download=true',
})
}
sInfo = await this.load('sentence-transformer-mini')
// Load specifically in embedding mode
sInfo = await this.load('sentence-transformer-mini', undefined, true)
}
const baseUrl = `http://localhost:${sInfo.port}/v1/embeddings`
const attemptRequest = async (session: SessionInfo) => {
const baseUrl = `http://localhost:${session.port}/v1/embeddings`
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${sInfo.api_key}`,
'Authorization': `Bearer ${session.api_key}`,
}
const body = JSON.stringify({
input: text,
model: sInfo.model_id,
model: session.model_id,
encoding_format: 'float',
})
const response = await fetch(baseUrl, {
@ -2043,13 +2140,25 @@ export default class llamacpp_extension extends AIEngine {
headers,
body,
})
return response
}
// First try with the existing session (may have been started without --embedding previously)
let response = await attemptRequest(sInfo)
// If embeddings endpoint is not available (501), reload with embedding mode and retry once
if (response.status === 501) {
try {
await this.unload('sentence-transformer-mini')
} catch {}
sInfo = await this.load('sentence-transformer-mini', undefined, true)
response = await attemptRequest(sInfo)
}
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(
`API request failed with status ${response.status}: ${JSON.stringify(
errorData
)}`
`API request failed with status ${response.status}: ${JSON.stringify(errorData)}`
)
}
const responseData = await response.json()
@ -2151,7 +2260,12 @@ export default class llamacpp_extension extends AIEngine {
if (mmprojPath && !this.isAbsolutePath(mmprojPath))
mmprojPath = await joinPath([await getJanDataFolderPath(), path])
try {
const result = await planModelLoadInternal(path, this.memoryMode, mmprojPath, requestedCtx)
const result = await planModelLoadInternal(
path,
this.memoryMode,
mmprojPath,
requestedCtx
)
return result
} catch (e) {
throw new Error(String(e))
@ -2279,12 +2393,18 @@ export default class llamacpp_extension extends AIEngine {
}
// Calculate text tokens
const messages = JSON.stringify({ messages: opts.messages })
// Use chat_template_kwargs from opts if provided, otherwise default to disable enable_thinking
const tokenizeRequest = {
messages: opts.messages,
chat_template_kwargs: opts.chat_template_kwargs || {
enable_thinking: false,
},
}
let parseResponse = await fetch(`${baseUrl}/apply-template`, {
method: 'POST',
headers: headers,
body: messages,
body: JSON.stringify(tokenizeRequest),
})
if (!parseResponse.ok) {

View File

@ -0,0 +1,33 @@
{
"name": "@janhq/rag-extension",
"productName": "RAG Tools",
"version": "0.1.0",
"description": "Registers RAG tools and orchestrates retrieval across parser, embeddings, and vector DB",
"main": "dist/index.js",
"module": "dist/module.js",
"author": "Jan <service@jan.ai>",
"license": "AGPL-3.0",
"scripts": {
"build": "rolldown -c rolldown.config.mjs",
"build:publish": "rimraf *.tgz --glob || true && yarn build && npm pack && cpx *.tgz ../../pre-install"
},
"devDependencies": {
"cpx": "1.5.0",
"rimraf": "6.0.1",
"rolldown": "1.0.0-beta.1",
"typescript": "5.9.2"
},
"dependencies": {
"@janhq/core": "../../core/package.tgz",
"@janhq/tauri-plugin-rag-api": "link:../../src-tauri/plugins/tauri-plugin-rag",
"@janhq/tauri-plugin-vector-db-api": "link:../../src-tauri/plugins/tauri-plugin-vector-db"
},
"files": [
"dist/*",
"package.json"
],
"installConfig": {
"hoistingLimits": "workspaces"
},
"packageManager": "yarn@4.5.3"
}

View File

@ -0,0 +1,14 @@
import { defineConfig } from 'rolldown'
import settingJson from './settings.json' with { type: 'json' }
export default defineConfig({
input: 'src/index.ts',
output: {
format: 'esm',
file: 'dist/index.js',
},
platform: 'browser',
define: {
SETTINGS: JSON.stringify(settingJson),
},
})

View File

@ -0,0 +1,58 @@
[
{
"key": "enabled",
"titleKey": "settings:attachments.enable",
"descriptionKey": "settings:attachments.enableDesc",
"controllerType": "checkbox",
"controllerProps": { "value": true }
},
{
"key": "max_file_size_mb",
"titleKey": "settings:attachments.maxFile",
"descriptionKey": "settings:attachments.maxFileDesc",
"controllerType": "input",
"controllerProps": { "value": 20, "type": "number", "min": 1, "max": 200, "step": 1, "textAlign": "right" }
},
{
"key": "retrieval_limit",
"titleKey": "settings:attachments.topK",
"descriptionKey": "settings:attachments.topKDesc",
"controllerType": "input",
"controllerProps": { "value": 3, "type": "number", "min": 1, "max": 20, "step": 1, "textAlign": "right" }
},
{
"key": "retrieval_threshold",
"titleKey": "settings:attachments.threshold",
"descriptionKey": "settings:attachments.thresholdDesc",
"controllerType": "input",
"controllerProps": { "value": 0.3, "type": "number", "min": 0, "max": 1, "step": 0.01, "textAlign": "right" }
},
{
"key": "chunk_size_tokens",
"titleKey": "settings:attachments.chunkSize",
"descriptionKey": "settings:attachments.chunkSizeDesc",
"controllerType": "input",
"controllerProps": { "value": 512, "type": "number", "min": 64, "max": 8192, "step": 64, "textAlign": "right" }
},
{
"key": "overlap_tokens",
"titleKey": "settings:attachments.chunkOverlap",
"descriptionKey": "settings:attachments.chunkOverlapDesc",
"controllerType": "input",
"controllerProps": { "value": 64, "type": "number", "min": 0, "max": 1024, "step": 16, "textAlign": "right" }
},
{
"key": "search_mode",
"titleKey": "settings:attachments.searchMode",
"descriptionKey": "settings:attachments.searchModeDesc",
"controllerType": "dropdown",
"controllerProps": {
"value": "auto",
"options": [
{ "name": "Auto (recommended)", "value": "auto" },
{ "name": "ANN (sqlite-vec)", "value": "ann" },
{ "name": "Linear", "value": "linear" }
]
}
}
]

5
extensions/rag-extension/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
import type { SettingComponentProps } from '@janhq/core'
declare global {
const SETTINGS: SettingComponentProps[]
}
export {}

View File

@ -0,0 +1,14 @@
import type { BaseExtension, ExtensionTypeEnum } from '@janhq/core'
declare global {
interface Window {
core?: {
extensionManager: {
get<T = BaseExtension>(type: ExtensionTypeEnum): T | undefined
getByName(name: string): BaseExtension | undefined
}
}
}
}
export {}

View File

@ -0,0 +1,305 @@
import { RAGExtension, MCPTool, MCPToolCallResult, ExtensionTypeEnum, VectorDBExtension, type AttachmentInput, type SettingComponentProps, AIEngine, type AttachmentFileInfo } from '@janhq/core'
import './env.d'
import { getRAGTools, RETRIEVE, LIST_ATTACHMENTS, GET_CHUNKS } from './tools'
export default class RagExtension extends RAGExtension {
private config = {
enabled: true,
retrievalLimit: 3,
retrievalThreshold: 0.3,
chunkSizeTokens: 512,
overlapTokens: 64,
searchMode: 'auto' as 'auto' | 'ann' | 'linear',
maxFileSizeMB: 20,
}
async onLoad(): Promise<void> {
const settings = structuredClone(SETTINGS) as SettingComponentProps[]
await this.registerSettings(settings)
this.config.enabled = await this.getSetting('enabled', this.config.enabled)
this.config.maxFileSizeMB = await this.getSetting('max_file_size_mb', this.config.maxFileSizeMB)
this.config.retrievalLimit = await this.getSetting('retrieval_limit', this.config.retrievalLimit)
this.config.retrievalThreshold = await this.getSetting('retrieval_threshold', this.config.retrievalThreshold)
this.config.chunkSizeTokens = await this.getSetting('chunk_size_tokens', this.config.chunkSizeTokens)
this.config.overlapTokens = await this.getSetting('overlap_tokens', this.config.overlapTokens)
this.config.searchMode = await this.getSetting('search_mode', this.config.searchMode)
// Check ANN availability on load
try {
const vec = window.core?.extensionManager.get(ExtensionTypeEnum.VectorDB) as unknown as VectorDBExtension
if (vec?.getStatus) {
const status = await vec.getStatus()
console.log('[RAG] Vector DB ANN support:', status.ann_available ? '✓ AVAILABLE' : '✗ NOT AVAILABLE')
if (!status.ann_available) {
console.warn('[RAG] Warning: sqlite-vec not loaded. Collections will use slower linear search.')
}
}
} catch (e) {
console.error('[RAG] Failed to check ANN status:', e)
}
}
onUnload(): void {}
async getTools(): Promise<MCPTool[]> {
return getRAGTools(this.config.retrievalLimit)
}
async getToolNames(): Promise<string[]> {
// Keep this in sync with getTools() but without building full schemas
return [LIST_ATTACHMENTS, RETRIEVE, GET_CHUNKS]
}
async callTool(toolName: string, args: Record<string, unknown>): Promise<MCPToolCallResult> {
switch (toolName) {
case LIST_ATTACHMENTS:
return this.listAttachments(args)
case RETRIEVE:
return this.retrieve(args)
case GET_CHUNKS:
return this.getChunks(args)
default:
return {
error: `Unknown tool: ${toolName}`,
content: [{ type: 'text', text: `Unknown tool: ${toolName}` }],
}
}
}
private async listAttachments(args: Record<string, unknown>): Promise<MCPToolCallResult> {
const threadId = String(args['thread_id'] || '')
if (!threadId) {
return { error: 'Missing thread_id', content: [{ type: 'text', text: 'Missing thread_id' }] }
}
try {
const vec = window.core?.extensionManager.get(ExtensionTypeEnum.VectorDB) as unknown as VectorDBExtension
if (!vec?.listAttachments) {
return { error: 'Vector DB extension missing listAttachments', content: [{ type: 'text', text: 'Vector DB extension missing listAttachments' }] }
}
const files = await vec.listAttachments(threadId)
return {
error: '',
content: [
{
type: 'text',
text: JSON.stringify({ thread_id: threadId, attachments: files || [] }),
},
],
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return { error: msg, content: [{ type: 'text', text: `List attachments failed: ${msg}` }] }
}
}
private async retrieve(args: Record<string, unknown>): Promise<MCPToolCallResult> {
const threadId = String(args['thread_id'] || '')
const query = String(args['query'] || '')
const fileIds = args['file_ids'] as string[] | undefined
const s = this.config
const topK = (args['top_k'] as number) || s.retrievalLimit || 3
const threshold = s.retrievalThreshold ?? 0.3
const mode: 'auto' | 'ann' | 'linear' = s.searchMode || 'auto'
if (s.enabled === false) {
return {
error: 'Attachments feature disabled',
content: [
{
type: 'text',
text: 'Attachments are disabled in Settings. Enable them to use retrieval.',
},
],
}
}
if (!threadId || !query) {
return {
error: 'Missing thread_id or query',
content: [{ type: 'text', text: 'Missing required parameters' }],
}
}
try {
// Resolve extensions
const vec = window.core?.extensionManager.get(ExtensionTypeEnum.VectorDB) as unknown as VectorDBExtension
if (!vec?.searchCollection) {
return {
error: 'RAG dependencies not available',
content: [
{ type: 'text', text: 'Vector DB extension not available' },
],
}
}
const queryEmb = (await this.embedTexts([query]))?.[0]
if (!queryEmb) {
return {
error: 'Failed to compute embeddings',
content: [{ type: 'text', text: 'Failed to compute embeddings' }],
}
}
const results = await vec.searchCollection(
threadId,
queryEmb,
topK,
threshold,
mode,
fileIds
)
const payload = {
thread_id: threadId,
query,
citations: results?.map((r: any) => ({
id: r.id,
text: r.text,
score: r.score,
file_id: r.file_id,
chunk_file_order: r.chunk_file_order
})) ?? [],
mode,
}
return { error: '', content: [{ type: 'text', text: JSON.stringify(payload) }] }
} catch (e) {
console.error('[RAG] Retrieve error:', e)
let msg = 'Unknown error'
if (e instanceof Error) {
msg = e.message
} else if (typeof e === 'string') {
msg = e
} else if (e && typeof e === 'object') {
msg = JSON.stringify(e)
}
return { error: msg, content: [{ type: 'text', text: `Retrieve failed: ${msg}` }] }
}
}
private async getChunks(args: Record<string, unknown>): Promise<MCPToolCallResult> {
const threadId = String(args['thread_id'] || '')
const fileId = String(args['file_id'] || '')
const startOrder = args['start_order'] as number | undefined
const endOrder = args['end_order'] as number | undefined
if (!threadId || !fileId || startOrder === undefined || endOrder === undefined) {
return {
error: 'Missing thread_id, file_id, start_order, or end_order',
content: [{ type: 'text', text: 'Missing required parameters' }],
}
}
try {
const vec = window.core?.extensionManager.get(ExtensionTypeEnum.VectorDB) as unknown as VectorDBExtension
if (!vec?.getChunks) {
return {
error: 'Vector DB extension not available',
content: [{ type: 'text', text: 'Vector DB extension not available' }],
}
}
const chunks = await vec.getChunks(threadId, fileId, startOrder, endOrder)
const payload = {
thread_id: threadId,
file_id: fileId,
chunks: chunks || [],
}
return { error: '', content: [{ type: 'text', text: JSON.stringify(payload) }] }
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return { error: msg, content: [{ type: 'text', text: `Get chunks failed: ${msg}` }] }
}
}
// Desktop-only ingestion by file paths
async ingestAttachments(
threadId: string,
files: AttachmentInput[]
): Promise<{ filesProcessed: number; chunksInserted: number; files: AttachmentFileInfo[] }> {
if (!threadId || !Array.isArray(files) || files.length === 0) {
return { filesProcessed: 0, chunksInserted: 0, files: [] }
}
// Respect feature flag: do nothing when disabled
if (this.config.enabled === false) {
return { filesProcessed: 0, chunksInserted: 0, files: [] }
}
const vec = window.core?.extensionManager.get(ExtensionTypeEnum.VectorDB) as unknown as VectorDBExtension
if (!vec?.createCollection || !vec?.insertChunks) {
throw new Error('Vector DB extension not available')
}
// Load settings
const s = this.config
const maxSize = (s?.enabled === false ? 0 : s?.maxFileSizeMB) || undefined
const chunkSize = s?.chunkSizeTokens as number | undefined
const chunkOverlap = s?.overlapTokens as number | undefined
let totalChunks = 0
const processedFiles: AttachmentFileInfo[] = []
for (const f of files) {
if (!f?.path) continue
if (maxSize && f.size && f.size > maxSize * 1024 * 1024) {
throw new Error(`File '${f.name}' exceeds size limit (${f.size} bytes > ${maxSize} MB).`)
}
const fileName = f.name || f.path.split(/[\\/]/).pop()
// Preferred/required path: let Vector DB extension handle full file ingestion
const canIngestFile = typeof (vec as any)?.ingestFile === 'function'
if (!canIngestFile) {
console.error('[RAG] Vector DB extension missing ingestFile; cannot ingest document')
continue
}
const info = await (vec as VectorDBExtension).ingestFile(
threadId,
{ path: f.path, name: fileName, type: f.type, size: f.size },
{ chunkSize: chunkSize ?? 512, chunkOverlap: chunkOverlap ?? 64 }
)
totalChunks += Number(info?.chunk_count || 0)
processedFiles.push(info)
}
// Return files we ingested with real IDs directly from ingestFile
return { filesProcessed: processedFiles.length, chunksInserted: totalChunks, files: processedFiles }
}
onSettingUpdate<T>(key: string, value: T): void {
switch (key) {
case 'enabled':
this.config.enabled = Boolean(value)
break
case 'max_file_size_mb':
this.config.maxFileSizeMB = Number(value)
break
case 'retrieval_limit':
this.config.retrievalLimit = Number(value)
break
case 'retrieval_threshold':
this.config.retrievalThreshold = Number(value)
break
case 'chunk_size_tokens':
this.config.chunkSizeTokens = Number(value)
break
case 'overlap_tokens':
this.config.overlapTokens = Number(value)
break
case 'search_mode':
this.config.searchMode = String(value) as 'auto' | 'ann' | 'linear'
break
}
}
// Locally implement embedding logic (previously in embeddings-extension)
private async embedTexts(texts: string[]): Promise<number[][]> {
const llm = window.core?.extensionManager.getByName('@janhq/llamacpp-extension') as AIEngine & { embed?: (texts: string[]) => Promise<{ data: Array<{ embedding: number[]; index: number }> }> }
if (!llm?.embed) throw new Error('llamacpp extension not available')
const res = await llm.embed(texts)
const data: Array<{ embedding: number[]; index: number }> = res?.data || []
const out: number[][] = new Array(texts.length)
for (const item of data) out[item.index] = item.embedding
return out
}
}

View File

@ -0,0 +1,58 @@
import { MCPTool, RAG_INTERNAL_SERVER } from '@janhq/core'
// Tool names
export const RETRIEVE = 'retrieve'
export const LIST_ATTACHMENTS = 'list_attachments'
export const GET_CHUNKS = 'get_chunks'
export function getRAGTools(retrievalLimit: number): MCPTool[] {
const maxTopK = Math.max(1, Number(retrievalLimit ?? 3))
return [
{
name: LIST_ATTACHMENTS,
description:
'List files attached to the current thread. Thread is inferred automatically; you may optionally provide {"scope":"thread"}. Returns basic file info (name/path).',
inputSchema: {
type: 'object',
properties: {
scope: { type: 'string', enum: ['thread'], description: 'Retrieval scope; currently only thread is supported' },
},
required: ['scope'],
},
server: RAG_INTERNAL_SERVER,
},
{
name: RETRIEVE,
description:
'Retrieve relevant snippets from locally attached, indexed documents. Use query only; do not pass raw document content. Thread context is inferred automatically; you may optionally provide {"scope":"thread"}. Use file_ids to search within specific files only.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'User query to search for' },
top_k: { type: 'number', description: 'Optional: Max citations to return. Adjust as needed.', minimum: 1, maximum: maxTopK, default: retrievalLimit ?? 3 },
scope: { type: 'string', enum: ['thread'], description: 'Retrieval scope; currently only thread is supported' },
file_ids: { type: 'array', items: { type: 'string' }, description: 'Optional: Filter search to specific file IDs from list_attachments' },
},
required: ['query', 'scope'],
},
server: RAG_INTERNAL_SERVER,
},
{
name: GET_CHUNKS,
description:
'Retrieve chunks from a file by their order range. For a single chunk, use start_order = end_order. Thread context is inferred automatically; you may optionally provide {"scope":"thread"}. Use sparingly; intended for advanced usage. Prefer using retrieve instead for relevance-based fetching.',
inputSchema: {
type: 'object',
properties: {
file_id: { type: 'string', description: 'File ID from list_attachments' },
start_order: { type: 'number', description: 'Start of chunk range (inclusive, 0-indexed)' },
end_order: { type: 'number', description: 'End of chunk range (inclusive, 0-indexed). For single chunk, use start_order = end_order.' },
scope: { type: 'string', enum: ['thread'], description: 'Retrieval scope; currently only thread is supported' },
},
required: ['file_id', 'start_order', 'end_order', 'scope'],
},
server: RAG_INTERNAL_SERVER,
},
]
}

View File

@ -0,0 +1,33 @@
{
"name": "@janhq/vector-db-extension",
"productName": "Vector DB",
"version": "0.1.0",
"description": "Vector DB integration using sqlite-vec if available with linear fallback",
"main": "dist/index.js",
"module": "dist/module.js",
"author": "Jan <service@jan.ai>",
"license": "AGPL-3.0",
"scripts": {
"build": "rolldown -c rolldown.config.mjs",
"build:publish": "rimraf *.tgz --glob || true && yarn build && npm pack && cpx *.tgz ../../pre-install"
},
"devDependencies": {
"cpx": "1.5.0",
"rimraf": "6.0.1",
"rolldown": "1.0.0-beta.1",
"typescript": "5.9.2"
},
"dependencies": {
"@janhq/core": "../../core/package.tgz",
"@janhq/tauri-plugin-rag-api": "link:../../src-tauri/plugins/tauri-plugin-rag",
"@janhq/tauri-plugin-vector-db-api": "link:../../src-tauri/plugins/tauri-plugin-vector-db"
},
"files": [
"dist/*",
"package.json"
],
"installConfig": {
"hoistingLimits": "workspaces"
},
"packageManager": "yarn@4.5.3"
}

View File

@ -0,0 +1,11 @@
import { defineConfig } from 'rolldown'
export default defineConfig({
input: 'src/index.ts',
output: {
format: 'esm',
file: 'dist/index.js',
},
platform: 'browser',
define: {},
})

View File

@ -0,0 +1,107 @@
import { VectorDBExtension, type SearchMode, type VectorDBStatus, type VectorChunkInput, type VectorSearchResult, type AttachmentFileInfo, type VectorDBFileInput, type VectorDBIngestOptions, AIEngine } from '@janhq/core'
import * as vecdb from '@janhq/tauri-plugin-vector-db-api'
import * as ragApi from '@janhq/tauri-plugin-rag-api'
export default class VectorDBExt extends VectorDBExtension {
async onLoad(): Promise<void> {
// no-op
}
onUnload(): void {}
async getStatus(): Promise<VectorDBStatus> {
return await vecdb.getStatus() as VectorDBStatus
}
private collectionForThread(threadId: string): string {
return `attachments_${threadId}`
}
async createCollection(threadId: string, dimension: number): Promise<void> {
return await vecdb.createCollection(this.collectionForThread(threadId), dimension)
}
async insertChunks(threadId: string, fileId: string, chunks: VectorChunkInput[]): Promise<void> {
return await vecdb.insertChunks(this.collectionForThread(threadId), fileId, chunks)
}
async searchCollection(
threadId: string,
query_embedding: number[],
limit: number,
threshold: number,
mode?: SearchMode,
fileIds?: string[]
): Promise<VectorSearchResult[]> {
return await vecdb.searchCollection(this.collectionForThread(threadId), query_embedding, limit, threshold, mode, fileIds) as VectorSearchResult[]
}
async deleteChunks(threadId: string, ids: string[]): Promise<void> {
return await vecdb.deleteChunks(this.collectionForThread(threadId), ids)
}
async deleteCollection(threadId: string): Promise<void> {
return await vecdb.deleteCollection(this.collectionForThread(threadId))
}
// Optional helper for chunking
private async chunkText(text: string, chunkSize: number, chunkOverlap: number): Promise<string[]> {
return await vecdb.chunkText(text, chunkSize, chunkOverlap)
}
private async embedTexts(texts: string[]): Promise<number[][]> {
const llm = window.core?.extensionManager.getByName('@janhq/llamacpp-extension') as AIEngine & { embed?: (texts: string[]) => Promise<{ data: Array<{ embedding: number[]; index: number }> }> }
if (!llm?.embed) throw new Error('llamacpp extension not available')
const res = await llm.embed(texts)
const data: Array<{ embedding: number[]; index: number }> = res?.data || []
const out: number[][] = new Array(texts.length)
for (const item of data) out[item.index] = item.embedding
return out
}
async ingestFile(threadId: string, file: VectorDBFileInput, opts: VectorDBIngestOptions): Promise<AttachmentFileInfo> {
// Check for duplicate file (same name + path)
const existingFiles = await vecdb.listAttachments(this.collectionForThread(threadId)).catch(() => [])
const duplicate = existingFiles.find((f: any) => f.name === file.name && f.path === file.path)
if (duplicate) {
throw new Error(`File '${file.name}' has already been attached to this thread`)
}
const text = await ragApi.parseDocument(file.path, file.type || 'application/octet-stream')
const chunks = await this.chunkText(text, opts.chunkSize, opts.chunkOverlap)
if (!chunks.length) {
const fi = await vecdb.createFile(this.collectionForThread(threadId), file)
return fi
}
const embeddings = await this.embedTexts(chunks)
const dimension = embeddings[0]?.length || 0
if (dimension <= 0) throw new Error('Embedding dimension not available')
await this.createCollection(threadId, dimension)
const fi = await vecdb.createFile(this.collectionForThread(threadId), file)
await vecdb.insertChunks(
this.collectionForThread(threadId),
fi.id,
chunks.map((t, i) => ({ text: t, embedding: embeddings[i] }))
)
const infos = await vecdb.listAttachments(this.collectionForThread(threadId))
const updated = infos.find((e) => e.id === fi.id)
return updated || { ...fi, chunk_count: chunks.length }
}
async listAttachments(threadId: string, limit?: number): Promise<AttachmentFileInfo[]> {
return await vecdb.listAttachments(this.collectionForThread(threadId), limit) as AttachmentFileInfo[]
}
async getChunks(
threadId: string,
fileId: string,
startOrder: number,
endOrder: number
): Promise<VectorSearchResult[]> {
return await vecdb.getChunks(this.collectionForThread(threadId), fileId, startOrder, endOrder) as VectorSearchResult[]
}
async deleteFile(threadId: string, fileId: string): Promise<void> {
return await vecdb.deleteFile(this.collectionForThread(threadId), fileId)
}
}

View File

@ -26,17 +26,16 @@
"serve:web-app": "yarn workspace @janhq/web-app serve:web",
"build:serve:web-app": "yarn build:web-app && yarn serve:web-app",
"dev:tauri": "yarn build:icon && yarn copy:assets:tauri && cross-env IS_CLEAN=true tauri dev",
"dev:ios": "yarn build:extensions-web && yarn copy:assets:mobile && RUSTC_WRAPPER= yarn tauri ios dev --features mobile",
"dev:android": "yarn build:extensions-web && yarn copy:assets:mobile && cross-env IS_CLEAN=true TAURI_ANDROID_BUILD=true yarn tauri android dev --features mobile",
"build:android": "yarn build:icon && yarn copy:assets:mobile && cross-env IS_CLEAN=true TAURI_ANDROID_BUILD=true yarn tauri android build -- --no-default-features --features mobile",
"build:ios": "yarn copy:assets:mobile && yarn tauri ios build -- --no-default-features --features mobile",
"build:ios:device": "yarn build:icon && yarn copy:assets:mobile && yarn tauri ios build -- --no-default-features --features mobile --export-method debugging",
"dev:ios": "yarn copy:assets:mobile && RUSTC_WRAPPER= cross-env IS_IOS=true yarn tauri ios dev --features mobile",
"dev:android": "yarn copy:assets:mobile && cross-env IS_ANDROID=true yarn tauri android dev --features mobile",
"build:android": "yarn build:icon && yarn copy:assets:mobile && cross-env IS_CLEAN=true yarn tauri android build -- --no-default-features --features mobile",
"build:ios": "yarn build:icon && yarn copy:assets:mobile && cross-env IS_IOS=true yarn tauri ios build -- --no-default-features --features mobile",
"build:ios:device": "yarn build:icon && yarn copy:assets:mobile && cross-env IS_IOS=true yarn tauri ios build -- --no-default-features --features mobile --export-method debugging",
"copy:assets:tauri": "cpx \"pre-install/*.tgz\" \"src-tauri/resources/pre-install/\" && cpx \"LICENSE\" \"src-tauri/resources/\"",
"copy:assets:mobile": "cpx \"pre-install/*.tgz\" \"src-tauri/resources/pre-install/\" && cpx \"LICENSE\" \"src-tauri/resources/\"",
"download:lib": "node ./scripts/download-lib.mjs",
"download:bin": "node ./scripts/download-bin.mjs",
"download:windows-installer": "node ./scripts/download-win-installer-deps.mjs",
"build:tauri:win32": "yarn download:bin && yarn download:windows-installer && yarn tauri build",
"build:tauri:win32": "yarn download:bin && yarn tauri build",
"build:tauri:linux": "yarn download:bin && NO_STRIP=1 ./src-tauri/build-utils/shim-linuxdeploy.sh yarn tauri build && ./src-tauri/build-utils/buildAppImage.sh",
"build:tauri:darwin": "yarn download:bin && yarn tauri build --target universal-apple-darwin",
"build:tauri": "yarn build:icon && yarn copy:assets:tauri && run-script-os",

View File

@ -56,6 +56,75 @@ async function decompress(filePath, targetDir) {
}
}
async function getJson(url, headers = {}) {
return new Promise((resolve, reject) => {
const opts = new URL(url)
opts.headers = {
'User-Agent': 'jan-app',
'Accept': 'application/vnd.github+json',
...headers,
}
https
.get(opts, (res) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return getJson(res.headers.location, headers).then(resolve, reject)
}
if (res.statusCode !== 200) {
reject(new Error(`GET ${url} failed with status ${res.statusCode}`))
return
}
let data = ''
res.on('data', (chunk) => (data += chunk))
res.on('end', () => {
try {
resolve(JSON.parse(data))
} catch (e) {
reject(e)
}
})
})
.on('error', reject)
})
}
function matchSqliteVecAsset(assets, platform, arch) {
const osHints =
platform === 'darwin'
? ['darwin', 'macos', 'apple-darwin']
: platform === 'win32'
? ['windows', 'win', 'msvc']
: ['linux']
const archHints = arch === 'arm64' ? ['arm64', 'aarch64'] : ['x86_64', 'x64', 'amd64']
const extHints = ['zip', 'tar.gz']
const lc = (s) => s.toLowerCase()
const candidates = assets
.filter((a) => a && a.browser_download_url && a.name)
.map((a) => ({ name: lc(a.name), url: a.browser_download_url }))
// Prefer exact OS + arch matches
let matches = candidates.filter((c) => osHints.some((o) => c.name.includes(o)) && archHints.some((h) => c.name.includes(h)) && extHints.some((e) => c.name.endsWith(e)))
if (matches.length) return matches[0].url
// Fallback: OS only
matches = candidates.filter((c) => osHints.some((o) => c.name.includes(o)) && extHints.some((e) => c.name.endsWith(e)))
if (matches.length) return matches[0].url
// Last resort: any asset with shared library extension inside is unknown here, so pick any zip/tar.gz
matches = candidates.filter((c) => extHints.some((e) => c.name.endsWith(e)))
return matches.length ? matches[0].url : null
}
async function fetchLatestSqliteVecUrl(platform, arch) {
try {
const rel = await getJson('https://api.github.com/repos/asg017/sqlite-vec/releases/latest')
const url = matchSqliteVecAsset(rel.assets || [], platform, arch)
return url
} catch (e) {
console.log('Failed to query sqlite-vec latest release:', e.message)
return null
}
}
function getPlatformArch() {
const platform = os.platform() // 'darwin', 'linux', 'win32'
const arch = os.arch() // 'x64', 'arm64', etc.
@ -266,6 +335,64 @@ async function main() {
}
console.log('UV downloaded.')
// ----- sqlite-vec (optional, ANN acceleration) -----
try {
const binDir = 'src-tauri/resources/bin'
const platform = os.platform()
const ext = platform === 'darwin' ? 'dylib' : platform === 'win32' ? 'dll' : 'so'
const targetLibPath = path.join(binDir, `sqlite-vec.${ext}`)
if (fs.existsSync(targetLibPath)) {
console.log(`sqlite-vec already present at ${targetLibPath}`)
} else {
let sqlvecUrl = await fetchLatestSqliteVecUrl(platform, os.arch())
// Allow override via env if needed
if ((process.env.SQLVEC_URL || process.env.JAN_SQLITE_VEC_URL) && !sqlvecUrl) {
sqlvecUrl = process.env.SQLVEC_URL || process.env.JAN_SQLITE_VEC_URL
}
if (!sqlvecUrl) {
console.log('Could not determine sqlite-vec download URL; skipping (linear fallback will be used).')
} else {
console.log(`Downloading sqlite-vec from ${sqlvecUrl}...`)
const sqlvecArchive = path.join(tempBinDir, `sqlite-vec-download`)
const guessedExt = sqlvecUrl.endsWith('.zip') ? '.zip' : sqlvecUrl.endsWith('.tar.gz') ? '.tar.gz' : ''
const archivePath = sqlvecArchive + guessedExt
await download(sqlvecUrl, archivePath)
if (!guessedExt) {
console.log('Unknown archive type for sqlite-vec; expecting .zip or .tar.gz')
} else {
await decompress(archivePath, tempBinDir)
// Try to find a shared library in the extracted files
const candidates = []
function walk(dir) {
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry)
const stat = fs.statSync(full)
if (stat.isDirectory()) walk(full)
else if (full.endsWith(`.${ext}`)) candidates.push(full)
}
}
walk(tempBinDir)
if (candidates.length === 0) {
console.log('No sqlite-vec shared library found in archive; skipping copy.')
} else {
// Pick the first match and copy/rename to sqlite-vec.<ext>
const libSrc = candidates[0]
// Ensure we copy the FILE, not a directory (fs-extra copySync can copy dirs)
if (fs.statSync(libSrc).isFile()) {
fs.copyFileSync(libSrc, targetLibPath)
console.log(`sqlite-vec installed at ${targetLibPath}`)
} else {
console.log(`Found non-file at ${libSrc}; skipping.`)
}
}
}
}
}
} catch (err) {
console.log('sqlite-vec download step failed (non-fatal):', err)
}
console.log('Downloads completed.')
}

View File

@ -1,83 +0,0 @@
console.log('Downloading Windows installer dependencies...')
// scripts/download-win-installer-deps.mjs
import https from 'https'
import fs, { mkdirSync } from 'fs'
import os from 'os'
import path from 'path'
import { copySync } from 'cpx'
function download(url, dest) {
return new Promise((resolve, reject) => {
console.log(`Downloading ${url} to ${dest}`)
const file = fs.createWriteStream(dest)
https
.get(url, (response) => {
console.log(`Response status code: ${response.statusCode}`)
if (
response.statusCode >= 300 &&
response.statusCode < 400 &&
response.headers.location
) {
// Handle redirect
const redirectURL = response.headers.location
console.log(`Redirecting to ${redirectURL}`)
download(redirectURL, dest).then(resolve, reject) // Recursive call
return
} else if (response.statusCode !== 200) {
reject(`Failed to get '${url}' (${response.statusCode})`)
return
}
response.pipe(file)
file.on('finish', () => {
file.close(resolve)
})
})
.on('error', (err) => {
fs.unlink(dest, () => reject(err.message))
})
})
}
async function main() {
console.log('Starting Windows installer dependencies download')
const platform = os.platform() // 'darwin', 'linux', 'win32'
const arch = os.arch() // 'x64', 'arm64', etc.
if (arch != 'x64') return
const libDir = 'src-tauri/resources/lib'
const tempDir = 'scripts/dist'
try {
mkdirSync('scripts/dist')
} catch (err) {
// Expect EEXIST error if the directory already exists
}
// Download VC++ Redistributable 17
if (platform == 'win32') {
const vcFilename = 'vc_redist.x64.exe'
const vcUrl = 'https://aka.ms/vs/17/release/vc_redist.x64.exe'
console.log(`Downloading VC++ Redistributable...`)
const vcSavePath = path.join(tempDir, vcFilename)
if (!fs.existsSync(vcSavePath)) {
await download(vcUrl, vcSavePath)
}
// copy to tauri resources
try {
copySync(vcSavePath, libDir)
} catch (err) {
// Expect EEXIST error
}
}
console.log('Windows installer dependencies downloads completed.')
}
main().catch((err) => {
console.error('Error:', err)
process.exit(1)
})

2364
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,7 @@ mobile = [
"tauri/protocol-asset",
"tauri/test",
"tauri/wry",
"dep:sqlx",
]
test-tauri = [
"tauri/wry",
@ -59,11 +60,12 @@ hyper = { version = "0.14", features = ["server"] }
jan-utils = { path = "./utils" }
libloading = "0.8.7"
log = "0.4"
reqwest = { version = "0.11", features = ["json", "blocking", "stream", "native-tls-vendored"] }
rmcp = { version = "0.6.0", features = [
"client",
"transport-sse-client",
"transport-sse-client-reqwest",
"transport-streamable-http-client",
"transport-streamable-http-client-reqwest",
"transport-child-process",
"tower",
"reqwest",
@ -77,12 +79,15 @@ tauri-plugin-dialog = "2.2.1"
tauri-plugin-deep-link = { version = "2", optional = true }
tauri-plugin-hardware = { path = "./plugins/tauri-plugin-hardware", optional = true }
tauri-plugin-llamacpp = { path = "./plugins/tauri-plugin-llamacpp" }
tauri-plugin-vector-db = { path = "./plugins/tauri-plugin-vector-db" }
tauri-plugin-rag = { path = "./plugins/tauri-plugin-rag" }
tauri-plugin-http = { version = "2", features = ["unsafe-headers"] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-opener = "2.2.7"
tauri-plugin-os = "2.2.1"
tauri-plugin-shell = "2.2.0"
tauri-plugin-store = "2"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = true }
thiserror = "2.0.12"
tokio = { version = "1", features = ["full"] }
tokio-util = "0.7.14"
@ -105,11 +110,13 @@ libc = "0.2.172"
windows-sys = { version = "0.60.2", features = ["Win32_Storage_FileSystem"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
reqwest = { version = "0.11", features = ["json", "blocking", "stream", "native-tls-vendored"] }
tauri-plugin-updater = "2"
once_cell = "1.18"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
reqwest = { version = "0.11", features = ["json", "blocking", "stream", "rustls-tls"], default-features = false }
tauri-plugin-dialog = { version = "2.2.1", default-features = false }
tauri-plugin-http = { version = "2", default-features = false }
tauri-plugin-log = { version = "2.0.0-rc", default-features = false }

View File

@ -22,6 +22,8 @@
"core:webview:allow-create-webview-window",
"opener:allow-open-url",
"store:default",
"vector-db:default",
"rag:default",
{
"identifier": "http:default",
"allow": [

View File

@ -12,6 +12,8 @@
"core:webview:allow-set-webview-zoom",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-get-all-windows",
"core:event:allow-listen",
"shell:allow-spawn",
"shell:allow-open",
"core:app:allow-set-app-theme",
@ -23,6 +25,8 @@
"core:webview:allow-create-webview-window",
"opener:allow-open-url",
"store:default",
"vector-db:default",
"rag:default",
"llamacpp:default",
"deep-link:default",
"hardware:default",

View File

@ -1,14 +1,18 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "logs-app-window",
"identifier": "log-app-window",
"description": "enables permissions for the logs app window",
"windows": ["logs-app-window"],
"platforms": ["linux", "macOS", "windows"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-get-all-windows",
"core:event:allow-listen",
"log:default",
"core:webview:allow-create-webview-window",
"core:webview:allow-get-all-webviews",
"core:window:allow-set-focus"
]
}

View File

@ -3,12 +3,16 @@
"identifier": "logs-window",
"description": "enables permissions for the logs window",
"windows": ["logs-window-local-api-server"],
"platforms": ["linux", "macOS", "windows"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-get-all-windows",
"core:event:allow-listen",
"log:default",
"core:webview:allow-create-webview-window",
"core:webview:allow-get-all-webviews",
"core:window:allow-set-focus"
]
}

View File

@ -8,13 +8,28 @@
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-get-all-windows",
"core:event:allow-listen",
"log:default",
"core:webview:allow-create-webview-window",
"core:webview:allow-get-all-webviews",
"core:window:allow-set-focus",
"hardware:allow-get-system-info",
"hardware:allow-get-system-usage",
"llamacpp:allow-get-devices",
"llamacpp:allow-read-gguf-metadata",
"deep-link:allow-get-current"
"deep-link:allow-get-current",
{
"identifier": "http:default",
"allow": [
{
"url": "https://*:*"
},
{
"url": "http://*:*"
}
],
"deny": []
}
]
}

View File

@ -23,9 +23,14 @@ sysinfo = "0.34.2"
tauri = { version = "2.5.0", default-features = false, features = [] }
thiserror = "2.0.12"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json", "blocking", "stream"] }
tauri-plugin-hardware = { path = "../tauri-plugin-hardware" }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
reqwest = { version = "0.11", features = ["json", "blocking", "stream", "native-tls"] }
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
reqwest = { version = "0.11", features = ["json", "blocking", "stream", "rustls-tls"], default-features = false }
# Unix-specific dependencies
[target.'cfg(unix)'.dependencies]
nix = { version = "=0.30.1", features = ["signal", "process"] }

View File

@ -30,12 +30,14 @@ export async function cleanupLlamaProcesses(): Promise<void> {
export async function loadLlamaModel(
backendPath: string,
libraryPath?: string,
args: string[] = []
args: string[] = [],
isEmbedding: boolean = false
): Promise<SessionInfo> {
return await invoke('plugin:llamacpp|load_llama_model', {
backendPath,
libraryPath,
args,
isEmbedding,
})
}

View File

@ -44,6 +44,7 @@ pub async fn load_llama_model<R: Runtime>(
library_path: Option<&str>,
mut args: Vec<String>,
envs: HashMap<String, String>,
is_embedding: bool,
) -> ServerResult<SessionInfo> {
let state: State<LlamacppState> = app_handle.state();
let mut process_map = state.llama_server_process.lock().await;
@ -223,6 +224,7 @@ pub async fn load_llama_model<R: Runtime>(
port: port,
model_id: model_id,
model_path: model_path_pb.display().to_string(),
is_embedding: is_embedding,
api_key: api_key,
mmproj_path: mmproj_path_string,
};

View File

@ -87,19 +87,25 @@ pub async fn is_model_supported(
);
const RESERVE_BYTES: u64 = 2288490189;
let total_system_memory = system_info.total_memory * 1024 * 1024;
let total_system_memory: u64 = match system_info.gpus.is_empty() {
// on MacOS with unified memory, treat RAM = 0 for now
true => 0,
false => system_info.total_memory * 1024 * 1024,
};
// Calculate total VRAM from all GPUs
let total_vram: u64 = if system_info.gpus.is_empty() {
let total_vram: u64 = match system_info.gpus.is_empty() {
// On macOS with unified memory, GPU info may be empty
// Use total RAM as VRAM since memory is shared
true => {
log::info!("No GPUs detected (likely unified memory system), using total RAM as VRAM");
total_system_memory
} else {
system_info
system_info.total_memory * 1024 * 1024
}
false => system_info
.gpus
.iter()
.map(|g| g.total_memory * 1024 * 1024)
.sum::<u64>()
.sum::<u64>(),
};
log::info!("Total VRAM reported/calculated (in bytes): {}", &total_vram);
@ -113,7 +119,7 @@ pub async fn is_model_supported(
let usable_total_memory = if total_system_memory > RESERVE_BYTES {
(total_system_memory - RESERVE_BYTES) + usable_vram
} else {
0
usable_vram
};
log::info!("System RAM: {} bytes", &total_system_memory);
log::info!("Total VRAM: {} bytes", &total_vram);

View File

@ -80,25 +80,25 @@ pub async fn plan_model_load(
log::info!("Got GPUs:\n{:?}", &sys_info.gpus);
let total_ram: u64 = sys_info.total_memory * 1024 * 1024;
log::info!(
"Total system memory reported from tauri_plugin_hardware(in bytes): {}",
&total_ram
);
let total_ram: u64 = match sys_info.gpus.is_empty() {
// Consider RAM as 0 for unified memory
true => 0,
false => sys_info.total_memory * 1024 * 1024,
};
let total_vram: u64 = if sys_info.gpus.is_empty() {
// On macOS with unified memory, GPU info may be empty
// Use total RAM as VRAM since memory is shared
// Calculate total VRAM from all GPUs
let total_vram: u64 = match sys_info.gpus.is_empty() {
true => {
log::info!("No GPUs detected (likely unified memory system), using total RAM as VRAM");
total_ram
} else {
sys_info
sys_info.total_memory * 1024 * 1024
}
false => sys_info
.gpus
.iter()
.map(|g| g.total_memory * 1024 * 1024)
.sum::<u64>()
.sum::<u64>(),
};
log::info!("Total RAM reported/calculated (in bytes): {}", &total_ram);
log::info!("Total VRAM reported/calculated (in bytes): {}", &total_vram);
let usable_vram: u64 = if total_vram > RESERVE_BYTES {
(((total_vram - RESERVE_BYTES) as f64) * multiplier) as u64

View File

@ -10,6 +10,7 @@ pub struct SessionInfo {
pub port: i32, // llama-server output port
pub model_id: String,
pub model_path: String, // path of the loaded model
pub is_embedding: bool,
pub api_key: String,
#[serde(default)]
pub mmproj_path: Option<String>,

View File

@ -0,0 +1,17 @@
/.vs
.DS_Store
.Thumbs.db
*.sublime*
.idea/
debug.log
package-lock.json
.vscode/settings.json
yarn.lock
/.tauri
/target
Cargo.lock
node_modules/
dist-js
dist

View File

@ -0,0 +1,31 @@
[package]
name = "tauri-plugin-rag"
version = "0.1.0"
authors = ["Jan <service@jan.ai>"]
description = "Tauri plugin for RAG utilities (document parsing, types)"
license = "MIT"
repository = "https://github.com/menloresearch/jan"
edition = "2021"
rust-version = "1.77.2"
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-rag"
[dependencies]
tauri = { version = "2.8.5", default-features = false }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
tokio = { version = "1", features = ["full"] }
log = "0.4"
pdf-extract = "0.7"
zip = "0.6"
quick-xml = { version = "0.31", features = ["serialize"] }
csv = "1.3"
calamine = "0.23"
html2text = "0.11"
chardetng = "0.1"
encoding_rs = "0.8"
infer = "0.15"
[build-dependencies]
tauri-plugin = { version = "2.3.1", features = ["build"] }

View File

@ -0,0 +1,7 @@
fn main() {
tauri_plugin::Builder::new(&[
"parse_document",
])
.build();
}

View File

@ -0,0 +1,6 @@
import { invoke } from '@tauri-apps/api/core'
export async function parseDocument(filePath: string, fileType: string): Promise<string> {
// Send both snake_case and camelCase for compatibility across runtimes/builds
return await invoke('plugin:rag|parse_document', { filePath, fileType })
}

View File

@ -0,0 +1,33 @@
{
"name": "@janhq/tauri-plugin-rag-api",
"version": "0.1.0",
"private": true,
"description": "Guest JS API for Jan RAG plugin",
"type": "module",
"types": "./dist-js/index.d.ts",
"main": "./dist-js/index.cjs",
"module": "./dist-js/index.js",
"exports": {
"types": "./dist-js/index.d.ts",
"import": "./dist-js/index.js",
"require": "./dist-js/index.cjs"
},
"files": [
"dist-js",
"README.md"
],
"scripts": {
"build": "rollup -c",
"prepublishOnly": "yarn build",
"pretest": "yarn build"
},
"dependencies": {
"@tauri-apps/api": ">=2.0.0-beta.6"
},
"devDependencies": {
"@rollup/plugin-typescript": "^12.0.0",
"rollup": "^4.9.6",
"tslib": "^2.6.2",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-parse-document"
description = "Enables the parse_document command without any pre-configured scope."
commands.allow = ["parse_document"]
[[permission]]
identifier = "deny-parse-document"
description = "Denies the parse_document command without any pre-configured scope."
commands.deny = ["parse_document"]

View File

@ -0,0 +1,43 @@
## Default Permission
Default permissions for the rag plugin
#### This default permission set includes the following:
- `allow-parse-document`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`rag:allow-parse-document`
</td>
<td>
Enables the parse_document command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`rag:deny-parse-document`
</td>
<td>
Denies the parse_document command without any pre-configured scope.
</td>
</tr>
</table>

View File

@ -0,0 +1,6 @@
[default]
description = "Default permissions for the rag plugin"
permissions = [
"allow-parse-document",
]

View File

@ -0,0 +1,318 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the parse_document command without any pre-configured scope.",
"type": "string",
"const": "allow-parse-document",
"markdownDescription": "Enables the parse_document command without any pre-configured scope."
},
{
"description": "Denies the parse_document command without any pre-configured scope.",
"type": "string",
"const": "deny-parse-document",
"markdownDescription": "Denies the parse_document command without any pre-configured scope."
},
{
"description": "Default permissions for the rag plugin\n#### This default permission set includes:\n\n- `allow-parse-document`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the rag plugin\n#### This default permission set includes:\n\n- `allow-parse-document`"
}
]
}
}
}

View File

@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { cwd } from 'node:process'
import typescript from '@rollup/plugin-typescript'
const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8'))
export default {
input: 'guest-js/index.ts',
output: [
{
file: pkg.exports.import,
format: 'esm'
},
{
file: pkg.exports.require,
format: 'cjs'
}
],
plugins: [
typescript({
declaration: true,
declarationDir: dirname(pkg.exports.import)
})
],
external: [
/^@tauri-apps\/api/,
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {})
]
}

View File

@ -0,0 +1,12 @@
use crate::{RagError, parser};
#[tauri::command]
pub async fn parse_document<R: tauri::Runtime>(
_app: tauri::AppHandle<R>,
file_path: String,
file_type: String,
) -> Result<String, RagError> {
log::info!("Parsing document: {} (type: {})", file_path, file_type);
let res = parser::parse_document(&file_path, &file_type);
res
}

View File

@ -0,0 +1,20 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
pub enum RagError {
#[error("Failed to parse document: {0}")]
ParseError(String),
#[error("Unsupported file type: {0}")]
UnsupportedFileType(String),
#[error("IO error: {0}")]
IoError(String),
}
impl From<std::io::Error> for RagError {
fn from(err: std::io::Error) -> Self {
RagError::IoError(err.to_string())
}
}

View File

@ -0,0 +1,20 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
mod parser;
mod error;
mod commands;
pub use error::RagError;
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("rag")
.invoke_handler(tauri::generate_handler![
commands::parse_document,
])
.setup(|_app, _api| Ok(()))
.build()
}

View File

@ -0,0 +1,274 @@
use crate::RagError;
use std::fs;
use std::io::{Read, Cursor};
use zip::read::ZipArchive;
use quick_xml::events::Event;
use quick_xml::Reader;
use csv as csv_crate;
use calamine::{Reader as _, open_workbook_auto, DataType};
use html2text;
use chardetng::EncodingDetector;
use infer;
use std::borrow::Cow;
pub fn parse_pdf(file_path: &str) -> Result<String, RagError> {
let bytes = fs::read(file_path)?;
let text = pdf_extract::extract_text_from_mem(&bytes)
.map_err(|e| RagError::ParseError(format!("PDF parse error: {}", e)))?;
// Validate that the PDF has extractable text (not image-based/scanned)
// Count meaningful characters (excluding whitespace)
let meaningful_chars = text.chars()
.filter(|c| !c.is_whitespace())
.count();
// Require at least 50 non-whitespace characters to consider it a text PDF
// This threshold filters out PDFs that are purely images or scanned documents
if meaningful_chars < 50 {
return Err(RagError::ParseError(
"PDF appears to be image-based or scanned. OCR is not supported yet. Please use a text-based PDF.".to_string()
));
}
Ok(text)
}
pub fn parse_text(file_path: &str) -> Result<String, RagError> {
read_text_auto(file_path)
}
pub fn parse_document(file_path: &str, file_type: &str) -> Result<String, RagError> {
match file_type.to_lowercase().as_str() {
"pdf" | "application/pdf" => parse_pdf(file_path),
"txt" | "text/plain" | "md" | "text/markdown" => parse_text(file_path),
"csv" | "text/csv" => parse_csv(file_path),
// Excel family via calamine
"xlsx"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
| "xls"
| "application/vnd.ms-excel"
| "ods"
| "application/vnd.oasis.opendocument.spreadsheet" => parse_spreadsheet(file_path),
// PowerPoint
"pptx"
| "application/vnd.openxmlformats-officedocument.presentationml.presentation" => parse_pptx(file_path),
// HTML
"html" | "htm" | "text/html" => parse_html(file_path),
"docx"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {
parse_docx(file_path)
}
other => {
// Try MIME sniffing when extension or MIME is unknown
if let Ok(Some(k)) = infer::get_from_path(file_path) {
let mime = k.mime_type();
return parse_document(file_path, mime);
}
Err(RagError::UnsupportedFileType(other.to_string()))
}
}
}
fn parse_docx(file_path: &str) -> Result<String, RagError> {
let file = std::fs::File::open(file_path)?;
let mut zip = ZipArchive::new(file).map_err(|e| RagError::ParseError(e.to_string()))?;
// Standard DOCX stores document text at word/document.xml
let mut doc_xml = match zip.by_name("word/document.xml") {
Ok(f) => f,
Err(_) => return Err(RagError::ParseError("document.xml not found".into())),
};
let mut xml_content = String::new();
doc_xml
.read_to_string(&mut xml_content)
.map_err(|e| RagError::ParseError(e.to_string()))?;
// Parse XML and extract text from w:t nodes; add newlines on w:p boundaries
let mut reader = Reader::from_str(&xml_content);
reader.trim_text(true);
let mut buf = Vec::new();
let mut result = String::new();
let mut in_text = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let name: String = reader
.decoder()
.decode(e.name().as_ref())
.unwrap_or(Cow::Borrowed(""))
.into_owned();
if name.ends_with(":t") || name == "w:t" || name == "t" {
in_text = true;
}
}
Ok(Event::End(e)) => {
let name: String = reader
.decoder()
.decode(e.name().as_ref())
.unwrap_or(Cow::Borrowed(""))
.into_owned();
if name.ends_with(":t") || name == "w:t" || name == "t" {
in_text = false;
result.push(' ');
}
if name.ends_with(":p") || name == "w:p" || name == "p" {
// Paragraph end add newline
result.push_str("\n\n");
}
}
Ok(Event::Text(t)) => {
if in_text {
let text = t.unescape().unwrap_or_default();
result.push_str(&text);
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(RagError::ParseError(e.to_string())),
_ => {}
}
}
// Normalize whitespace
let normalized = result
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("\n");
Ok(normalized)
}
fn parse_csv(file_path: &str) -> Result<String, RagError> {
let mut rdr = csv_crate::ReaderBuilder::new()
.has_headers(false)
.flexible(true)
.from_path(file_path)
.map_err(|e| RagError::ParseError(e.to_string()))?;
let mut out = String::new();
for rec in rdr.records() {
let rec = rec.map_err(|e| RagError::ParseError(e.to_string()))?;
out.push_str(&rec.iter().collect::<Vec<_>>().join(", "));
out.push('\n');
}
Ok(out)
}
fn parse_spreadsheet(file_path: &str) -> Result<String, RagError> {
let mut workbook = open_workbook_auto(file_path)
.map_err(|e| RagError::ParseError(e.to_string()))?;
let mut out = String::new();
for sheet_name in workbook.sheet_names().to_owned() {
if let Ok(range) = workbook.worksheet_range(&sheet_name) {
out.push_str(&format!("# Sheet: {}\n", sheet_name));
for row in range.rows() {
let cells = row
.iter()
.map(|c| match c {
DataType::Empty => "".to_string(),
DataType::String(s) => s.to_string(),
DataType::Float(f) => format!("{}", f),
DataType::Int(i) => i.to_string(),
DataType::Bool(b) => b.to_string(),
DataType::DateTime(f) => format!("{}", f),
other => other.to_string(),
})
.collect::<Vec<_>>()
.join("\t");
out.push_str(&cells);
out.push('\n');
}
out.push_str("\n");
}
}
Ok(out)
}
fn parse_pptx(file_path: &str) -> Result<String, RagError> {
let file = std::fs::File::open(file_path)?;
let mut zip = ZipArchive::new(file).map_err(|e| RagError::ParseError(e.to_string()))?;
// Collect slide files: ppt/slides/slide*.xml
let mut slides = Vec::new();
for i in 0..zip.len() {
let name = zip.by_index(i).map(|f| f.name().to_string()).unwrap_or_default();
if name.starts_with("ppt/slides/") && name.ends_with(".xml") {
slides.push(name);
}
}
slides.sort();
let mut output = String::new();
for slide_name in slides {
let mut file = zip.by_name(&slide_name).map_err(|e| RagError::ParseError(e.to_string()))?;
let mut xml = String::new();
file.read_to_string(&mut xml).map_err(|e| RagError::ParseError(e.to_string()))?;
output.push_str(&extract_pptx_text(&xml));
output.push_str("\n\n");
}
Ok(output)
}
fn extract_pptx_text(xml: &str) -> String {
let mut reader = Reader::from_str(xml);
reader.trim_text(true);
let mut buf = Vec::new();
let mut result = String::new();
let mut in_text = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let name: String = reader
.decoder()
.decode(e.name().as_ref())
.unwrap_or(Cow::Borrowed(""))
.into_owned();
if name.ends_with(":t") || name == "a:t" || name == "t" {
in_text = true;
}
}
Ok(Event::End(e)) => {
let name: String = reader
.decoder()
.decode(e.name().as_ref())
.unwrap_or(Cow::Borrowed(""))
.into_owned();
if name.ends_with(":t") || name == "a:t" || name == "t" {
in_text = false;
result.push(' ');
}
}
Ok(Event::Text(t)) => {
if in_text {
let text = t.unescape().unwrap_or_default();
result.push_str(&text);
}
}
Ok(Event::Eof) => break,
Err(_) => break,
_ => {}
}
}
result
}
fn parse_html(file_path: &str) -> Result<String, RagError> {
let html = read_text_auto(file_path)?;
// 80-column wrap default
Ok(html2text::from_read(Cursor::new(html), 80))
}
fn read_text_auto(file_path: &str) -> Result<String, RagError> {
let bytes = fs::read(file_path)?;
// Detect encoding
let mut detector = EncodingDetector::new();
detector.feed(&bytes, true);
let enc = detector.guess(None, true);
let (decoded, _, had_errors) = enc.decode(&bytes);
if had_errors {
// fallback to UTF-8 lossy
Ok(String::from_utf8_lossy(&bytes).to_string())
} else {
Ok(decoded.to_string())
}
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2021",
"module": "esnext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noImplicitAny": true,
"noEmit": true
},
"include": ["guest-js/*.ts"],
"exclude": ["dist-js", "node_modules"]
}

View File

@ -0,0 +1,17 @@
/.vs
.DS_Store
.Thumbs.db
*.sublime*
.idea/
debug.log
package-lock.json
.vscode/settings.json
yarn.lock
/.tauri
/target
Cargo.lock
node_modules/
dist-js
dist

View File

@ -0,0 +1,25 @@
[package]
name = "tauri-plugin-vector-db"
version = "0.1.0"
authors = ["Jan <service@jan.ai>"]
description = "Tauri plugin for vector storage and similarity search"
license = "MIT"
repository = "https://github.com/menloresearch/jan"
edition = "2021"
rust-version = "1.77.2"
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-vector-db"
[dependencies]
tauri = { version = "2.8.5", default-features = false }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
tokio = { version = "1", features = ["full"] }
log = "0.4"
rusqlite = { version = "0.32", features = ["bundled", "load_extension"] }
uuid = { version = "1.7", features = ["v4", "serde"] }
dirs = "6.0.0"
[build-dependencies]
tauri-plugin = { version = "2.3.1", features = ["build"] }

View File

@ -0,0 +1,16 @@
fn main() {
tauri_plugin::Builder::new(&[
"create_collection",
"create_file",
"insert_chunks",
"search_collection",
"delete_chunks",
"delete_file",
"delete_collection",
"chunk_text",
"get_status",
"list_attachments",
"get_chunks",
])
.build();
}

View File

@ -0,0 +1,114 @@
import { invoke } from '@tauri-apps/api/core'
export type SearchMode = 'auto' | 'ann' | 'linear'
export interface SearchResult {
id: string
text: string
score?: number
file_id: string
chunk_file_order: number
}
export interface Status {
ann_available: boolean
}
export interface AttachmentFileInfo {
id: string
name?: string
path?: string
type?: string
size?: number
chunk_count: number
}
// Events
// Events are not exported in guest-js to keep API minimal
export async function getStatus(): Promise<Status> {
return await invoke('plugin:vector-db|get_status')
}
export async function createCollection(name: string, dimension: number): Promise<void> {
// Use camelCase param name `dimension` to match Tauri v2 argument keys
return await invoke('plugin:vector-db|create_collection', { name, dimension })
}
export async function createFile(
collection: string,
file: { path: string; name?: string; type?: string; size?: number }
): Promise<AttachmentFileInfo> {
return await invoke('plugin:vector-db|create_file', { collection, file })
}
export async function insertChunks(
collection: string,
fileId: string,
chunks: Array<{ text: string; embedding: number[] }>
): Promise<void> {
return await invoke('plugin:vector-db|insert_chunks', { collection, fileId, chunks })
}
export async function deleteFile(
collection: string,
fileId: string
): Promise<void> {
return await invoke('plugin:vector-db|delete_file', { collection, fileId })
}
export async function searchCollection(
collection: string,
queryEmbedding: number[],
limit: number,
threshold: number,
mode?: SearchMode,
fileIds?: string[]
): Promise<SearchResult[]> {
return await invoke('plugin:vector-db|search_collection', {
collection,
queryEmbedding,
limit,
threshold,
mode,
fileIds,
})
}
export async function deleteChunks(collection: string, ids: string[]): Promise<void> {
return await invoke('plugin:vector-db|delete_chunks', { collection, ids })
}
export async function deleteCollection(collection: string): Promise<void> {
return await invoke('plugin:vector-db|delete_collection', { collection })
}
export async function chunkText(
text: string,
chunkSize: number,
chunkOverlap: number
): Promise<string[]> {
// Use snake_case to match Rust command parameter names
return await invoke('plugin:vector-db|chunk_text', { text, chunkSize, chunkOverlap })
}
export async function listAttachments(
collection: string,
limit?: number
): Promise<AttachmentFileInfo[]> {
return await invoke('plugin:vector-db|list_attachments', { collection, limit })
}
export async function getChunks(
collection: string,
fileId: string,
startOrder: number,
endOrder: number
): Promise<SearchResult[]> {
return await invoke('plugin:vector-db|get_chunks', {
collection,
fileId,
startOrder,
endOrder,
})
}

View File

@ -0,0 +1,33 @@
{
"name": "@janhq/tauri-plugin-vector-db-api",
"version": "0.1.0",
"private": true,
"description": "Guest JS API for Jan vector DB plugin",
"type": "module",
"types": "./dist-js/index.d.ts",
"main": "./dist-js/index.cjs",
"module": "./dist-js/index.js",
"exports": {
"types": "./dist-js/index.d.ts",
"import": "./dist-js/index.js",
"require": "./dist-js/index.cjs"
},
"files": [
"dist-js",
"README.md"
],
"scripts": {
"build": "rollup -c",
"prepublishOnly": "yarn build",
"pretest": "yarn build"
},
"dependencies": {
"@tauri-apps/api": ">=2.0.0-beta.6"
},
"devDependencies": {
"@rollup/plugin-typescript": "^12.0.0",
"rollup": "^4.9.6",
"tslib": "^2.6.2",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-chunk-text"
description = "Enables the chunk_text command without any pre-configured scope."
commands.allow = ["chunk_text"]
[[permission]]
identifier = "deny-chunk-text"
description = "Denies the chunk_text command without any pre-configured scope."
commands.deny = ["chunk_text"]

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-create-collection"
description = "Enables the create_collection command without any pre-configured scope."
commands.allow = ["create_collection"]
[[permission]]
identifier = "deny-create-collection"
description = "Denies the create_collection command without any pre-configured scope."
commands.deny = ["create_collection"]

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-create-file"
description = "Enables the create_file command without any pre-configured scope."
commands.allow = ["create_file"]
[[permission]]
identifier = "deny-create-file"
description = "Denies the create_file command without any pre-configured scope."
commands.deny = ["create_file"]

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-delete-chunks"
description = "Enables the delete_chunks command without any pre-configured scope."
commands.allow = ["delete_chunks"]
[[permission]]
identifier = "deny-delete-chunks"
description = "Denies the delete_chunks command without any pre-configured scope."
commands.deny = ["delete_chunks"]

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-delete-collection"
description = "Enables the delete_collection command without any pre-configured scope."
commands.allow = ["delete_collection"]
[[permission]]
identifier = "deny-delete-collection"
description = "Denies the delete_collection command without any pre-configured scope."
commands.deny = ["delete_collection"]

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-delete-file"
description = "Enables the delete_file command without any pre-configured scope."
commands.allow = ["delete_file"]
[[permission]]
identifier = "deny-delete-file"
description = "Denies the delete_file command without any pre-configured scope."
commands.deny = ["delete_file"]

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-chunks"
description = "Enables the get_chunks command without any pre-configured scope."
commands.allow = ["get_chunks"]
[[permission]]
identifier = "deny-get-chunks"
description = "Denies the get_chunks command without any pre-configured scope."
commands.deny = ["get_chunks"]

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-status"
description = "Enables the get_status command without any pre-configured scope."
commands.allow = ["get_status"]
[[permission]]
identifier = "deny-get-status"
description = "Denies the get_status command without any pre-configured scope."
commands.deny = ["get_status"]

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-insert-chunks"
description = "Enables the insert_chunks command without any pre-configured scope."
commands.allow = ["insert_chunks"]
[[permission]]
identifier = "deny-insert-chunks"
description = "Denies the insert_chunks command without any pre-configured scope."
commands.deny = ["insert_chunks"]

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-list-attachments"
description = "Enables the list_attachments command without any pre-configured scope."
commands.allow = ["list_attachments"]
[[permission]]
identifier = "deny-list-attachments"
description = "Denies the list_attachments command without any pre-configured scope."
commands.deny = ["list_attachments"]

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-search-collection"
description = "Enables the search_collection command without any pre-configured scope."
commands.allow = ["search_collection"]
[[permission]]
identifier = "deny-search-collection"
description = "Denies the search_collection command without any pre-configured scope."
commands.deny = ["search_collection"]

View File

@ -0,0 +1,313 @@
## Default Permission
Default permissions for the vector-db plugin
#### This default permission set includes the following:
- `allow-get-status`
- `allow-create-collection`
- `allow-insert-chunks`
- `allow-create-file`
- `allow-search-collection`
- `allow-delete-chunks`
- `allow-delete-file`
- `allow-delete-collection`
- `allow-chunk-text`
- `allow-list-attachments`
- `allow-get-chunks`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`vector-db:allow-chunk-text`
</td>
<td>
Enables the chunk_text command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:deny-chunk-text`
</td>
<td>
Denies the chunk_text command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:allow-create-collection`
</td>
<td>
Enables the create_collection command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:deny-create-collection`
</td>
<td>
Denies the create_collection command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:allow-create-file`
</td>
<td>
Enables the create_file command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:deny-create-file`
</td>
<td>
Denies the create_file command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:allow-delete-chunks`
</td>
<td>
Enables the delete_chunks command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:deny-delete-chunks`
</td>
<td>
Denies the delete_chunks command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:allow-delete-collection`
</td>
<td>
Enables the delete_collection command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:deny-delete-collection`
</td>
<td>
Denies the delete_collection command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:allow-delete-file`
</td>
<td>
Enables the delete_file command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:deny-delete-file`
</td>
<td>
Denies the delete_file command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:allow-get-chunks`
</td>
<td>
Enables the get_chunks command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:deny-get-chunks`
</td>
<td>
Denies the get_chunks command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:allow-get-status`
</td>
<td>
Enables the get_status command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:deny-get-status`
</td>
<td>
Denies the get_status command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:allow-insert-chunks`
</td>
<td>
Enables the insert_chunks command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:deny-insert-chunks`
</td>
<td>
Denies the insert_chunks command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:allow-list-attachments`
</td>
<td>
Enables the list_attachments command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:deny-list-attachments`
</td>
<td>
Denies the list_attachments command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:allow-search-collection`
</td>
<td>
Enables the search_collection command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vector-db:deny-search-collection`
</td>
<td>
Denies the search_collection command without any pre-configured scope.
</td>
</tr>
</table>

View File

@ -0,0 +1,15 @@
[default]
description = "Default permissions for the vector-db plugin"
permissions = [
"allow-get-status",
"allow-create-collection",
"allow-insert-chunks",
"allow-create-file",
"allow-search-collection",
"allow-delete-chunks",
"allow-delete-file",
"allow-delete-collection",
"allow-chunk-text",
"allow-list-attachments",
"allow-get-chunks",
]

View File

@ -0,0 +1,438 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the chunk_text command without any pre-configured scope.",
"type": "string",
"const": "allow-chunk-text",
"markdownDescription": "Enables the chunk_text command without any pre-configured scope."
},
{
"description": "Denies the chunk_text command without any pre-configured scope.",
"type": "string",
"const": "deny-chunk-text",
"markdownDescription": "Denies the chunk_text command without any pre-configured scope."
},
{
"description": "Enables the create_collection command without any pre-configured scope.",
"type": "string",
"const": "allow-create-collection",
"markdownDescription": "Enables the create_collection command without any pre-configured scope."
},
{
"description": "Denies the create_collection command without any pre-configured scope.",
"type": "string",
"const": "deny-create-collection",
"markdownDescription": "Denies the create_collection command without any pre-configured scope."
},
{
"description": "Enables the create_file command without any pre-configured scope.",
"type": "string",
"const": "allow-create-file",
"markdownDescription": "Enables the create_file command without any pre-configured scope."
},
{
"description": "Denies the create_file command without any pre-configured scope.",
"type": "string",
"const": "deny-create-file",
"markdownDescription": "Denies the create_file command without any pre-configured scope."
},
{
"description": "Enables the delete_chunks command without any pre-configured scope.",
"type": "string",
"const": "allow-delete-chunks",
"markdownDescription": "Enables the delete_chunks command without any pre-configured scope."
},
{
"description": "Denies the delete_chunks command without any pre-configured scope.",
"type": "string",
"const": "deny-delete-chunks",
"markdownDescription": "Denies the delete_chunks command without any pre-configured scope."
},
{
"description": "Enables the delete_collection command without any pre-configured scope.",
"type": "string",
"const": "allow-delete-collection",
"markdownDescription": "Enables the delete_collection command without any pre-configured scope."
},
{
"description": "Denies the delete_collection command without any pre-configured scope.",
"type": "string",
"const": "deny-delete-collection",
"markdownDescription": "Denies the delete_collection command without any pre-configured scope."
},
{
"description": "Enables the delete_file command without any pre-configured scope.",
"type": "string",
"const": "allow-delete-file",
"markdownDescription": "Enables the delete_file command without any pre-configured scope."
},
{
"description": "Denies the delete_file command without any pre-configured scope.",
"type": "string",
"const": "deny-delete-file",
"markdownDescription": "Denies the delete_file command without any pre-configured scope."
},
{
"description": "Enables the get_chunks command without any pre-configured scope.",
"type": "string",
"const": "allow-get-chunks",
"markdownDescription": "Enables the get_chunks command without any pre-configured scope."
},
{
"description": "Denies the get_chunks command without any pre-configured scope.",
"type": "string",
"const": "deny-get-chunks",
"markdownDescription": "Denies the get_chunks command without any pre-configured scope."
},
{
"description": "Enables the get_status command without any pre-configured scope.",
"type": "string",
"const": "allow-get-status",
"markdownDescription": "Enables the get_status command without any pre-configured scope."
},
{
"description": "Denies the get_status command without any pre-configured scope.",
"type": "string",
"const": "deny-get-status",
"markdownDescription": "Denies the get_status command without any pre-configured scope."
},
{
"description": "Enables the insert_chunks command without any pre-configured scope.",
"type": "string",
"const": "allow-insert-chunks",
"markdownDescription": "Enables the insert_chunks command without any pre-configured scope."
},
{
"description": "Denies the insert_chunks command without any pre-configured scope.",
"type": "string",
"const": "deny-insert-chunks",
"markdownDescription": "Denies the insert_chunks command without any pre-configured scope."
},
{
"description": "Enables the list_attachments command without any pre-configured scope.",
"type": "string",
"const": "allow-list-attachments",
"markdownDescription": "Enables the list_attachments command without any pre-configured scope."
},
{
"description": "Denies the list_attachments command without any pre-configured scope.",
"type": "string",
"const": "deny-list-attachments",
"markdownDescription": "Denies the list_attachments command without any pre-configured scope."
},
{
"description": "Enables the search_collection command without any pre-configured scope.",
"type": "string",
"const": "allow-search-collection",
"markdownDescription": "Enables the search_collection command without any pre-configured scope."
},
{
"description": "Denies the search_collection command without any pre-configured scope.",
"type": "string",
"const": "deny-search-collection",
"markdownDescription": "Denies the search_collection command without any pre-configured scope."
},
{
"description": "Default permissions for the vector-db plugin\n#### This default permission set includes:\n\n- `allow-get-status`\n- `allow-create-collection`\n- `allow-insert-chunks`\n- `allow-create-file`\n- `allow-search-collection`\n- `allow-delete-chunks`\n- `allow-delete-file`\n- `allow-delete-collection`\n- `allow-chunk-text`\n- `allow-list-attachments`\n- `allow-get-chunks`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the vector-db plugin\n#### This default permission set includes:\n\n- `allow-get-status`\n- `allow-create-collection`\n- `allow-insert-chunks`\n- `allow-create-file`\n- `allow-search-collection`\n- `allow-delete-chunks`\n- `allow-delete-file`\n- `allow-delete-collection`\n- `allow-chunk-text`\n- `allow-list-attachments`\n- `allow-get-chunks`"
}
]
}
}
}

View File

@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { cwd } from 'node:process'
import typescript from '@rollup/plugin-typescript'
const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8'))
export default {
input: 'guest-js/index.ts',
output: [
{
file: pkg.exports.import,
format: 'esm'
},
{
file: pkg.exports.require,
format: 'cjs'
}
],
plugins: [
typescript({
declaration: true,
declarationDir: dirname(pkg.exports.import)
})
],
external: [
/^@tauri-apps\/api/,
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {})
]
}

View File

@ -0,0 +1,206 @@
use crate::{VectorDBError, VectorDBState};
use crate::db::{
self, AttachmentFileInfo, SearchResult, MinimalChunkInput,
};
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Debug, Serialize, Deserialize)]
pub struct Status {
pub ann_available: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileInput {
pub path: String,
pub name: Option<String>,
#[serde(rename = "type")]
pub file_type: Option<String>,
pub size: Option<i64>,
}
// ============================================================================
// Tauri Command Handlers
// ============================================================================
#[tauri::command]
pub async fn get_status(state: State<'_, VectorDBState>) -> Result<Status, VectorDBError> {
println!("[VectorDB] Checking ANN availability...");
let temp = db::collection_path(&state.base_dir, "__status__");
let conn = db::open_or_init_conn(&temp)?;
// Verbose version for startup diagnostics
let ann = {
if conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp.temp_vec USING vec0(embedding float[1])", []).is_ok() {
let _ = conn.execute("DROP TABLE IF EXISTS temp.temp_vec", []);
println!("[VectorDB] ✓ sqlite-vec already loaded");
true
} else {
unsafe { let _ = conn.load_extension_enable(); }
let paths = db::possible_sqlite_vec_paths();
println!("[VectorDB] Trying {} bundled paths...", paths.len());
let mut found = false;
for p in paths {
println!("[VectorDB] Trying: {}", p);
unsafe {
if let Ok(_) = conn.load_extension(&p, Some("sqlite3_vec_init")) {
if conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp.temp_vec USING vec0(embedding float[1])", []).is_ok() {
let _ = conn.execute("DROP TABLE IF EXISTS temp.temp_vec", []);
println!("[VectorDB] ✓ sqlite-vec loaded from: {}", p);
found = true;
break;
}
}
}
}
if !found {
println!("[VectorDB] ✗ Failed to load sqlite-vec from all paths");
}
found
}
};
println!("[VectorDB] ANN status: {}", if ann { "AVAILABLE ✓" } else { "NOT AVAILABLE ✗" });
Ok(Status { ann_available: ann })
}
#[tauri::command]
pub async fn create_collection<R: tauri::Runtime>(
_app: tauri::AppHandle<R>,
state: State<'_, VectorDBState>,
name: String,
dimension: usize,
) -> Result<(), VectorDBError> {
let path = db::collection_path(&state.base_dir, &name);
let conn = db::open_or_init_conn(&path)?;
let has_ann = db::create_schema(&conn, dimension)?;
if has_ann {
println!("[VectorDB] ✓ Collection '{}' created with ANN support", name);
} else {
println!("[VectorDB] ⚠ Collection '{}' created WITHOUT ANN support (will use linear search)", name);
}
Ok(())
}
#[tauri::command]
pub async fn create_file<R: tauri::Runtime>(
_app: tauri::AppHandle<R>,
state: State<'_, VectorDBState>,
collection: String,
file: FileInput,
) -> Result<AttachmentFileInfo, VectorDBError> {
let path = db::collection_path(&state.base_dir, &collection);
let conn = db::open_or_init_conn(&path)?;
db::create_file(
&conn,
&file.path,
file.name.as_deref(),
file.file_type.as_deref(),
file.size,
)
}
#[tauri::command]
pub async fn insert_chunks<R: tauri::Runtime>(
_app: tauri::AppHandle<R>,
state: State<'_, VectorDBState>,
collection: String,
file_id: String,
chunks: Vec<MinimalChunkInput>,
) -> Result<(), VectorDBError> {
let path = db::collection_path(&state.base_dir, &collection);
let conn = db::open_or_init_conn(&path)?;
let vec_loaded = db::try_load_sqlite_vec(&conn);
db::insert_chunks(&conn, &file_id, chunks, vec_loaded)
}
#[tauri::command]
pub async fn delete_file<R: tauri::Runtime>(
_app: tauri::AppHandle<R>,
state: State<'_, VectorDBState>,
collection: String,
file_id: String,
) -> Result<(), VectorDBError> {
let path = db::collection_path(&state.base_dir, &collection);
let conn = db::open_or_init_conn(&path)?;
db::delete_file(&conn, &file_id)
}
#[tauri::command]
pub async fn search_collection<R: tauri::Runtime>(
_app: tauri::AppHandle<R>,
state: State<'_, VectorDBState>,
collection: String,
query_embedding: Vec<f32>,
limit: usize,
threshold: f32,
mode: Option<String>,
file_ids: Option<Vec<String>>,
) -> Result<Vec<SearchResult>, VectorDBError> {
let path = db::collection_path(&state.base_dir, &collection);
let conn = db::open_or_init_conn(&path)?;
let vec_loaded = db::try_load_sqlite_vec(&conn);
db::search_collection(&conn, &query_embedding, limit, threshold, mode, vec_loaded, file_ids)
}
#[tauri::command]
pub async fn list_attachments<R: tauri::Runtime>(
_app: tauri::AppHandle<R>,
state: State<'_, VectorDBState>,
collection: String,
limit: Option<usize>,
) -> Result<Vec<AttachmentFileInfo>, VectorDBError> {
let path = db::collection_path(&state.base_dir, &collection);
let conn = db::open_or_init_conn(&path)?;
db::list_attachments(&conn, limit)
}
#[tauri::command]
pub async fn delete_chunks<R: tauri::Runtime>(
_app: tauri::AppHandle<R>,
state: State<'_, VectorDBState>,
collection: String,
ids: Vec<String>,
) -> Result<(), VectorDBError> {
let path = db::collection_path(&state.base_dir, &collection);
let conn = db::open_or_init_conn(&path)?;
db::delete_chunks(&conn, ids)
}
#[tauri::command]
pub async fn delete_collection<R: tauri::Runtime>(
_app: tauri::AppHandle<R>,
state: State<'_, VectorDBState>,
collection: String,
) -> Result<(), VectorDBError> {
let path = db::collection_path(&state.base_dir, &collection);
if path.exists() {
std::fs::remove_file(path).ok();
}
Ok(())
}
#[tauri::command]
pub async fn chunk_text<R: tauri::Runtime>(
_app: tauri::AppHandle<R>,
text: String,
chunk_size: usize,
chunk_overlap: usize,
) -> Result<Vec<String>, VectorDBError> {
Ok(db::chunk_text(text, chunk_size, chunk_overlap))
}
#[tauri::command]
pub async fn get_chunks<R: tauri::Runtime>(
_app: tauri::AppHandle<R>,
state: State<'_, VectorDBState>,
collection: String,
file_id: String,
start_order: i64,
end_order: i64,
) -> Result<Vec<SearchResult>, VectorDBError> {
let path = db::collection_path(&state.base_dir, &collection);
let conn = db::open_or_init_conn(&path)?;
db::get_chunks(&conn, file_id, start_order, end_order)
}

View File

@ -0,0 +1,630 @@
use crate::VectorDBError;
use crate::utils::{cosine_similarity, from_le_bytes_vec, to_le_bytes_vec};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FileMetadata {
pub name: Option<String>,
pub path: String,
#[serde(rename = "type")]
pub file_type: Option<String>,
pub size: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchResult {
pub id: String,
pub text: String,
pub score: Option<f32>,
pub file_id: String,
pub chunk_file_order: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AttachmentFileInfo {
pub id: String,
pub name: Option<String>,
pub path: Option<String>,
#[serde(rename = "type")]
pub file_type: Option<String>,
pub size: Option<i64>,
pub chunk_count: i64,
}
// New minimal chunk input (no id/metadata) for file-scoped insertion
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MinimalChunkInput {
pub text: String,
pub embedding: Vec<f32>,
}
// ============================================================================
// Connection & Path Management
// ============================================================================
pub fn collection_path(base: &PathBuf, name: &str) -> PathBuf {
let mut p = base.clone();
let clean = name.replace(['/', '\\'], "_");
let filename = format!("{}.db", clean);
p.push(&filename);
p
}
pub fn open_or_init_conn(path: &PathBuf) -> Result<Connection, VectorDBError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok();
}
let conn = Connection::open(path)?;
Ok(conn)
}
// ============================================================================
// SQLite-vec Extension Loading
// ============================================================================
pub fn try_load_sqlite_vec(conn: &Connection) -> bool {
// Check if vec0 module is already available
if conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp.temp_vec USING vec0(embedding float[1])", []).is_ok() {
let _ = conn.execute("DROP TABLE IF EXISTS temp.temp_vec", []);
return true;
}
unsafe {
let _ = conn.load_extension_enable();
}
let paths = possible_sqlite_vec_paths();
for p in paths {
unsafe {
if let Ok(_) = conn.load_extension(&p, Some("sqlite3_vec_init")) {
if conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp.temp_vec USING vec0(embedding float[1])", []).is_ok() {
let _ = conn.execute("DROP TABLE IF EXISTS temp.temp_vec", []);
return true;
}
}
}
}
false
}
pub fn possible_sqlite_vec_paths() -> Vec<String> {
let mut paths = Vec::new();
// Dev paths
paths.push("./src-tauri/resources/bin/sqlite-vec".to_string());
paths.push("./resources/bin/sqlite-vec".to_string());
// Exe-relative paths
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let mut d = dir.to_path_buf();
d.push("resources");
d.push("bin");
d.push("sqlite-vec");
paths.push(d.to_string_lossy().to_string());
}
#[cfg(target_os = "macos")]
{
if let Some(mac_dir) = exe.parent().and_then(|p| p.parent()) {
let mut r = mac_dir.to_path_buf();
r.push("Resources");
r.push("bin");
r.push("sqlite-vec");
paths.push(r.to_string_lossy().to_string());
}
}
}
paths
}
pub fn ensure_vec_table(conn: &Connection, dimension: usize) -> bool {
if try_load_sqlite_vec(conn) {
let create = format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[{}])",
dimension
);
match conn.execute(&create, []) {
Ok(_) => return true,
Err(e) => {
println!("[VectorDB] ✗ Failed to create chunks_vec: {}", e);
}
}
}
false
}
// ============================================================================
// Schema Creation
// ============================================================================
pub fn create_schema(conn: &Connection, dimension: usize) -> Result<bool, VectorDBError> {
// Files table
conn.execute(
"CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
path TEXT UNIQUE NOT NULL,
name TEXT,
type TEXT,
size INTEGER,
chunk_count INTEGER DEFAULT 0
)",
[],
)?;
// Chunks table
conn.execute(
"CREATE TABLE IF NOT EXISTS chunks (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
embedding BLOB NOT NULL,
file_id TEXT,
chunk_file_order INTEGER,
FOREIGN KEY (file_id) REFERENCES files(id)
)",
[],
)?;
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_id ON chunks(id)", [])?;
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_id ON chunks(file_id)", [])?;
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_order ON chunks(file_id, chunk_file_order)", [])?;
// Try to create vec virtual table
let has_ann = ensure_vec_table(conn, dimension);
Ok(has_ann)
}
// ============================================================================
// Insert Operations
// ============================================================================
pub fn create_file(
conn: &Connection,
path: &str,
name: Option<&str>,
file_type: Option<&str>,
size: Option<i64>,
) -> Result<AttachmentFileInfo, VectorDBError> {
let tx = conn.unchecked_transaction()?;
// Try get existing by path
if let Ok(Some(id)) = tx
.prepare("SELECT id FROM files WHERE path = ?1")
.and_then(|mut s| s.query_row(params![path], |r| r.get::<_, String>(0)).optional())
{
let row: AttachmentFileInfo = {
let mut stmt = tx.prepare(
"SELECT id, path, name, type, size, chunk_count FROM files WHERE id = ?1",
)?;
stmt.query_row(params![id.as_str()], |r| {
Ok(AttachmentFileInfo {
id: r.get(0)?,
path: r.get(1)?,
name: r.get(2)?,
file_type: r.get(3)?,
size: r.get(4)?,
chunk_count: r.get(5)?,
})
})?
};
tx.commit()?;
return Ok(row);
}
let new_id = Uuid::new_v4().to_string();
// Determine file size if not provided
let computed_size: Option<i64> = match size {
Some(s) if s > 0 => Some(s),
_ => {
match std::fs::metadata(path) {
Ok(meta) => Some(meta.len() as i64),
Err(_) => None,
}
}
};
tx.execute(
"INSERT INTO files (id, path, name, type, size, chunk_count) VALUES (?1, ?2, ?3, ?4, ?5, 0)",
params![new_id, path, name, file_type, computed_size],
)?;
let row: AttachmentFileInfo = {
let mut stmt = tx.prepare(
"SELECT id, path, name, type, size, chunk_count FROM files WHERE path = ?1",
)?;
stmt.query_row(params![path], |r| {
Ok(AttachmentFileInfo {
id: r.get(0)?,
path: r.get(1)?,
name: r.get(2)?,
file_type: r.get(3)?,
size: r.get(4)?,
chunk_count: r.get(5)?,
})
})?
};
tx.commit()?;
Ok(row)
}
pub fn insert_chunks(
conn: &Connection,
file_id: &str,
chunks: Vec<MinimalChunkInput>,
vec_loaded: bool,
) -> Result<(), VectorDBError> {
let tx = conn.unchecked_transaction()?;
// Check if vec table exists
let has_vec = if vec_loaded {
conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_vec'")
.and_then(|mut s| s.query_row([], |r| r.get::<_, String>(0)).optional())
.ok()
.flatten()
.is_some()
} else {
false
};
// Determine current max order
let mut current_order: i64 = tx
.query_row(
"SELECT COALESCE(MAX(chunk_file_order), -1) FROM chunks WHERE file_id = ?1",
params![file_id],
|row| row.get::<_, i64>(0),
)
.unwrap_or(-1);
for ch in chunks.into_iter() {
current_order += 1;
let emb = to_le_bytes_vec(&ch.embedding);
let chunk_id = Uuid::new_v4().to_string();
tx.execute(
"INSERT OR REPLACE INTO chunks (id, text, embedding, file_id, chunk_file_order) VALUES (?1, ?2, ?3, ?4, ?5)",
params![chunk_id, ch.text, emb, file_id, current_order],
)?;
if has_vec {
let rowid: i64 = tx
.prepare("SELECT rowid FROM chunks WHERE id=?1")?
.query_row(params![chunk_id], |r| r.get(0))?;
let json_vec = serde_json::to_string(&ch.embedding).unwrap_or("[]".to_string());
let _ = tx.execute(
"INSERT OR REPLACE INTO chunks_vec(rowid, embedding) VALUES (?1, ?2)",
params![rowid, json_vec],
);
}
}
// Update chunk_count
let count: i64 = tx.query_row(
"SELECT COUNT(*) FROM chunks WHERE file_id = ?1",
params![file_id],
|row| row.get(0),
)?;
tx.execute(
"UPDATE files SET chunk_count = ?1 WHERE id = ?2",
params![count, file_id],
)?;
tx.commit()?;
Ok(())
}
pub fn delete_file(conn: &Connection, file_id: &str) -> Result<(), VectorDBError> {
let tx = conn.unchecked_transaction()?;
tx.execute("DELETE FROM chunks WHERE file_id = ?1", params![file_id])?;
tx.execute("DELETE FROM files WHERE id = ?1", params![file_id])?;
tx.commit()?;
Ok(())
}
// ============================================================================
// Search Operations
// ============================================================================
pub fn search_collection(
conn: &Connection,
query_embedding: &[f32],
limit: usize,
threshold: f32,
mode: Option<String>,
vec_loaded: bool,
file_ids: Option<Vec<String>>,
) -> Result<Vec<SearchResult>, VectorDBError> {
let has_vec = if vec_loaded {
conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_vec'")
.and_then(|mut s| s.query_row([], |r| r.get::<_, String>(0)).optional())
.ok()
.flatten()
.is_some()
} else {
false
};
let prefer_ann = match mode.as_deref() {
Some("ann") => true,
Some("linear") => false,
_ => true, // auto prefers ANN when available
};
if has_vec && prefer_ann {
search_ann(conn, query_embedding, limit, file_ids)
} else {
search_linear(conn, query_embedding, limit, threshold, file_ids)
}
}
fn search_ann(
conn: &Connection,
query_embedding: &[f32],
limit: usize,
file_ids: Option<Vec<String>>,
) -> Result<Vec<SearchResult>, VectorDBError> {
let json_vec = serde_json::to_string(&query_embedding).unwrap_or("[]".to_string());
// Build query with optional file_id filtering
let query = if let Some(ref ids) = file_ids {
let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
format!(
"SELECT c.id, c.text, c.file_id, c.chunk_file_order, v.distance
FROM chunks_vec v
JOIN chunks c ON c.rowid = v.rowid
WHERE v.embedding MATCH ?1 AND k = ?2 AND c.file_id IN ({})
ORDER BY v.distance",
placeholders
)
} else {
"SELECT c.id, c.text, c.file_id, c.chunk_file_order, v.distance
FROM chunks_vec v
JOIN chunks c ON c.rowid = v.rowid
WHERE v.embedding MATCH ?1 AND k = ?2
ORDER BY v.distance".to_string()
};
let mut stmt = match conn.prepare(&query) {
Ok(s) => s,
Err(e) => {
println!("[VectorDB] ✗ Failed to prepare ANN query: {}", e);
return Err(e.into());
}
};
let mut rows = if let Some(ids) = file_ids {
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(json_vec),
Box::new(limit as i64),
];
for id in ids {
params.push(Box::new(id));
}
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
match stmt.query(&*param_refs) {
Ok(r) => r,
Err(e) => {
println!("[VectorDB] ✗ Failed to execute ANN query: {}", e);
return Err(e.into());
}
}
} else {
match stmt.query(params![json_vec, limit as i64]) {
Ok(r) => r,
Err(e) => {
println!("[VectorDB] ✗ Failed to execute ANN query: {}", e);
return Err(e.into());
}
}
};
let mut results = Vec::new();
while let Some(row) = rows.next()? {
let id: String = row.get(0)?;
let text: String = row.get(1)?;
let file_id: String = row.get(2)?;
let chunk_file_order: i64 = row.get(3)?;
let distance: f32 = row.get(4)?;
results.push(SearchResult {
id,
text,
score: Some(distance),
file_id,
chunk_file_order,
});
}
println!("[VectorDB] ANN search returned {} results", results.len());
Ok(results)
}
fn search_linear(
conn: &Connection,
query_embedding: &[f32],
limit: usize,
threshold: f32,
file_ids: Option<Vec<String>>,
) -> Result<Vec<SearchResult>, VectorDBError> {
let (query, params_vec): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(ids) = file_ids {
let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let query_str = format!(
"SELECT c.id, c.text, c.embedding, c.file_id, c.chunk_file_order
FROM chunks c
WHERE c.file_id IN ({})",
placeholders
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
for id in ids {
params.push(Box::new(id));
}
(query_str, params)
} else {
(
"SELECT c.id, c.text, c.embedding, c.file_id, c.chunk_file_order
FROM chunks c".to_string(),
Vec::new()
)
};
let mut stmt = conn.prepare(&query)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
let mut rows = if param_refs.is_empty() {
stmt.query([])?
} else {
stmt.query(&*param_refs)?
};
let mut results: Vec<SearchResult> = Vec::new();
while let Some(row) = rows.next()? {
let id: String = row.get(0)?;
let text: String = row.get(1)?;
let embedding_bytes: Vec<u8> = row.get(2)?;
let file_id: String = row.get(3)?;
let chunk_file_order: i64 = row.get(4)?;
let emb = from_le_bytes_vec(&embedding_bytes);
let score = cosine_similarity(query_embedding, &emb)?;
if score >= threshold {
results.push(SearchResult {
id,
text,
score: Some(score),
file_id,
chunk_file_order,
});
}
}
results.sort_by(|a, b| {
match (b.score, a.score) {
(Some(b_score), Some(a_score)) => b_score.partial_cmp(&a_score).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
let take: Vec<SearchResult> = results.into_iter().take(limit).collect();
println!("[VectorDB] Linear search returned {} results", take.len());
Ok(take)
}
// ============================================================================
// List Operations
// ============================================================================
pub fn list_attachments(
conn: &Connection,
limit: Option<usize>,
) -> Result<Vec<AttachmentFileInfo>, VectorDBError> {
let query = if let Some(lim) = limit {
format!("SELECT id, path, name, type, size, chunk_count FROM files LIMIT {}", lim)
} else {
"SELECT id, path, name, type, size, chunk_count FROM files".to_string()
};
let mut stmt = conn.prepare(&query)?;
let mut rows = stmt.query([])?;
let mut out = Vec::new();
while let Some(row) = rows.next()? {
let id: String = row.get(0)?;
let path: Option<String> = row.get(1)?;
let name: Option<String> = row.get(2)?;
let file_type: Option<String> = row.get(3)?;
let size: Option<i64> = row.get(4)?;
let chunk_count: i64 = row.get(5)?;
out.push(AttachmentFileInfo {
id,
name,
path,
file_type,
size,
chunk_count,
});
}
Ok(out)
}
// ============================================================================
// Delete Operations
// ============================================================================
pub fn delete_chunks(conn: &Connection, ids: Vec<String>) -> Result<(), VectorDBError> {
let tx = conn.unchecked_transaction()?;
for id in ids {
tx.execute("DELETE FROM chunks WHERE id = ?1", params![id])?;
}
tx.commit()?;
Ok(())
}
// ============================================================================
// Get Chunks by Order
// ============================================================================
pub fn get_chunks(
conn: &Connection,
file_id: String,
start_order: i64,
end_order: i64,
) -> Result<Vec<SearchResult>, VectorDBError> {
let mut stmt = conn.prepare(
"SELECT id, text, chunk_file_order FROM chunks
WHERE file_id = ?1 AND chunk_file_order >= ?2 AND chunk_file_order <= ?3
ORDER BY chunk_file_order"
)?;
let mut rows = stmt.query(params![&file_id, start_order, end_order])?;
let mut results = Vec::new();
while let Some(row) = rows.next()? {
results.push(SearchResult {
id: row.get(0)?,
text: row.get(1)?,
score: None,
file_id: file_id.clone(),
chunk_file_order: row.get(2)?,
});
}
Ok(results)
}
// ============================================================================
// Utility Operations
// ============================================================================
pub fn chunk_text(text: String, chunk_size: usize, chunk_overlap: usize) -> Vec<String> {
if chunk_size == 0 {
return vec![];
}
let mut chunks = Vec::new();
let chars: Vec<char> = text.chars().collect();
let mut start = 0usize;
while start < chars.len() {
let end = (start + chunk_size).min(chars.len());
let ch: String = chars[start..end].iter().collect();
chunks.push(ch);
if end >= chars.len() {
break;
}
let advance = if chunk_overlap >= chunk_size {
1
} else {
chunk_size - chunk_overlap
};
start += advance;
}
chunks
}

View File

@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
pub enum VectorDBError {
#[error("Database error: {0}")]
DatabaseError(String),
#[error("Invalid input: {0}")]
InvalidInput(String),
}
impl From<rusqlite::Error> for VectorDBError {
fn from(err: rusqlite::Error) -> Self {
VectorDBError::DatabaseError(err.to_string())
}
}
impl From<serde_json::Error> for VectorDBError {
fn from(err: serde_json::Error) -> Self {
VectorDBError::DatabaseError(err.to_string())
}
}

View File

@ -0,0 +1,36 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
Manager,
};
mod commands;
mod db;
mod error;
mod state;
mod utils;
pub use error::VectorDBError;
pub use state::VectorDBState;
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("vector-db")
.invoke_handler(tauri::generate_handler![
commands::create_collection,
commands::insert_chunks,
commands::create_file,
commands::search_collection,
commands::delete_chunks,
commands::delete_file,
commands::delete_collection,
commands::chunk_text,
commands::get_status,
commands::list_attachments,
commands::get_chunks,
])
.setup(|app, _api| {
app.manage(state::VectorDBState::new());
Ok(())
})
.build()
}

View File

@ -0,0 +1,17 @@
use std::path::PathBuf;
pub struct VectorDBState {
pub base_dir: PathBuf,
}
impl VectorDBState {
pub fn new() -> Self {
// Default vector db path: /Jan/data/db
let mut base = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
base.push("Jan");
base.push("data");
base.push("db");
std::fs::create_dir_all(&base).ok();
Self { base_dir: base }
}
}

View File

@ -0,0 +1,27 @@
use crate::VectorDBError;
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> Result<f32, VectorDBError> {
if a.len() != b.len() {
return Err(VectorDBError::InvalidInput(
"Vector dimensions don't match".to_string(),
));
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if mag_a == 0.0 || mag_b == 0.0 { return Ok(0.0); }
Ok(dot / (mag_a * mag_b))
}
pub fn to_le_bytes_vec(v: &[f32]) -> Vec<u8> {
v.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>()
}
pub fn from_le_bytes_vec(bytes: &[u8]) -> Vec<f32> {
bytes
.chunks_exact(4)
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.collect::<Vec<f32>>()
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2021",
"module": "esnext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noImplicitAny": true,
"noEmit": true
},
"include": ["guest-js/*.ts"],
"exclude": ["dist-js", "node_modules"]
}

View File

@ -19,10 +19,7 @@ pub fn get_app_configurations<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Ap
let default_data_folder = default_data_folder_path(app_handle.clone());
if !configuration_file.exists() {
log::info!(
"App config not found, creating default config at {:?}",
configuration_file
);
log::info!("App config not found, creating default config at {configuration_file:?}");
app_default_configuration.data_folder = default_data_folder;
@ -30,7 +27,7 @@ pub fn get_app_configurations<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Ap
&configuration_file,
serde_json::to_string(&app_default_configuration).unwrap(),
) {
log::error!("Failed to create default config: {}", err);
log::error!("Failed to create default config: {err}");
}
return app_default_configuration;
@ -40,18 +37,12 @@ pub fn get_app_configurations<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Ap
Ok(content) => match serde_json::from_str::<AppConfiguration>(&content) {
Ok(app_configurations) => app_configurations,
Err(err) => {
log::error!(
"Failed to parse app config, returning default config instead. Error: {}",
err
);
log::error!("Failed to parse app config, returning default config instead. Error: {err}");
app_default_configuration
}
},
Err(err) => {
log::error!(
"Failed to read app config, returning default config instead. Error: {}",
err
);
log::error!("Failed to read app config, returning default config instead. Error: {err}");
app_default_configuration
}
}
@ -63,10 +54,7 @@ pub fn update_app_configuration<R: Runtime>(
configuration: AppConfiguration,
) -> Result<(), String> {
let configuration_file = get_configuration_file_path(app_handle);
log::info!(
"update_app_configuration, configuration_file: {:?}",
configuration_file
);
log::info!("update_app_configuration, configuration_file: {configuration_file:?}");
fs::write(
configuration_file,
@ -95,8 +83,7 @@ pub fn get_jan_data_folder_path<R: Runtime>(app_handle: tauri::AppHandle<R>) ->
pub fn get_configuration_file_path<R: Runtime>(app_handle: tauri::AppHandle<R>) -> PathBuf {
let app_path = app_handle.path().app_data_dir().unwrap_or_else(|err| {
log::error!(
"Failed to get app data directory: {}. Using home directory instead.",
err
"Failed to get app data directory: {err}. Using home directory instead."
);
let home_dir = std::env::var(if cfg!(target_os = "windows") {
@ -130,9 +117,9 @@ pub fn get_configuration_file_path<R: Runtime>(app_handle: tauri::AppHandle<R>)
.join(package_name);
if old_data_dir.exists() {
return old_data_dir.join(CONFIGURATION_FILE_NAME);
old_data_dir.join(CONFIGURATION_FILE_NAME)
} else {
return app_path.join(CONFIGURATION_FILE_NAME);
app_path.join(CONFIGURATION_FILE_NAME)
}
}
@ -156,7 +143,7 @@ pub fn default_data_folder_path<R: Runtime>(app_handle: tauri::AppHandle<R>) ->
#[tauri::command]
pub fn get_user_home_path<R: Runtime>(app: AppHandle<R>) -> String {
return get_app_configurations(app.clone()).data_folder;
get_app_configurations(app.clone()).data_folder
}
#[tauri::command]
@ -171,16 +158,12 @@ pub fn change_app_data_folder<R: Runtime>(
// Create the new data folder if it doesn't exist
if !new_data_folder_path.exists() {
fs::create_dir_all(&new_data_folder_path)
.map_err(|e| format!("Failed to create new data folder: {}", e))?;
.map_err(|e| format!("Failed to create new data folder: {e}"))?;
}
// Copy all files from the old folder to the new one
if current_data_folder.exists() {
log::info!(
"Copying data from {:?} to {:?}",
current_data_folder,
new_data_folder_path
);
log::info!("Copying data from {current_data_folder:?} to {new_data_folder_path:?}");
// Check if this is a parent directory to avoid infinite recursion
if new_data_folder_path.starts_with(&current_data_folder) {
@ -193,7 +176,7 @@ pub fn change_app_data_folder<R: Runtime>(
&new_data_folder_path,
&[".uvx", ".npx"],
)
.map_err(|e| format!("Failed to copy data to new folder: {}", e))?;
.map_err(|e| format!("Failed to copy data to new folder: {e}"))?;
} else {
log::info!("Current data folder does not exist, nothing to copy");
}

View File

@ -19,7 +19,7 @@ pub async fn download_files<R: Runtime>(
{
let mut download_manager = state.download_manager.lock().await;
if download_manager.cancel_tokens.contains_key(task_id) {
return Err(format!("task_id {} exists", task_id));
return Err(format!("task_id {task_id} exists"));
}
download_manager
.cancel_tokens
@ -60,9 +60,9 @@ pub async fn cancel_download_task(state: State<'_, AppState>, task_id: &str) ->
let mut download_manager = state.download_manager.lock().await;
if let Some(token) = download_manager.cancel_tokens.remove(task_id) {
token.cancel();
log::info!("Cancelled download task: {}", task_id);
log::info!("Cancelled download task: {task_id}");
Ok(())
} else {
Err(format!("No download task: {}", task_id))
Err(format!("No download task: {task_id}"))
}
}

View File

@ -15,7 +15,7 @@ use url::Url;
// ===== UTILITY FUNCTIONS =====
pub fn err_to_string<E: std::fmt::Display>(e: E) -> String {
format!("Error: {}", e)
format!("Error: {e}")
}
@ -55,7 +55,7 @@ async fn validate_downloaded_file(
)
.unwrap();
log::info!("Starting validation for model: {}", model_id);
log::info!("Starting validation for model: {model_id}");
// Validate size if provided (fast check first)
if let Some(expected_size) = &item.size {
@ -73,8 +73,7 @@ async fn validate_downloaded_file(
actual_size
);
return Err(format!(
"Size verification failed. Expected {} bytes but got {} bytes.",
expected_size, actual_size
"Size verification failed. Expected {expected_size} bytes but got {actual_size} bytes."
));
}
@ -90,7 +89,7 @@ async fn validate_downloaded_file(
save_path.display(),
e
);
return Err(format!("Failed to verify file size: {}", e));
return Err(format!("Failed to verify file size: {e}"));
}
}
}
@ -115,9 +114,7 @@ async fn validate_downloaded_file(
computed_sha256
);
return Err(format!(
"Hash verification failed. The downloaded file is corrupted or has been tampered with."
));
return Err("Hash verification failed. The downloaded file is corrupted or has been tampered with.".to_string());
}
log::info!("Hash verification successful for {}", item.url);
@ -128,7 +125,7 @@ async fn validate_downloaded_file(
save_path.display(),
e
);
return Err(format!("Failed to verify file integrity: {}", e));
return Err(format!("Failed to verify file integrity: {e}"));
}
}
}
@ -140,14 +137,14 @@ async fn validate_downloaded_file(
pub fn validate_proxy_config(config: &ProxyConfig) -> Result<(), String> {
// Validate proxy URL format
if let Err(e) = Url::parse(&config.url) {
return Err(format!("Invalid proxy URL '{}': {}", config.url, e));
return Err(format!("Invalid proxy URL '{}': {e}", config.url));
}
// Check if proxy URL has valid scheme
let url = Url::parse(&config.url).unwrap(); // Safe to unwrap as we just validated it
match url.scheme() {
"http" | "https" | "socks4" | "socks5" => {}
scheme => return Err(format!("Unsupported proxy scheme: {}", scheme)),
scheme => return Err(format!("Unsupported proxy scheme: {scheme}")),
}
// Validate authentication credentials
@ -167,7 +164,7 @@ pub fn validate_proxy_config(config: &ProxyConfig) -> Result<(), String> {
}
// Basic validation for wildcard patterns
if entry.starts_with("*.") && entry.len() < 3 {
return Err(format!("Invalid wildcard pattern: {}", entry));
return Err(format!("Invalid wildcard pattern: {entry}"));
}
}
}
@ -214,8 +211,7 @@ pub fn should_bypass_proxy(url: &str, no_proxy: &[String]) -> bool {
}
// Simple wildcard matching
if entry.starts_with("*.") {
let domain = &entry[2..];
if let Some(domain) = entry.strip_prefix("*.") {
if host.ends_with(domain) {
return true;
}
@ -305,7 +301,7 @@ pub async fn _download_files_internal(
resume: bool,
cancel_token: CancellationToken,
) -> Result<(), String> {
log::info!("Start download task: {}", task_id);
log::info!("Start download task: {task_id}");
let header_map = _convert_headers(headers).map_err(err_to_string)?;
@ -320,9 +316,9 @@ pub async fn _download_files_internal(
}
let total_size: u64 = file_sizes.values().sum();
log::info!("Total download size: {}", total_size);
log::info!("Total download size: {total_size}");
let evt_name = format!("download-{}", task_id);
let evt_name = format!("download-{task_id}");
// Create progress tracker
let progress_tracker = ProgressTracker::new(items, file_sizes.clone());
@ -352,7 +348,7 @@ pub async fn _download_files_internal(
let cancel_token_clone = cancel_token.clone();
let evt_name_clone = evt_name.clone();
let progress_tracker_clone = progress_tracker.clone();
let file_id = format!("{}-{}", task_id, index);
let file_id = format!("{task_id}-{index}");
let file_size = file_sizes.get(&item.url).copied().unwrap_or(0);
let task = tokio::spawn(async move {
@ -377,7 +373,7 @@ pub async fn _download_files_internal(
// Wait for all downloads to complete
let mut validation_tasks = Vec::new();
for (task, item) in download_tasks.into_iter().zip(items.iter()) {
let result = task.await.map_err(|e| format!("Task join error: {}", e))?;
let result = task.await.map_err(|e| format!("Task join error: {e}"))?;
match result {
Ok(downloaded_path) => {
@ -399,7 +395,7 @@ pub async fn _download_files_internal(
for (validation_task, save_path, _item) in validation_tasks {
let validation_result = validation_task
.await
.map_err(|e| format!("Validation task join error: {}", e))?;
.map_err(|e| format!("Validation task join error: {e}"))?;
if let Err(validation_error) = validation_result {
// Clean up the file if validation fails
@ -448,7 +444,7 @@ async fn download_single_file(
if current_extension.is_empty() {
ext.to_string()
} else {
format!("{}.{}", current_extension, ext)
format!("{current_extension}.{ext}")
}
};
let tmp_save_path = save_path.with_extension(append_extension("tmp"));
@ -469,8 +465,8 @@ async fn download_single_file(
let decoded_url = url::Url::parse(&item.url)
.map(|u| u.to_string())
.unwrap_or_else(|_| item.url.clone());
log::info!("Started downloading: {}", decoded_url);
let client = _get_client_for_item(item, &header_map).map_err(err_to_string)?;
log::info!("Started downloading: {decoded_url}");
let client = _get_client_for_item(item, header_map).map_err(err_to_string)?;
let mut download_delta = 0u64;
let mut initial_progress = 0u64;
@ -503,7 +499,7 @@ async fn download_single_file(
}
Err(e) => {
// fallback to normal download
log::warn!("Failed to resume download: {}", e);
log::warn!("Failed to resume download: {e}");
should_resume = false;
_get_maybe_resume(&client, &item.url, 0).await?
}
@ -592,7 +588,7 @@ async fn download_single_file(
let decoded_url = url::Url::parse(&item.url)
.map(|u| u.to_string())
.unwrap_or_else(|_| item.url.clone());
log::info!("Finished downloading: {}", decoded_url);
log::info!("Finished downloading: {decoded_url}");
Ok(save_path.to_path_buf())
}
@ -606,7 +602,7 @@ pub async fn _get_maybe_resume(
if start_bytes > 0 {
let resp = client
.get(url)
.header("Range", format!("bytes={}-", start_bytes))
.header("Range", format!("bytes={start_bytes}-"))
.send()
.await
.map_err(err_to_string)?;

View File

@ -13,15 +13,24 @@ pub fn get_jan_extensions_path<R: Runtime>(app_handle: tauri::AppHandle<R>) -> P
#[tauri::command]
pub fn install_extensions<R: Runtime>(app: AppHandle<R>) {
if let Err(err) = setup::install_extensions(app, true) {
log::error!("Failed to install extensions: {}", err);
log::error!("Failed to install extensions: {err}");
}
}
#[tauri::command]
pub fn get_active_extensions<R: Runtime>(app: AppHandle<R>) -> Vec<serde_json::Value> {
// On mobile platforms, extensions are pre-bundled in the frontend
// Return empty array so frontend's MobileCoreService handles it
#[cfg(any(target_os = "android", target_os = "ios"))]
{
return vec![];
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
let mut path = get_jan_extensions_path(app);
path.push("extensions.json");
log::info!("get jan extensions, path: {:?}", path);
log::info!("get jan extensions, path: {path:?}");
let contents = fs::read_to_string(path);
let contents: Vec<serde_json::Value> = match contents {
@ -40,14 +49,15 @@ pub fn get_active_extensions<R: Runtime>(app: AppHandle<R>) -> Vec<serde_json::V
})
.collect(),
Err(error) => {
log::error!("Failed to parse extensions.json: {}", error);
log::error!("Failed to parse extensions.json: {error}");
vec![]
}
},
Err(error) => {
log::error!("Failed to read extensions.json: {}", error);
log::error!("Failed to read extensions.json: {error}");
vec![]
}
};
return contents;
}
}

View File

@ -9,7 +9,7 @@ fn test_rm() {
let app = mock_app();
let path = "test_rm_dir";
fs::create_dir_all(get_jan_data_folder_path(app.handle().clone()).join(path)).unwrap();
let args = vec![format!("file://{}", path).to_string()];
let args = vec![format!("file://{path}").to_string()];
let result = rm(app.handle().clone(), args);
assert!(result.is_ok());
assert!(!get_jan_data_folder_path(app.handle().clone())
@ -21,7 +21,7 @@ fn test_rm() {
fn test_mkdir() {
let app = mock_app();
let path = "test_mkdir_dir";
let args = vec![format!("file://{}", path).to_string()];
let args = vec![format!("file://{path}").to_string()];
let result = mkdir(app.handle().clone(), args);
assert!(result.is_ok());
assert!(get_jan_data_folder_path(app.handle().clone())
@ -39,7 +39,7 @@ fn test_join_path() {
assert_eq!(
result,
get_jan_data_folder_path(app.handle().clone())
.join(&format!("test_dir{}test_file", std::path::MAIN_SEPARATOR))
.join(format!("test_dir{}test_file", std::path::MAIN_SEPARATOR))
.to_string_lossy()
.to_string()
);

View File

@ -30,28 +30,28 @@ pub async fn activate_mcp_server<R: Runtime>(
#[tauri::command]
pub async fn deactivate_mcp_server(state: State<'_, AppState>, name: String) -> Result<(), String> {
log::info!("Deactivating MCP server: {}", name);
log::info!("Deactivating MCP server: {name}");
// First, mark server as manually deactivated to prevent restart
// Remove from active servers list to prevent restart
{
let mut active_servers = state.mcp_active_servers.lock().await;
active_servers.remove(&name);
log::info!("Removed MCP server {} from active servers list", name);
log::info!("Removed MCP server {name} from active servers list");
}
// Mark as not successfully connected to prevent restart logic
{
let mut connected = state.mcp_successfully_connected.lock().await;
connected.insert(name.clone(), false);
log::info!("Marked MCP server {} as not successfully connected", name);
log::info!("Marked MCP server {name} as not successfully connected");
}
// Reset restart count
{
let mut counts = state.mcp_restart_counts.lock().await;
counts.remove(&name);
log::info!("Reset restart count for MCP server {}", name);
log::info!("Reset restart count for MCP server {name}");
}
// Now remove and stop the server
@ -60,7 +60,7 @@ pub async fn deactivate_mcp_server(state: State<'_, AppState>, name: String) ->
let service = servers_map
.remove(&name)
.ok_or_else(|| format!("Server {} not found", name))?;
.ok_or_else(|| format!("Server {name} not found"))?;
// Release the lock before calling cancel
drop(servers_map);
@ -89,7 +89,7 @@ pub async fn restart_mcp_servers<R: Runtime>(app: AppHandle<R>, state: State<'_,
restart_active_mcp_servers(&app, servers).await?;
app.emit("mcp-update", "MCP servers updated")
.map_err(|e| format!("Failed to emit event: {}", e))?;
.map_err(|e| format!("Failed to emit event: {e}"))?;
Ok(())
}
@ -110,9 +110,7 @@ pub async fn reset_mcp_restart_count(
let old_count = *count;
*count = 0;
log::info!(
"MCP server {} restart count reset from {} to 0.",
server_name,
old_count
"MCP server {server_name} restart count reset from {old_count} to 0."
);
Ok(())
}
@ -219,7 +217,7 @@ pub async fn call_tool(
continue; // Tool not found in this server, try next
}
println!("Found tool {} in server", tool_name);
println!("Found tool {tool_name} in server");
// Call the tool with timeout and cancellation support
let tool_call = service.call_tool(CallToolRequestParam {
@ -234,22 +232,20 @@ pub async fn call_tool(
match result {
Ok(call_result) => call_result.map_err(|e| e.to_string()),
Err(_) => Err(format!(
"Tool call '{}' timed out after {} seconds",
tool_name,
"Tool call '{tool_name}' timed out after {} seconds",
MCP_TOOL_CALL_TIMEOUT.as_secs()
)),
}
}
_ = cancel_rx => {
Err(format!("Tool call '{}' was cancelled", tool_name))
Err(format!("Tool call '{tool_name}' was cancelled"))
}
}
} else {
match timeout(MCP_TOOL_CALL_TIMEOUT, tool_call).await {
Ok(call_result) => call_result.map_err(|e| e.to_string()),
Err(_) => Err(format!(
"Tool call '{}' timed out after {} seconds",
tool_name,
"Tool call '{tool_name}' timed out after {} seconds",
MCP_TOOL_CALL_TIMEOUT.as_secs()
)),
}
@ -264,7 +260,7 @@ pub async fn call_tool(
return result;
}
Err(format!("Tool {} not found", tool_name))
Err(format!("Tool {tool_name} not found"))
}
/// Cancels a running tool call by its cancellation token
@ -285,10 +281,10 @@ pub async fn cancel_tool_call(
if let Some(cancel_tx) = cancellations.remove(&cancellation_token) {
// Send cancellation signal - ignore if receiver is already dropped
let _ = cancel_tx.send(());
println!("Tool call with token {} cancelled", cancellation_token);
println!("Tool call with token {cancellation_token} cancelled");
Ok(())
} else {
Err(format!("Cancellation token {} not found", cancellation_token))
Err(format!("Cancellation token {cancellation_token} not found"))
}
}
@ -301,7 +297,7 @@ pub async fn get_mcp_configs<R: Runtime>(app: AppHandle<R>) -> Result<String, St
if !path.exists() {
log::info!("mcp_config.json not found, creating default empty config");
fs::write(&path, DEFAULT_MCP_CONFIG)
.map_err(|e| format!("Failed to create default MCP config: {}", e))?;
.map_err(|e| format!("Failed to create default MCP config: {e}"))?;
}
fs::read_to_string(path).map_err(|e| e.to_string())
@ -311,7 +307,7 @@ pub async fn get_mcp_configs<R: Runtime>(app: AppHandle<R>) -> Result<String, St
pub async fn save_mcp_configs<R: Runtime>(app: AppHandle<R>, configs: String) -> Result<(), String> {
let mut path = get_jan_data_folder_path(app);
path.push("mcp_config.json");
log::info!("save mcp configs, path: {:?}", path);
log::info!("save mcp configs, path: {path:?}");
fs::write(path, configs).map_err(|e| e.to_string())
}

View File

@ -56,22 +56,13 @@ pub fn calculate_exponential_backoff_delay(attempt: u32) -> u64 {
let hash = hasher.finish();
// Convert hash to jitter value in range [-jitter_range, +jitter_range]
let jitter_offset = (hash % (jitter_range * 2)) as i64 - jitter_range as i64;
jitter_offset
(hash % (jitter_range * 2)) as i64 - jitter_range as i64
} else {
0
};
// Apply jitter while ensuring delay stays positive and within bounds
let final_delay = cmp::max(
100, // Minimum 100ms delay
cmp::min(
MCP_MAX_RESTART_DELAY_MS,
(capped_delay as i64 + jitter) as u64,
),
);
final_delay
((capped_delay as i64 + jitter) as u64).clamp(100, MCP_MAX_RESTART_DELAY_MS)
}
/// Runs MCP commands by reading configuration from a JSON file and initializing servers
@ -135,9 +126,7 @@ pub async fn run_mcp_commands<R: Runtime>(
// If initial startup failed, we still want to continue with other servers
if let Err(e) = &result {
log::error!(
"Initial startup failed for MCP server {}: {}",
name_clone,
e
"Initial startup failed for MCP server {name_clone}: {e}"
);
}
@ -155,25 +144,23 @@ pub async fn run_mcp_commands<R: Runtime>(
match handle.await {
Ok((name, result)) => match result {
Ok(_) => {
log::info!("MCP server {} initialized successfully", name);
log::info!("MCP server {name} initialized successfully");
successful_count += 1;
}
Err(e) => {
log::error!("MCP server {} failed to initialize: {}", name, e);
log::error!("MCP server {name} failed to initialize: {e}");
failed_count += 1;
}
},
Err(e) => {
log::error!("Failed to join startup task: {}", e);
log::error!("Failed to join startup task: {e}");
failed_count += 1;
}
}
}
log::info!(
"MCP server initialization complete: {} successful, {} failed",
successful_count,
failed_count
"MCP server initialization complete: {successful_count} successful, {failed_count} failed"
);
Ok(())
@ -184,7 +171,7 @@ pub async fn monitor_mcp_server_handle(
servers_state: SharedMcpServers,
name: String,
) -> Option<rmcp::service::QuitReason> {
log::info!("Monitoring MCP server {} health", name);
log::info!("Monitoring MCP server {name} health");
// Monitor server health with periodic checks
loop {
@ -202,17 +189,17 @@ pub async fn monitor_mcp_server_handle(
true
}
Ok(Err(e)) => {
log::warn!("MCP server {} health check failed: {}", name, e);
log::warn!("MCP server {name} health check failed: {e}");
false
}
Err(_) => {
log::warn!("MCP server {} health check timed out", name);
log::warn!("MCP server {name} health check timed out");
false
}
}
} else {
// Server was removed from HashMap (e.g., by deactivate_mcp_server)
log::info!("MCP server {} no longer in running services", name);
log::info!("MCP server {name} no longer in running services");
return Some(rmcp::service::QuitReason::Closed);
}
};
@ -220,8 +207,7 @@ pub async fn monitor_mcp_server_handle(
if !health_check_result {
// Server failed health check - remove it and return
log::error!(
"MCP server {} failed health check, removing from active servers",
name
"MCP server {name} failed health check, removing from active servers"
);
let mut servers = servers_state.lock().await;
if let Some(service) = servers.remove(&name) {
@ -262,7 +248,7 @@ pub async fn start_mcp_server_with_restart<R: Runtime>(
let max_restarts = max_restarts.unwrap_or(5);
// Try the first start attempt and return its result
log::info!("Starting MCP server {} (Initial attempt)", name);
log::info!("Starting MCP server {name} (Initial attempt)");
let first_start_result = schedule_mcp_start_task(
app.clone(),
servers_state.clone(),
@ -273,7 +259,7 @@ pub async fn start_mcp_server_with_restart<R: Runtime>(
match first_start_result {
Ok(_) => {
log::info!("MCP server {} started successfully on first attempt", name);
log::info!("MCP server {name} started successfully on first attempt");
reset_restart_count(&restart_counts, &name).await;
// Check if server was marked as successfully connected (passed verification)
@ -298,18 +284,15 @@ pub async fn start_mcp_server_with_restart<R: Runtime>(
Ok(())
} else {
// Server failed verification, don't monitor for restarts
log::error!("MCP server {} failed verification after startup", name);
log::error!("MCP server {name} failed verification after startup");
Err(format!(
"MCP server {} failed verification after startup",
name
"MCP server {name} failed verification after startup"
))
}
}
Err(e) => {
log::error!(
"Failed to start MCP server {} on first attempt: {}",
name,
e
"Failed to start MCP server {name} on first attempt: {e}"
);
Err(e)
}
@ -336,9 +319,7 @@ pub async fn start_restart_loop<R: Runtime>(
if current_restart_count > max_restarts {
log::error!(
"MCP server {} reached maximum restart attempts ({}). Giving up.",
name,
max_restarts
"MCP server {name} reached maximum restart attempts ({max_restarts}). Giving up."
);
if let Err(e) = app.emit(
"mcp_max_restarts_reached",
@ -353,19 +334,13 @@ pub async fn start_restart_loop<R: Runtime>(
}
log::info!(
"Restarting MCP server {} (Attempt {}/{})",
name,
current_restart_count,
max_restarts
"Restarting MCP server {name} (Attempt {current_restart_count}/{max_restarts})"
);
// Calculate exponential backoff delay
let delay_ms = calculate_exponential_backoff_delay(current_restart_count);
log::info!(
"Waiting {}ms before restart attempt {} for MCP server {}",
delay_ms,
current_restart_count,
name
"Waiting {delay_ms}ms before restart attempt {current_restart_count} for MCP server {name}"
);
sleep(Duration::from_millis(delay_ms)).await;
@ -380,7 +355,7 @@ pub async fn start_restart_loop<R: Runtime>(
match start_result {
Ok(_) => {
log::info!("MCP server {} restarted successfully.", name);
log::info!("MCP server {name} restarted successfully.");
// Check if server passed verification (was marked as successfully connected)
let passed_verification = {
@ -390,8 +365,7 @@ pub async fn start_restart_loop<R: Runtime>(
if !passed_verification {
log::error!(
"MCP server {} failed verification after restart - stopping permanently",
name
"MCP server {name} failed verification after restart - stopping permanently"
);
break;
}
@ -402,9 +376,7 @@ pub async fn start_restart_loop<R: Runtime>(
if let Some(count) = counts.get_mut(&name) {
if *count > 0 {
log::info!(
"MCP server {} restarted successfully, resetting restart count from {} to 0.",
name,
*count
"MCP server {name} restarted successfully, resetting restart count from {count} to 0."
);
*count = 0;
}
@ -415,7 +387,7 @@ pub async fn start_restart_loop<R: Runtime>(
let quit_reason =
monitor_mcp_server_handle(servers_state.clone(), name.clone()).await;
log::info!("MCP server {} quit with reason: {:?}", name, quit_reason);
log::info!("MCP server {name} quit with reason: {quit_reason:?}");
// Check if server was marked as successfully connected
let was_connected = {
@ -426,8 +398,7 @@ pub async fn start_restart_loop<R: Runtime>(
// Only continue restart loop if server was previously connected
if !was_connected {
log::error!(
"MCP server {} failed before establishing successful connection - stopping permanently",
name
"MCP server {name} failed before establishing successful connection - stopping permanently"
);
break;
}
@ -435,11 +406,11 @@ pub async fn start_restart_loop<R: Runtime>(
// Determine if we should restart based on quit reason
let should_restart = match quit_reason {
Some(reason) => {
log::warn!("MCP server {} terminated unexpectedly: {:?}", name, reason);
log::warn!("MCP server {name} terminated unexpectedly: {reason:?}");
true
}
None => {
log::info!("MCP server {} was manually stopped - not restarting", name);
log::info!("MCP server {name} was manually stopped - not restarting");
false
}
};
@ -450,7 +421,7 @@ pub async fn start_restart_loop<R: Runtime>(
// Continue the loop for another restart attempt
}
Err(e) => {
log::error!("Failed to restart MCP server {}: {}", name, e);
log::error!("Failed to restart MCP server {name}: {e}");
// Check if server was marked as successfully connected before
let was_connected = {
@ -461,8 +432,7 @@ pub async fn start_restart_loop<R: Runtime>(
// Only continue restart attempts if server was previously connected
if !was_connected {
log::error!(
"MCP server {} failed restart and was never successfully connected - stopping permanently",
name
"MCP server {name} failed restart and was never successfully connected - stopping permanently"
);
break;
}
@ -526,10 +496,13 @@ async fn schedule_mcp_start_task<R: Runtime>(
client_info: Implementation {
name: "Jan Streamable Client".to_string(),
version: "0.0.1".to_string(),
title: None,
website_url: None,
icons: None,
},
};
let client = client_info.serve(transport).await.inspect_err(|e| {
log::error!("client error: {:?}", e);
log::error!("client error: {e:?}");
});
match client {
@ -545,12 +518,12 @@ async fn schedule_mcp_start_task<R: Runtime>(
let app_state = app.state::<AppState>();
let mut connected = app_state.mcp_successfully_connected.lock().await;
connected.insert(name.clone(), true);
log::info!("Marked MCP server {} as successfully connected", name);
log::info!("Marked MCP server {name} as successfully connected");
}
}
Err(e) => {
log::error!("Failed to connect to server: {}", e);
return Err(format!("Failed to connect to server: {}", e));
log::error!("Failed to connect to server: {e}");
return Err(format!("Failed to connect to server: {e}"));
}
}
} else if config_params.transport_type.as_deref() == Some("sse") && config_params.url.is_some()
@ -587,8 +560,8 @@ async fn schedule_mcp_start_task<R: Runtime>(
)
.await
.map_err(|e| {
log::error!("transport error: {:?}", e);
format!("Failed to start SSE transport: {}", e)
log::error!("transport error: {e:?}");
format!("Failed to start SSE transport: {e}")
})?;
let client_info = ClientInfo {
@ -597,10 +570,13 @@ async fn schedule_mcp_start_task<R: Runtime>(
client_info: Implementation {
name: "Jan SSE Client".to_string(),
version: "0.0.1".to_string(),
title: None,
website_url: None,
icons: None,
},
};
let client = client_info.serve(transport).await.map_err(|e| {
log::error!("client error: {:?}", e);
log::error!("client error: {e:?}");
e.to_string()
});
@ -617,12 +593,12 @@ async fn schedule_mcp_start_task<R: Runtime>(
let app_state = app.state::<AppState>();
let mut connected = app_state.mcp_successfully_connected.lock().await;
connected.insert(name.clone(), true);
log::info!("Marked MCP server {} as successfully connected", name);
log::info!("Marked MCP server {name} as successfully connected");
}
}
Err(e) => {
log::error!("Failed to connect to server: {}", e);
return Err(format!("Failed to connect to server: {}", e));
log::error!("Failed to connect to server: {e}");
return Err(format!("Failed to connect to server: {e}"));
}
}
} else {
@ -639,7 +615,7 @@ async fn schedule_mcp_start_task<R: Runtime>(
cache_dir.push(".npx");
cmd = Command::new(bun_x_path.display().to_string());
cmd.arg("x");
cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string());
cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap());
}
let uv_path = if cfg!(windows) {
@ -654,7 +630,7 @@ async fn schedule_mcp_start_task<R: Runtime>(
cmd = Command::new(uv_path);
cmd.arg("tool");
cmd.arg("run");
cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap().to_string());
cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap());
}
#[cfg(windows)]
{
@ -726,8 +702,7 @@ async fn schedule_mcp_start_task<R: Runtime>(
if !server_still_running {
return Err(format!(
"MCP server {} quit immediately after starting",
name
"MCP server {name} quit immediately after starting"
));
}
// Mark server as successfully connected (for restart policy)
@ -735,7 +710,7 @@ async fn schedule_mcp_start_task<R: Runtime>(
let app_state = app.state::<AppState>();
let mut connected = app_state.mcp_successfully_connected.lock().await;
connected.insert(name.clone(), true);
log::info!("Marked MCP server {} as successfully connected", name);
log::info!("Marked MCP server {name} as successfully connected");
}
}
Ok(())
@ -792,7 +767,7 @@ pub async fn restart_active_mcp_servers<R: Runtime>(
);
for (name, config) in active_servers.iter() {
log::info!("Restarting MCP server: {}", name);
log::info!("Restarting MCP server: {name}");
// Start server with restart monitoring - spawn async task
let app_clone = app.clone();
@ -891,9 +866,7 @@ pub async fn spawn_server_monitoring_task<R: Runtime>(
monitor_mcp_server_handle(servers_clone.clone(), name_clone.clone()).await;
log::info!(
"MCP server {} quit with reason: {:?}",
name_clone,
quit_reason
"MCP server {name_clone} quit with reason: {quit_reason:?}"
);
// Check if we should restart based on connection status and quit reason
@ -928,8 +901,7 @@ pub async fn should_restart_server(
// Only restart if server was previously connected
if !was_connected {
log::error!(
"MCP server {} failed before establishing successful connection - stopping permanently",
name
"MCP server {name} failed before establishing successful connection - stopping permanently"
);
return false;
}
@ -937,11 +909,11 @@ pub async fn should_restart_server(
// Determine if we should restart based on quit reason
match quit_reason {
Some(reason) => {
log::warn!("MCP server {} terminated unexpectedly: {:?}", name, reason);
log::warn!("MCP server {name} terminated unexpectedly: {reason:?}");
true
}
None => {
log::info!("MCP server {} was manually stopped - not restarting", name);
log::info!("MCP server {name} was manually stopped - not restarting");
false
}
}

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