Merge branch 'dev-web' into stag-web

This commit is contained in:
Dinh Long Nguyen 2025-10-24 15:25:42 +07:00
commit 39917920bd
227 changed files with 12035 additions and 2478 deletions

View File

@ -12,7 +12,7 @@ jobs:
build-and-preview:
runs-on: [ubuntu-24-04-docker]
env:
JAN_API_BASE: "https://api-dev.menlo.ai/v1"
MENLO_PLATFORM_BASE_URL: "https://api-dev.menlo.ai/v1"
permissions:
pull-requests: write
contents: write
@ -52,7 +52,7 @@ jobs:
- name: Build docker image
run: |
docker build --build-arg JAN_API_BASE=${{ env.JAN_API_BASE }} -t ${{ steps.vars.outputs.FULL_IMAGE }} .
docker build --build-arg MENLO_PLATFORM_BASE_URL=${{ env.MENLO_PLATFORM_BASE_URL }} -t ${{ steps.vars.outputs.FULL_IMAGE }} .
- name: Push docker image
if: github.event_name == 'push'

View File

@ -13,7 +13,7 @@ jobs:
deployments: write
pull-requests: write
env:
JAN_API_BASE: "https://api.menlo.ai/v1"
MENLO_PLATFORM_BASE_URL: "https://api.menlo.ai/v1"
GA_MEASUREMENT_ID: "G-YK53MX8M8M"
CLOUDFLARE_PROJECT_NAME: "jan-server-web"
steps:
@ -43,7 +43,7 @@ jobs:
- name: Install dependencies
run: make config-yarn && yarn install && yarn build:core && make build-web-app
env:
JAN_API_BASE: ${{ env.JAN_API_BASE }}
MENLO_PLATFORM_BASE_URL: ${{ env.MENLO_PLATFORM_BASE_URL }}
GA_MEASUREMENT_ID: ${{ env.GA_MEASUREMENT_ID }}
- name: Publish to Cloudflare Pages Production

View File

@ -12,7 +12,7 @@ jobs:
build-and-preview:
runs-on: [ubuntu-24-04-docker]
env:
JAN_API_BASE: "https://api-stag.menlo.ai/v1"
MENLO_PLATFORM_BASE_URL: "https://api-stag.menlo.ai/v1"
permissions:
pull-requests: write
contents: write
@ -52,7 +52,7 @@ jobs:
- name: Build docker image
run: |
docker build --build-arg JAN_API_BASE=${{ env.JAN_API_BASE }} -t ${{ steps.vars.outputs.FULL_IMAGE }} .
docker build --build-arg MENLO_PLATFORM_BASE_URL=${{ env.MENLO_PLATFORM_BASE_URL }} -t ${{ steps.vars.outputs.FULL_IMAGE }} .
- name: Push docker image
if: github.event_name == 'push'

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

@ -1,8 +1,8 @@
# Stage 1: Build stage with Node.js and Yarn v4
FROM node:20-alpine AS builder
ARG JAN_API_BASE=https://api-dev.jan.ai/v1
ENV JAN_API_BASE=$JAN_API_BASE
ARG MENLO_PLATFORM_BASE_URL=https://api-dev.menlo.ai/v1
ENV MENLO_PLATFORM_BASE_URL=$MENLO_PLATFORM_BASE_URL
# Install build dependencies
RUN apk add --no-cache \

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

@ -2,7 +2,17 @@
Internal tracker for web component changes and features.
## v0.0.12 (Current)
## v0.0.13 (current)
**Release Date**: 2025-10-24
**Commit SHA**: 22645549cea48b1ae24b5b9dc70411fd3bfc9935
**Main Features**:
- Migrate auth to platform menlo
- Remove conv prefix
- Disable Project for web
- Model capabilites are fetched correctly from model catalog
## v0.0.12
**Release Date**: 2025-10-02
**Commit SHA**: df145d63a93bd27336b5b539ce0719fe9c7719e3

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

@ -16,7 +16,7 @@ import {
ListConversationItemsResponse
} from './types'
declare const JAN_API_BASE: string
declare const MENLO_PLATFORM_BASE_URL: string
export class RemoteApi {
private authService: JanAuthService
@ -28,7 +28,7 @@ export class RemoteApi {
async createConversation(
data: Conversation
): Promise<ConversationResponse> {
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATIONS}`
const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATIONS}`
return this.authService.makeAuthenticatedRequest<ConversationResponse>(
url,
@ -43,12 +43,12 @@ export class RemoteApi {
conversationId: string,
data: Conversation
): Promise<ConversationResponse> {
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}`
const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}`
return this.authService.makeAuthenticatedRequest<ConversationResponse>(
url,
{
method: 'PATCH',
method: 'POST',
body: JSON.stringify(data),
}
)
@ -70,7 +70,7 @@ export class RemoteApi {
}
const queryString = queryParams.toString()
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATIONS}${queryString ? `?${queryString}` : ''}`
const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATIONS}${queryString ? `?${queryString}` : ''}`
return this.authService.makeAuthenticatedRequest<ListConversationsResponse>(
url,
@ -114,7 +114,7 @@ export class RemoteApi {
}
async deleteConversation(conversationId: string): Promise<void> {
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}`
const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}`
await this.authService.makeAuthenticatedRequest(
url,
@ -141,7 +141,7 @@ export class RemoteApi {
}
const queryString = queryParams.toString()
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_ITEMS(conversationId)}${queryString ? `?${queryString}` : ''}`
const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATION_ITEMS(conversationId)}${queryString ? `?${queryString}` : ''}`
return this.authService.makeAuthenticatedRequest<ListConversationItemsResponse>(
url,

View File

@ -31,7 +31,7 @@ export interface ConversationResponse {
id: string
object: 'conversation'
title?: string
created_at: number
created_at: number | string
metadata: ConversationMetadata
}
@ -50,6 +50,7 @@ export interface ConversationItemAnnotation {
}
export interface ConversationItemContent {
type?: string
file?: {
file_id?: string
mime_type?: string
@ -62,23 +63,50 @@ export interface ConversationItemContent {
file_id?: string
url?: string
}
image_file?: {
file_id?: string
mime_type?: string
}
input_text?: string
output_text?: {
annotations?: ConversationItemAnnotation[]
text?: string
}
reasoning_content?: string
text?: {
value?: string
text?: string
}
type?: string
reasoning_content?: string
tool_calls?: Array<{
id?: string
type?: string
function?: {
name?: string
arguments?: string
}
}>
tool_call_id?: string
tool_result?: {
content?: Array<{
type?: string
text?: string
output_text?: {
text?: string
}
}>
output_text?: {
text?: string
}
}
text_result?: string
}
export interface ConversationItem {
content?: ConversationItemContent[]
created_at: number
created_at: number | string
id: string
object: string
metadata?: Record<string, unknown>
role: string
status?: string
type?: string

View File

@ -1,5 +1,5 @@
import { Thread, ThreadAssistantInfo, ThreadMessage, ContentType } from '@janhq/core'
import { Conversation, ConversationResponse, ConversationItem } from './types'
import { Conversation, ConversationResponse, ConversationItem, ConversationItemContent, ConversationMetadata } from './types'
import { DEFAULT_ASSISTANT } from './const'
export class ObjectParser {
@ -7,7 +7,7 @@ export class ObjectParser {
const modelName = thread.assistants?.[0]?.model?.id || undefined
const modelProvider = thread.assistants?.[0]?.model?.engine || undefined
const isFavorite = thread.metadata?.is_favorite?.toString() || 'false'
let metadata = {}
let metadata: ConversationMetadata = {}
if (modelName && modelProvider) {
metadata = {
model_id: modelName,
@ -23,15 +23,14 @@ export class ObjectParser {
static conversationToThread(conversation: ConversationResponse): Thread {
const assistants: ThreadAssistantInfo[] = []
if (
conversation.metadata?.model_id &&
conversation.metadata?.model_provider
) {
const metadata: ConversationMetadata = conversation.metadata || {}
if (metadata.model_id && metadata.model_provider) {
assistants.push({
...DEFAULT_ASSISTANT,
model: {
id: conversation.metadata.model_id,
engine: conversation.metadata.model_provider,
id: metadata.model_id,
engine: metadata.model_provider,
},
})
} else {
@ -44,16 +43,18 @@ export class ObjectParser {
})
}
const isFavorite = conversation.metadata?.is_favorite === 'true'
const isFavorite = metadata.is_favorite === 'true'
const createdAtMs = parseTimestamp(conversation.created_at)
return {
id: conversation.id,
title: conversation.title || '',
assistants,
created: conversation.created_at,
updated: conversation.created_at,
created: createdAtMs,
updated: createdAtMs,
model: {
id: conversation.metadata.model_id,
provider: conversation.metadata.model_provider,
id: metadata.model_id,
provider: metadata.model_provider,
},
isFavorite,
metadata: { is_favorite: isFavorite },
@ -65,74 +66,70 @@ export class ObjectParser {
threadId: string
): ThreadMessage {
// Extract text content and metadata from the item
let textContent = ''
let reasoningContent = ''
const textSegments: string[] = []
const reasoningSegments: string[] = []
const imageUrls: string[] = []
let toolCalls: any[] = []
let finishReason = ''
if (item.content && item.content.length > 0) {
for (const content of item.content) {
// Handle text content
if (content.text?.value) {
textContent = content.text.value
}
// Handle output_text for assistant messages
if (content.output_text?.text) {
textContent = content.output_text.text
}
// Handle reasoning content
if (content.reasoning_content) {
reasoningContent = content.reasoning_content
}
// Handle image content
if (content.image?.url) {
imageUrls.push(content.image.url)
}
// Extract finish_reason
if (content.finish_reason) {
finishReason = content.finish_reason
}
}
}
// Handle tool calls parsing for assistant messages
if (item.role === 'assistant' && finishReason === 'tool_calls') {
try {
// Tool calls are embedded as JSON string in textContent
const toolCallMatch = textContent.match(/\[.*\]/)
if (toolCallMatch) {
const toolCallsData = JSON.parse(toolCallMatch[0])
toolCalls = toolCallsData.map((toolCall: any) => ({
tool: {
id: toolCall.id || 'unknown',
function: {
name: toolCall.function?.name || 'unknown',
arguments: toolCall.function?.arguments || '{}'
},
type: toolCall.type || 'function'
},
response: {
error: '',
content: []
},
state: 'ready'
}))
// Remove tool calls JSON from text content, keep only reasoning
textContent = ''
}
} catch (error) {
console.error('Failed to parse tool calls:', error)
extractContentByType(content, {
onText: (value) => {
if (value) {
textSegments.push(value)
}
},
onReasoning: (value) => {
if (value) {
reasoningSegments.push(value)
}
},
onImage: (url) => {
if (url) {
imageUrls.push(url)
}
},
onToolCalls: (calls) => {
toolCalls = calls.map((toolCall) => {
const callId = toolCall.id || 'unknown'
const rawArgs = toolCall.function?.arguments
const normalizedArgs =
typeof rawArgs === 'string'
? rawArgs
: JSON.stringify(rawArgs ?? {})
return {
id: callId,
tool_call_id: callId,
tool: {
id: callId,
function: {
name: toolCall.function?.name || 'unknown',
arguments: normalizedArgs,
},
type: toolCall.type || 'function',
},
response: {
error: '',
content: [],
},
state: 'pending',
}
})
},
})
}
}
// Format final content with reasoning if present
let finalTextValue = ''
if (reasoningContent) {
finalTextValue = `<think>${reasoningContent}</think>`
if (reasoningSegments.length > 0) {
finalTextValue += `<think>${reasoningSegments.join('\n')}</think>`
}
if (textContent) {
finalTextValue += textContent
if (textSegments.length > 0) {
if (finalTextValue) {
finalTextValue += '\n'
}
finalTextValue += textSegments.join('\n')
}
// Build content array for ThreadMessage
@ -157,22 +154,26 @@ export class ObjectParser {
}
// Build metadata
const metadata: any = {}
const metadata: any = { ...(item.metadata || {}) }
if (toolCalls.length > 0) {
metadata.tool_calls = toolCalls
}
const createdAtMs = parseTimestamp(item.created_at)
// Map status from server format to frontend format
const mappedStatus = item.status === 'completed' ? 'ready' : item.status || 'ready'
const role = item.role === 'user' || item.role === 'assistant' ? item.role : 'assistant'
return {
type: 'text',
id: item.id,
object: 'thread.message',
thread_id: threadId,
role: item.role as 'user' | 'assistant',
role,
content: messageContent,
created_at: item.created_at * 1000, // Convert to milliseconds
created_at: createdAtMs,
completed_at: 0,
status: mappedStatus,
metadata,
@ -201,25 +202,46 @@ export const combineConversationItemsToMessages = (
): ThreadMessage[] => {
const messages: ThreadMessage[] = []
const toolResponseMap = new Map<string, any>()
const sortedItems = [...items].sort(
(a, b) => parseTimestamp(a.created_at) - parseTimestamp(b.created_at)
)
// First pass: collect tool responses
for (const item of items) {
for (const item of sortedItems) {
if (item.role === 'tool') {
const toolContent = item.content?.[0]?.text?.value || ''
toolResponseMap.set(item.id, {
error: '',
content: [
{
type: 'text',
text: toolContent
}
]
})
for (const content of item.content ?? []) {
const toolCallId = content.tool_call_id || item.id
const toolResultText =
content.tool_result?.output_text?.text ||
(Array.isArray(content.tool_result?.content)
? content.tool_result?.content
?.map((entry) => entry.text || entry.output_text?.text)
.filter((text): text is string => Boolean(text))
.join('\n')
: undefined)
const toolContent =
content.text?.text ||
content.text?.value ||
content.output_text?.text ||
content.input_text ||
content.text_result ||
toolResultText ||
''
toolResponseMap.set(toolCallId, {
error: '',
content: [
{
type: 'text',
text: toolContent,
},
],
})
}
}
}
// Second pass: build messages and merge tool responses
for (const item of items) {
for (const item of sortedItems) {
// Skip tool messages as they will be merged into assistant messages
if (item.role === 'tool') {
continue
@ -228,14 +250,35 @@ export const combineConversationItemsToMessages = (
const message = ObjectParser.conversationItemToThreadMessage(item, threadId)
// If this is an assistant message with tool calls, merge tool responses
if (message.role === 'assistant' && message.metadata?.tool_calls && Array.isArray(message.metadata.tool_calls)) {
if (
message.role === 'assistant' &&
message.metadata?.tool_calls &&
Array.isArray(message.metadata.tool_calls)
) {
const toolCalls = message.metadata.tool_calls as any[]
let toolResponseIndex = 0
for (const [responseId, responseData] of toolResponseMap.entries()) {
if (toolResponseIndex < toolCalls.length) {
toolCalls[toolResponseIndex].response = responseData
toolResponseIndex++
for (const toolCall of toolCalls) {
const callId = toolCall.tool_call_id || toolCall.id || toolCall.tool?.id
let responseKey: string | undefined
let response: any = null
if (callId && toolResponseMap.has(callId)) {
responseKey = callId
response = toolResponseMap.get(callId)
} else {
const iterator = toolResponseMap.entries().next()
if (!iterator.done) {
responseKey = iterator.value[0]
response = iterator.value[1]
}
}
if (response) {
toolCall.response = response
toolCall.state = 'succeeded'
if (responseKey) {
toolResponseMap.delete(responseKey)
}
}
}
}
@ -245,3 +288,79 @@ export const combineConversationItemsToMessages = (
return messages
}
const parseTimestamp = (value: number | string | undefined): number => {
if (typeof value === 'number') {
// Distinguish between seconds and milliseconds
return value > 1e12 ? value : value * 1000
}
if (typeof value === 'string') {
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? Date.now() : parsed
}
return Date.now()
}
const extractContentByType = (
content: ConversationItemContent,
handlers: {
onText: (value: string) => void
onReasoning: (value: string) => void
onImage: (url: string) => void
onToolCalls: (calls: NonNullable<ConversationItemContent['tool_calls']>) => void
}
) => {
const type = content.type || ''
switch (type) {
case 'input_text':
handlers.onText(content.input_text || '')
break
case 'text':
handlers.onText(content.text?.text || content.text?.value || '')
break
case 'output_text':
handlers.onText(content.output_text?.text || '')
break
case 'reasoning_content':
handlers.onReasoning(content.reasoning_content || '')
break
case 'image':
case 'image_url':
if (content.image?.url) {
handlers.onImage(content.image.url)
}
break
case 'tool_calls':
if (content.tool_calls && Array.isArray(content.tool_calls)) {
handlers.onToolCalls(content.tool_calls)
}
break
case 'tool_result':
if (content.tool_result?.output_text?.text) {
handlers.onText(content.tool_result.output_text.text)
}
break
default:
// Fallback for legacy fields without explicit type
if (content.text?.value || content.text?.text) {
handlers.onText(content.text.value || content.text.text || '')
}
if (content.text_result) {
handlers.onText(content.text_result)
}
if (content.output_text?.text) {
handlers.onText(content.output_text.text)
}
if (content.reasoning_content) {
handlers.onReasoning(content.reasoning_content)
}
if (content.image?.url) {
handlers.onImage(content.image.url)
}
if (content.tool_calls && Array.isArray(content.tool_calls)) {
handlers.onToolCalls(content.tool_calls)
}
break
}
}

View File

@ -4,10 +4,11 @@
*/
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
// MENLO_PLATFORM_BASE_URL is defined in vite.config.ts
// Constants
const TEMPORARY_CHAT_ID = 'temporary-chat'
@ -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 = `${MENLO_PLATFORM_BASE_URL}${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()
const response = await this.authService.makeAuthenticatedRequest<JanModelsResponse>(
`${JAN_API_BASE}/conv/models`
)
this.modelsFetchPromise = (async () => {
const response = await this.authService.makeAuthenticatedRequest<JanModelsResponse>(
`${MENLO_PLATFORM_BASE_URL}${JAN_API_ROUTES.MODELS}`
)
const models = response.data || []
janProviderStore.setModels(models)
const summaries = response.data || []
return models
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 = `${MENLO_PLATFORM_BASE_URL}${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

@ -12,8 +12,8 @@ import { JanMCPOAuthProvider } from './oauth-provider'
import { WebSearchButton } from './components'
import type { ComponentType } from 'react'
// JAN_API_BASE is defined in vite.config.ts (defaults to 'https://api-dev.jan.ai/jan/v1')
declare const JAN_API_BASE: string
// MENLO_PLATFORM_BASE_URL is defined in vite.config.ts (defaults to 'https://api-dev.menlo.ai/jan/v1')
declare const MENLO_PLATFORM_BASE_URL: string
export default class MCPExtensionWeb extends MCPExtension {
private mcpEndpoint = '/mcp'
@ -77,7 +77,7 @@ export default class MCPExtensionWeb extends MCPExtension {
// Create transport with OAuth provider (handles token refresh automatically)
const transport = new StreamableHTTPClientTransport(
new URL(`${JAN_API_BASE}${this.mcpEndpoint}`),
new URL(`${MENLO_PLATFORM_BASE_URL}${this.mcpEndpoint}`),
{
authProvider: this.oauthProvider
// No sessionId needed - server will generate one automatically

View File

@ -6,13 +6,13 @@
import { AuthTokens } from './types'
import { AUTH_ENDPOINTS } from './const'
declare const JAN_API_BASE: string
declare const MENLO_PLATFORM_BASE_URL: string
/**
* Logout user on server
*/
export async function logoutUser(): Promise<void> {
const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.LOGOUT}`, {
const response = await fetch(`${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.LOGOUT}`, {
method: 'GET',
credentials: 'include',
headers: {
@ -29,7 +29,7 @@ export async function logoutUser(): Promise<void> {
* Guest login
*/
export async function guestLogin(): Promise<AuthTokens> {
const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.GUEST_LOGIN}`, {
const response = await fetch(`${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.GUEST_LOGIN}`, {
method: 'POST',
credentials: 'include',
headers: {
@ -51,7 +51,7 @@ export async function guestLogin(): Promise<AuthTokens> {
*/
export async function refreshToken(): Promise<AuthTokens> {
const response = await fetch(
`${JAN_API_BASE}${AUTH_ENDPOINTS.REFRESH_TOKEN}`,
`${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.REFRESH_TOKEN}`,
{
method: 'GET',
credentials: 'include',

View File

@ -5,10 +5,10 @@
import { AuthTokens, LoginUrlResponse } from './types'
declare const JAN_API_BASE: string
declare const MENLO_PLATFORM_BASE_URL: string
export async function getLoginUrl(endpoint: string): Promise<LoginUrlResponse> {
const response: Response = await fetch(`${JAN_API_BASE}${endpoint}`, {
const response: Response = await fetch(`${MENLO_PLATFORM_BASE_URL}${endpoint}`, {
method: 'GET',
credentials: 'include',
headers: {
@ -30,7 +30,7 @@ export async function handleOAuthCallback(
code: string,
state?: string
): Promise<AuthTokens> {
const response: Response = await fetch(`${JAN_API_BASE}${endpoint}`, {
const response: Response = await fetch(`${MENLO_PLATFORM_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@ -3,9 +3,9 @@
* Handles authentication flows for any OAuth provider
*/
declare const JAN_API_BASE: string
declare const MENLO_PLATFORM_BASE_URL: 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
}
})
@ -413,7 +429,7 @@ export class JanAuthService {
private async fetchUserProfile(): Promise<User | null> {
try {
return await this.makeAuthenticatedRequest<User>(
`${JAN_API_BASE}${AUTH_ENDPOINTS.ME}`
`${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.ME}`
)
} catch (error) {
console.error('Failed to fetch user profile:', error)

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

@ -1,5 +1,5 @@
export {}
declare global {
declare const JAN_API_BASE: string
declare const MENLO_PLATFORM_BASE_URL: string
}

View File

@ -14,6 +14,6 @@ export default defineConfig({
emptyOutDir: false // Don't clean the output directory
},
define: {
JAN_API_BASE: JSON.stringify(process.env.JAN_API_BASE || 'https://api-dev.jan.ai/v1'),
MENLO_PLATFORM_BASE_URL: JSON.stringify(process.env.MENLO_PLATFORM_BASE_URL || 'https://api-dev.menlo.ai/v1'),
}
})

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,30 +2120,45 @@ 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 headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${sInfo.api_key}`,
const attemptRequest = async (session: SessionInfo) => {
const baseUrl = `http://localhost:${session.port}/v1/embeddings`
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.api_key}`,
}
const body = JSON.stringify({
input: text,
model: session.model_id,
encoding_format: 'float',
})
const response = await fetch(baseUrl, {
method: 'POST',
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)
}
const body = JSON.stringify({
input: text,
model: sInfo.model_id,
encoding_format: 'float',
})
const response = await fetch(baseUrl, {
method: 'POST',
headers,
body,
})
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
log::info!("No GPUs detected (likely unified memory system), using total RAM as VRAM");
total_system_memory
} else {
system_info
true => {
log::info!("No GPUs detected (likely unified memory system), using total RAM as VRAM");
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
log::info!("No GPUs detected (likely unified memory system), using total RAM as VRAM");
total_ram
} else {
sys_info
// 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");
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)
}

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