Merge branch 'dev' into dev-web
This commit is contained in:
commit
22645549ce
@ -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: |
|
||||
|
||||
@ -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
|
||||
|
||||
1
Makefile
1
Makefile
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -11,6 +11,8 @@ export enum ExtensionTypeEnum {
|
||||
HuggingFace = 'huggingFace',
|
||||
Engine = 'engine',
|
||||
Hardware = 'hardware',
|
||||
RAG = 'rag',
|
||||
VectorDB = 'vectorDB',
|
||||
}
|
||||
|
||||
export interface ExtensionType {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
36
core/src/browser/extensions/rag.ts
Normal file
36
core/src/browser/extensions/rag.ts
Normal 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>
|
||||
}
|
||||
82
core/src/browser/extensions/vector-db.ts
Normal file
82
core/src/browser/extensions/vector-db.ts
Normal 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[]>
|
||||
}
|
||||
@ -12,6 +12,8 @@ export type SettingComponentProps = {
|
||||
extensionName?: string
|
||||
requireModelReload?: boolean
|
||||
configType?: ConfigType
|
||||
titleKey?: string
|
||||
descriptionKey?: string
|
||||
}
|
||||
|
||||
export type ConfigType = 'runtime' | 'setting'
|
||||
|
||||
BIN
docs/public/assets/images/changelog/jan-release-v0.7.0.jpeg
Normal file
BIN
docs/public/assets/images/changelog/jan-release-v0.7.0.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
28
docs/src/pages/changelog/2025-10-02-jan-projects.mdx
Normal file
28
docs/src/pages/changelog/2025-10-02-jan-projects.mdx
Normal 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.
|
||||
|
||||
### What’s 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).
|
||||
@ -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).
|
||||
|
||||
|
||||
25
docs/src/pages/changelog/2025-10-16-jan-security-update.mdx
Normal file
25
docs/src/pages/changelog/2025-10-16-jan-security-update.mdx
Normal 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).
|
||||
@ -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
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
*/
|
||||
|
||||
import { getSharedAuthService, JanAuthService } from '../shared'
|
||||
import { JanModel, janProviderStore } from './store'
|
||||
import { ApiError } from '../shared/types/errors'
|
||||
import { JAN_API_ROUTES } from './const'
|
||||
import { JanModel, janProviderStore } from './store'
|
||||
|
||||
// JAN_API_BASE is defined in vite.config.ts
|
||||
|
||||
@ -19,12 +20,7 @@ const TEMPORARY_CHAT_ID = 'temporary-chat'
|
||||
*/
|
||||
function getChatCompletionConfig(request: JanChatCompletionRequest, stream: boolean = false) {
|
||||
const isTemporaryChat = request.conversation_id === TEMPORARY_CHAT_ID
|
||||
|
||||
// For temporary chats, use the stateless /chat/completions endpoint
|
||||
// For regular conversations, use the stateful /conv/chat/completions endpoint
|
||||
const endpoint = isTemporaryChat
|
||||
? `${JAN_API_BASE}/chat/completions`
|
||||
: `${JAN_API_BASE}/conv/chat/completions`
|
||||
const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.CHAT_COMPLETIONS}`
|
||||
|
||||
const payload = {
|
||||
...request,
|
||||
@ -44,9 +40,30 @@ function getChatCompletionConfig(request: JanChatCompletionRequest, stream: bool
|
||||
return { endpoint, payload, isTemporaryChat }
|
||||
}
|
||||
|
||||
export interface JanModelsResponse {
|
||||
interface JanModelSummary {
|
||||
id: string
|
||||
object: string
|
||||
data: JanModel[]
|
||||
owned_by: string
|
||||
created?: number
|
||||
}
|
||||
|
||||
interface JanModelsResponse {
|
||||
object: string
|
||||
data: JanModelSummary[]
|
||||
}
|
||||
|
||||
interface JanModelCatalogResponse {
|
||||
id: string
|
||||
supported_parameters?: {
|
||||
names?: string[]
|
||||
default?: Record<string, unknown>
|
||||
}
|
||||
extras?: {
|
||||
supported_parameters?: string[]
|
||||
default_parameters?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface JanChatMessage {
|
||||
@ -112,6 +129,8 @@ export interface JanChatCompletionChunk {
|
||||
export class JanApiClient {
|
||||
private static instance: JanApiClient
|
||||
private authService: JanAuthService
|
||||
private modelsCache: JanModel[] | null = null
|
||||
private modelsFetchPromise: Promise<JanModel[]> | null = null
|
||||
|
||||
private constructor() {
|
||||
this.authService = getSharedAuthService()
|
||||
@ -124,25 +143,64 @@ export class JanApiClient {
|
||||
return JanApiClient.instance
|
||||
}
|
||||
|
||||
async getModels(): Promise<JanModel[]> {
|
||||
async getModels(options?: { forceRefresh?: boolean }): Promise<JanModel[]> {
|
||||
try {
|
||||
const forceRefresh = options?.forceRefresh ?? false
|
||||
|
||||
if (forceRefresh) {
|
||||
this.modelsCache = null
|
||||
} else if (this.modelsCache) {
|
||||
return this.modelsCache
|
||||
}
|
||||
|
||||
if (this.modelsFetchPromise) {
|
||||
return this.modelsFetchPromise
|
||||
}
|
||||
|
||||
janProviderStore.setLoadingModels(true)
|
||||
janProviderStore.clearError()
|
||||
|
||||
const response = await this.authService.makeAuthenticatedRequest<JanModelsResponse>(
|
||||
`${JAN_API_BASE}/conv/models`
|
||||
)
|
||||
this.modelsFetchPromise = (async () => {
|
||||
const response = await this.authService.makeAuthenticatedRequest<JanModelsResponse>(
|
||||
`${JAN_API_BASE}${JAN_API_ROUTES.MODELS}`
|
||||
)
|
||||
|
||||
const models = response.data || []
|
||||
janProviderStore.setModels(models)
|
||||
|
||||
return models
|
||||
const summaries = response.data || []
|
||||
|
||||
const models: JanModel[] = await Promise.all(
|
||||
summaries.map(async (summary) => {
|
||||
const supportedParameters = await this.fetchSupportedParameters(summary.id)
|
||||
const capabilities = this.deriveCapabilitiesFromParameters(supportedParameters)
|
||||
|
||||
return {
|
||||
id: summary.id,
|
||||
object: summary.object,
|
||||
owned_by: summary.owned_by,
|
||||
created: summary.created,
|
||||
capabilities,
|
||||
supportedParameters,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
this.modelsCache = models
|
||||
janProviderStore.setModels(models)
|
||||
|
||||
return models
|
||||
})()
|
||||
|
||||
return await this.modelsFetchPromise
|
||||
} catch (error) {
|
||||
this.modelsCache = null
|
||||
this.modelsFetchPromise = null
|
||||
|
||||
const errorMessage = error instanceof ApiError ? error.message :
|
||||
error instanceof Error ? error.message : 'Failed to fetch models'
|
||||
janProviderStore.setError(errorMessage)
|
||||
janProviderStore.setLoadingModels(false)
|
||||
throw error
|
||||
} finally {
|
||||
this.modelsFetchPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
@ -254,7 +312,7 @@ export class JanApiClient {
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
janProviderStore.setAuthenticated(true)
|
||||
// Fetch initial models
|
||||
// Fetch initial models (cached for subsequent calls)
|
||||
await this.getModels()
|
||||
console.log('Jan API client initialized successfully')
|
||||
} catch (error) {
|
||||
@ -266,6 +324,52 @@ export class JanApiClient {
|
||||
janProviderStore.setInitializing(false)
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchSupportedParameters(modelId: string): Promise<string[]> {
|
||||
try {
|
||||
const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.MODEL_CATALOGS}/${this.encodeModelIdForCatalog(modelId)}`
|
||||
const catalog = await this.authService.makeAuthenticatedRequest<JanModelCatalogResponse>(endpoint)
|
||||
return this.extractSupportedParameters(catalog)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch catalog metadata for model "${modelId}":`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private encodeModelIdForCatalog(modelId: string): string {
|
||||
return modelId
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/')
|
||||
}
|
||||
|
||||
private extractSupportedParameters(catalog: JanModelCatalogResponse | null | undefined): string[] {
|
||||
if (!catalog) {
|
||||
return []
|
||||
}
|
||||
|
||||
const primaryNames = catalog.supported_parameters?.names
|
||||
if (Array.isArray(primaryNames) && primaryNames.length > 0) {
|
||||
return [...new Set(primaryNames)]
|
||||
}
|
||||
|
||||
const extraNames = catalog.extras?.supported_parameters
|
||||
if (Array.isArray(extraNames) && extraNames.length > 0) {
|
||||
return [...new Set(extraNames)]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private deriveCapabilitiesFromParameters(parameters: string[]): string[] {
|
||||
const capabilities = new Set<string>()
|
||||
|
||||
if (parameters.includes('tools')) {
|
||||
capabilities.add('tools')
|
||||
}
|
||||
|
||||
return Array.from(capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
export const janApiClient = JanApiClient.getInstance()
|
||||
|
||||
7
extensions-web/src/jan-provider-web/const.ts
Normal file
7
extensions-web/src/jan-provider-web/const.ts
Normal 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'
|
||||
122
extensions-web/src/jan-provider-web/helpers.ts
Normal file
122
extensions-web/src/jan-provider-web/helpers.ts
Normal 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
|
||||
}
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,9 @@ export interface JanModel {
|
||||
id: string
|
||||
object: string
|
||||
owned_by: string
|
||||
created?: number
|
||||
capabilities: string[]
|
||||
supportedParameters?: string[]
|
||||
}
|
||||
|
||||
export interface JanProviderState {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
declare const JAN_API_BASE: string
|
||||
|
||||
import { User, AuthState, AuthBroadcastMessage } from './types'
|
||||
import { User, AuthState, AuthBroadcastMessage, AuthTokens } from './types'
|
||||
import {
|
||||
AUTH_STORAGE_KEYS,
|
||||
AUTH_ENDPOINTS,
|
||||
@ -115,7 +115,7 @@ export class JanAuthService {
|
||||
|
||||
// Store tokens and set authenticated state
|
||||
this.accessToken = tokens.access_token
|
||||
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
|
||||
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
|
||||
this.setAuthProvider(providerId)
|
||||
|
||||
this.authBroadcast.broadcastLogin()
|
||||
@ -158,7 +158,7 @@ export class JanAuthService {
|
||||
const tokens = await refreshToken()
|
||||
|
||||
this.accessToken = tokens.access_token
|
||||
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
|
||||
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh access token:', error)
|
||||
if (error instanceof ApiError && error.isStatus(401)) {
|
||||
@ -343,6 +343,23 @@ export class JanAuthService {
|
||||
localStorage.removeItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER)
|
||||
}
|
||||
|
||||
private computeTokenExpiry(tokens: AuthTokens): number {
|
||||
if (tokens.expires_at) {
|
||||
const expiresAt = new Date(tokens.expires_at).getTime()
|
||||
if (!Number.isNaN(expiresAt)) {
|
||||
return expiresAt
|
||||
}
|
||||
console.warn('Invalid expires_at format in auth tokens:', tokens.expires_at)
|
||||
}
|
||||
|
||||
if (typeof tokens.expires_in === 'number') {
|
||||
return Date.now() + tokens.expires_in * 1000
|
||||
}
|
||||
|
||||
console.warn('Auth tokens missing expiry information; defaulting to immediate expiry')
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure guest access is available
|
||||
*/
|
||||
@ -352,7 +369,7 @@ export class JanAuthService {
|
||||
if (!this.accessToken || Date.now() > this.tokenExpiryTime) {
|
||||
const tokens = await guestLogin()
|
||||
this.accessToken = tokens.access_token
|
||||
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
|
||||
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure guest access:', error)
|
||||
@ -387,7 +404,6 @@ export class JanAuthService {
|
||||
case AUTH_EVENTS.LOGOUT:
|
||||
// Another tab logged out, clear our state
|
||||
this.clearAuthState()
|
||||
this.ensureGuestAccess().catch(console.error)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 “not‑eligible 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) {
|
||||
|
||||
33
extensions/rag-extension/package.json
Normal file
33
extensions/rag-extension/package.json
Normal 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"
|
||||
}
|
||||
14
extensions/rag-extension/rolldown.config.mjs
Normal file
14
extensions/rag-extension/rolldown.config.mjs
Normal 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),
|
||||
},
|
||||
})
|
||||
58
extensions/rag-extension/settings.json
Normal file
58
extensions/rag-extension/settings.json
Normal 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
5
extensions/rag-extension/src/env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
import type { SettingComponentProps } from '@janhq/core'
|
||||
declare global {
|
||||
const SETTINGS: SettingComponentProps[]
|
||||
}
|
||||
export {}
|
||||
14
extensions/rag-extension/src/global.d.ts
vendored
Normal file
14
extensions/rag-extension/src/global.d.ts
vendored
Normal 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 {}
|
||||
305
extensions/rag-extension/src/index.ts
Normal file
305
extensions/rag-extension/src/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
58
extensions/rag-extension/src/tools.ts
Normal file
58
extensions/rag-extension/src/tools.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
33
extensions/vector-db-extension/package.json
Normal file
33
extensions/vector-db-extension/package.json
Normal 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"
|
||||
}
|
||||
11
extensions/vector-db-extension/rolldown.config.mjs
Normal file
11
extensions/vector-db-extension/rolldown.config.mjs
Normal 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: {},
|
||||
})
|
||||
107
extensions/vector-db-extension/src/index.ts
Normal file
107
extensions/vector-db-extension/src/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
13
package.json
13
package.json
@ -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",
|
||||
|
||||
@ -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.')
|
||||
}
|
||||
|
||||
|
||||
@ -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
2364
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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 }
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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",
|
||||
@ -60,4 +64,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>,
|
||||
|
||||
17
src-tauri/plugins/tauri-plugin-rag/.gitignore
vendored
Normal file
17
src-tauri/plugins/tauri-plugin-rag/.gitignore
vendored
Normal 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
|
||||
31
src-tauri/plugins/tauri-plugin-rag/Cargo.toml
Normal file
31
src-tauri/plugins/tauri-plugin-rag/Cargo.toml
Normal 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"] }
|
||||
7
src-tauri/plugins/tauri-plugin-rag/build.rs
Normal file
7
src-tauri/plugins/tauri-plugin-rag/build.rs
Normal file
@ -0,0 +1,7 @@
|
||||
fn main() {
|
||||
tauri_plugin::Builder::new(&[
|
||||
"parse_document",
|
||||
])
|
||||
.build();
|
||||
}
|
||||
|
||||
6
src-tauri/plugins/tauri-plugin-rag/guest-js/index.ts
Normal file
6
src-tauri/plugins/tauri-plugin-rag/guest-js/index.ts
Normal 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 })
|
||||
}
|
||||
33
src-tauri/plugins/tauri-plugin-rag/package.json
Normal file
33
src-tauri/plugins/tauri-plugin-rag/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
@ -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>
|
||||
@ -0,0 +1,6 @@
|
||||
[default]
|
||||
description = "Default permissions for the rag plugin"
|
||||
permissions = [
|
||||
"allow-parse-document",
|
||||
]
|
||||
|
||||
@ -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`"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src-tauri/plugins/tauri-plugin-rag/rollup.config.js
Normal file
32
src-tauri/plugins/tauri-plugin-rag/rollup.config.js
Normal 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 || {})
|
||||
]
|
||||
}
|
||||
|
||||
12
src-tauri/plugins/tauri-plugin-rag/src/commands.rs
Normal file
12
src-tauri/plugins/tauri-plugin-rag/src/commands.rs
Normal 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
|
||||
}
|
||||
20
src-tauri/plugins/tauri-plugin-rag/src/error.rs
Normal file
20
src-tauri/plugins/tauri-plugin-rag/src/error.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
|
||||
20
src-tauri/plugins/tauri-plugin-rag/src/lib.rs
Normal file
20
src-tauri/plugins/tauri-plugin-rag/src/lib.rs
Normal 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()
|
||||
}
|
||||
|
||||
274
src-tauri/plugins/tauri-plugin-rag/src/parser.rs
Normal file
274
src-tauri/plugins/tauri-plugin-rag/src/parser.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
15
src-tauri/plugins/tauri-plugin-rag/tsconfig.json
Normal file
15
src-tauri/plugins/tauri-plugin-rag/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
||||
17
src-tauri/plugins/tauri-plugin-vector-db/.gitignore
vendored
Normal file
17
src-tauri/plugins/tauri-plugin-vector-db/.gitignore
vendored
Normal 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
|
||||
25
src-tauri/plugins/tauri-plugin-vector-db/Cargo.toml
Normal file
25
src-tauri/plugins/tauri-plugin-vector-db/Cargo.toml
Normal 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"] }
|
||||
16
src-tauri/plugins/tauri-plugin-vector-db/build.rs
Normal file
16
src-tauri/plugins/tauri-plugin-vector-db/build.rs
Normal 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();
|
||||
}
|
||||
114
src-tauri/plugins/tauri-plugin-vector-db/guest-js/index.ts
Normal file
114
src-tauri/plugins/tauri-plugin-vector-db/guest-js/index.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
33
src-tauri/plugins/tauri-plugin-vector-db/package.json
Normal file
33
src-tauri/plugins/tauri-plugin-vector-db/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
@ -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"]
|
||||
@ -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"]
|
||||
@ -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"]
|
||||
@ -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"]
|
||||
@ -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"]
|
||||
@ -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"]
|
||||
@ -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"]
|
||||
@ -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"]
|
||||
@ -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"]
|
||||
@ -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"]
|
||||
@ -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>
|
||||
@ -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",
|
||||
]
|
||||
@ -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`"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src-tauri/plugins/tauri-plugin-vector-db/rollup.config.js
Normal file
32
src-tauri/plugins/tauri-plugin-vector-db/rollup.config.js
Normal 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 || {})
|
||||
]
|
||||
}
|
||||
|
||||
206
src-tauri/plugins/tauri-plugin-vector-db/src/commands.rs
Normal file
206
src-tauri/plugins/tauri-plugin-vector-db/src/commands.rs
Normal 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)
|
||||
}
|
||||
630
src-tauri/plugins/tauri-plugin-vector-db/src/db.rs
Normal file
630
src-tauri/plugins/tauri-plugin-vector-db/src/db.rs
Normal file
@ -0,0 +1,630 @@
|
||||
use crate::VectorDBError;
|
||||
use crate::utils::{cosine_similarity, from_le_bytes_vec, to_le_bytes_vec};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FileMetadata {
|
||||
pub name: Option<String>,
|
||||
pub path: String,
|
||||
#[serde(rename = "type")]
|
||||
pub file_type: Option<String>,
|
||||
pub size: Option<i64>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SearchResult {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub score: Option<f32>,
|
||||
pub file_id: String,
|
||||
pub chunk_file_order: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AttachmentFileInfo {
|
||||
pub id: String,
|
||||
pub name: Option<String>,
|
||||
pub path: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub file_type: Option<String>,
|
||||
pub size: Option<i64>,
|
||||
pub chunk_count: i64,
|
||||
}
|
||||
|
||||
// New minimal chunk input (no id/metadata) for file-scoped insertion
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MinimalChunkInput {
|
||||
pub text: String,
|
||||
pub embedding: Vec<f32>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection & Path Management
|
||||
// ============================================================================
|
||||
|
||||
pub fn collection_path(base: &PathBuf, name: &str) -> PathBuf {
|
||||
let mut p = base.clone();
|
||||
let clean = name.replace(['/', '\\'], "_");
|
||||
let filename = format!("{}.db", clean);
|
||||
p.push(&filename);
|
||||
p
|
||||
}
|
||||
|
||||
pub fn open_or_init_conn(path: &PathBuf) -> Result<Connection, VectorDBError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).ok();
|
||||
}
|
||||
let conn = Connection::open(path)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SQLite-vec Extension Loading
|
||||
// ============================================================================
|
||||
|
||||
pub fn try_load_sqlite_vec(conn: &Connection) -> bool {
|
||||
// Check if vec0 module is already available
|
||||
if conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp.temp_vec USING vec0(embedding float[1])", []).is_ok() {
|
||||
let _ = conn.execute("DROP TABLE IF EXISTS temp.temp_vec", []);
|
||||
return true;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let _ = conn.load_extension_enable();
|
||||
}
|
||||
|
||||
let paths = possible_sqlite_vec_paths();
|
||||
for p in paths {
|
||||
unsafe {
|
||||
if let Ok(_) = conn.load_extension(&p, Some("sqlite3_vec_init")) {
|
||||
if conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp.temp_vec USING vec0(embedding float[1])", []).is_ok() {
|
||||
let _ = conn.execute("DROP TABLE IF EXISTS temp.temp_vec", []);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn possible_sqlite_vec_paths() -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// Dev paths
|
||||
paths.push("./src-tauri/resources/bin/sqlite-vec".to_string());
|
||||
paths.push("./resources/bin/sqlite-vec".to_string());
|
||||
|
||||
// Exe-relative paths
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
let mut d = dir.to_path_buf();
|
||||
d.push("resources");
|
||||
d.push("bin");
|
||||
d.push("sqlite-vec");
|
||||
paths.push(d.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(mac_dir) = exe.parent().and_then(|p| p.parent()) {
|
||||
let mut r = mac_dir.to_path_buf();
|
||||
r.push("Resources");
|
||||
r.push("bin");
|
||||
r.push("sqlite-vec");
|
||||
paths.push(r.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
pub fn ensure_vec_table(conn: &Connection, dimension: usize) -> bool {
|
||||
if try_load_sqlite_vec(conn) {
|
||||
let create = format!(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[{}])",
|
||||
dimension
|
||||
);
|
||||
match conn.execute(&create, []) {
|
||||
Ok(_) => return true,
|
||||
Err(e) => {
|
||||
println!("[VectorDB] ✗ Failed to create chunks_vec: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schema Creation
|
||||
// ============================================================================
|
||||
|
||||
pub fn create_schema(conn: &Connection, dimension: usize) -> Result<bool, VectorDBError> {
|
||||
// Files table
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
type TEXT,
|
||||
size INTEGER,
|
||||
chunk_count INTEGER DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Chunks table
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
embedding BLOB NOT NULL,
|
||||
file_id TEXT,
|
||||
chunk_file_order INTEGER,
|
||||
FOREIGN KEY (file_id) REFERENCES files(id)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_id ON chunks(id)", [])?;
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_id ON chunks(file_id)", [])?;
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_order ON chunks(file_id, chunk_file_order)", [])?;
|
||||
|
||||
// Try to create vec virtual table
|
||||
let has_ann = ensure_vec_table(conn, dimension);
|
||||
Ok(has_ann)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Insert Operations
|
||||
// ============================================================================
|
||||
|
||||
pub fn create_file(
|
||||
conn: &Connection,
|
||||
path: &str,
|
||||
name: Option<&str>,
|
||||
file_type: Option<&str>,
|
||||
size: Option<i64>,
|
||||
) -> Result<AttachmentFileInfo, VectorDBError> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
|
||||
// Try get existing by path
|
||||
if let Ok(Some(id)) = tx
|
||||
.prepare("SELECT id FROM files WHERE path = ?1")
|
||||
.and_then(|mut s| s.query_row(params![path], |r| r.get::<_, String>(0)).optional())
|
||||
{
|
||||
let row: AttachmentFileInfo = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT id, path, name, type, size, chunk_count FROM files WHERE id = ?1",
|
||||
)?;
|
||||
stmt.query_row(params![id.as_str()], |r| {
|
||||
Ok(AttachmentFileInfo {
|
||||
id: r.get(0)?,
|
||||
path: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
file_type: r.get(3)?,
|
||||
size: r.get(4)?,
|
||||
chunk_count: r.get(5)?,
|
||||
})
|
||||
})?
|
||||
};
|
||||
tx.commit()?;
|
||||
return Ok(row);
|
||||
}
|
||||
|
||||
let new_id = Uuid::new_v4().to_string();
|
||||
// Determine file size if not provided
|
||||
let computed_size: Option<i64> = match size {
|
||||
Some(s) if s > 0 => Some(s),
|
||||
_ => {
|
||||
match std::fs::metadata(path) {
|
||||
Ok(meta) => Some(meta.len() as i64),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT INTO files (id, path, name, type, size, chunk_count) VALUES (?1, ?2, ?3, ?4, ?5, 0)",
|
||||
params![new_id, path, name, file_type, computed_size],
|
||||
)?;
|
||||
|
||||
let row: AttachmentFileInfo = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT id, path, name, type, size, chunk_count FROM files WHERE path = ?1",
|
||||
)?;
|
||||
stmt.query_row(params![path], |r| {
|
||||
Ok(AttachmentFileInfo {
|
||||
id: r.get(0)?,
|
||||
path: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
file_type: r.get(3)?,
|
||||
size: r.get(4)?,
|
||||
chunk_count: r.get(5)?,
|
||||
})
|
||||
})?
|
||||
};
|
||||
|
||||
tx.commit()?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub fn insert_chunks(
|
||||
conn: &Connection,
|
||||
file_id: &str,
|
||||
chunks: Vec<MinimalChunkInput>,
|
||||
vec_loaded: bool,
|
||||
) -> Result<(), VectorDBError> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
|
||||
// Check if vec table exists
|
||||
let has_vec = if vec_loaded {
|
||||
conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_vec'")
|
||||
.and_then(|mut s| s.query_row([], |r| r.get::<_, String>(0)).optional())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Determine current max order
|
||||
let mut current_order: i64 = tx
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(chunk_file_order), -1) FROM chunks WHERE file_id = ?1",
|
||||
params![file_id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap_or(-1);
|
||||
|
||||
for ch in chunks.into_iter() {
|
||||
current_order += 1;
|
||||
let emb = to_le_bytes_vec(&ch.embedding);
|
||||
let chunk_id = Uuid::new_v4().to_string();
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO chunks (id, text, embedding, file_id, chunk_file_order) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![chunk_id, ch.text, emb, file_id, current_order],
|
||||
)?;
|
||||
|
||||
if has_vec {
|
||||
let rowid: i64 = tx
|
||||
.prepare("SELECT rowid FROM chunks WHERE id=?1")?
|
||||
.query_row(params![chunk_id], |r| r.get(0))?;
|
||||
let json_vec = serde_json::to_string(&ch.embedding).unwrap_or("[]".to_string());
|
||||
let _ = tx.execute(
|
||||
"INSERT OR REPLACE INTO chunks_vec(rowid, embedding) VALUES (?1, ?2)",
|
||||
params![rowid, json_vec],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update chunk_count
|
||||
let count: i64 = tx.query_row(
|
||||
"SELECT COUNT(*) FROM chunks WHERE file_id = ?1",
|
||||
params![file_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE files SET chunk_count = ?1 WHERE id = ?2",
|
||||
params![count, file_id],
|
||||
)?;
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_file(conn: &Connection, file_id: &str) -> Result<(), VectorDBError> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute("DELETE FROM chunks WHERE file_id = ?1", params![file_id])?;
|
||||
tx.execute("DELETE FROM files WHERE id = ?1", params![file_id])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search Operations
|
||||
// ============================================================================
|
||||
|
||||
pub fn search_collection(
|
||||
conn: &Connection,
|
||||
query_embedding: &[f32],
|
||||
limit: usize,
|
||||
threshold: f32,
|
||||
mode: Option<String>,
|
||||
vec_loaded: bool,
|
||||
file_ids: Option<Vec<String>>,
|
||||
) -> Result<Vec<SearchResult>, VectorDBError> {
|
||||
let has_vec = if vec_loaded {
|
||||
conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_vec'")
|
||||
.and_then(|mut s| s.query_row([], |r| r.get::<_, String>(0)).optional())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let prefer_ann = match mode.as_deref() {
|
||||
Some("ann") => true,
|
||||
Some("linear") => false,
|
||||
_ => true, // auto prefers ANN when available
|
||||
};
|
||||
|
||||
if has_vec && prefer_ann {
|
||||
search_ann(conn, query_embedding, limit, file_ids)
|
||||
} else {
|
||||
search_linear(conn, query_embedding, limit, threshold, file_ids)
|
||||
}
|
||||
}
|
||||
|
||||
fn search_ann(
|
||||
conn: &Connection,
|
||||
query_embedding: &[f32],
|
||||
limit: usize,
|
||||
file_ids: Option<Vec<String>>,
|
||||
) -> Result<Vec<SearchResult>, VectorDBError> {
|
||||
let json_vec = serde_json::to_string(&query_embedding).unwrap_or("[]".to_string());
|
||||
|
||||
// Build query with optional file_id filtering
|
||||
let query = if let Some(ref ids) = file_ids {
|
||||
let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
|
||||
format!(
|
||||
"SELECT c.id, c.text, c.file_id, c.chunk_file_order, v.distance
|
||||
FROM chunks_vec v
|
||||
JOIN chunks c ON c.rowid = v.rowid
|
||||
WHERE v.embedding MATCH ?1 AND k = ?2 AND c.file_id IN ({})
|
||||
ORDER BY v.distance",
|
||||
placeholders
|
||||
)
|
||||
} else {
|
||||
"SELECT c.id, c.text, c.file_id, c.chunk_file_order, v.distance
|
||||
FROM chunks_vec v
|
||||
JOIN chunks c ON c.rowid = v.rowid
|
||||
WHERE v.embedding MATCH ?1 AND k = ?2
|
||||
ORDER BY v.distance".to_string()
|
||||
};
|
||||
|
||||
let mut stmt = match conn.prepare(&query) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
println!("[VectorDB] ✗ Failed to prepare ANN query: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
let mut rows = if let Some(ids) = file_ids {
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
|
||||
Box::new(json_vec),
|
||||
Box::new(limit as i64),
|
||||
];
|
||||
for id in ids {
|
||||
params.push(Box::new(id));
|
||||
}
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
match stmt.query(&*param_refs) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
println!("[VectorDB] ✗ Failed to execute ANN query: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match stmt.query(params![json_vec, limit as i64]) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
println!("[VectorDB] ✗ Failed to execute ANN query: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: String = row.get(0)?;
|
||||
let text: String = row.get(1)?;
|
||||
let file_id: String = row.get(2)?;
|
||||
let chunk_file_order: i64 = row.get(3)?;
|
||||
let distance: f32 = row.get(4)?;
|
||||
|
||||
results.push(SearchResult {
|
||||
id,
|
||||
text,
|
||||
score: Some(distance),
|
||||
file_id,
|
||||
chunk_file_order,
|
||||
});
|
||||
}
|
||||
|
||||
println!("[VectorDB] ANN search returned {} results", results.len());
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn search_linear(
|
||||
conn: &Connection,
|
||||
query_embedding: &[f32],
|
||||
limit: usize,
|
||||
threshold: f32,
|
||||
file_ids: Option<Vec<String>>,
|
||||
) -> Result<Vec<SearchResult>, VectorDBError> {
|
||||
let (query, params_vec): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(ids) = file_ids {
|
||||
let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
|
||||
let query_str = format!(
|
||||
"SELECT c.id, c.text, c.embedding, c.file_id, c.chunk_file_order
|
||||
FROM chunks c
|
||||
WHERE c.file_id IN ({})",
|
||||
placeholders
|
||||
);
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
for id in ids {
|
||||
params.push(Box::new(id));
|
||||
}
|
||||
(query_str, params)
|
||||
} else {
|
||||
(
|
||||
"SELECT c.id, c.text, c.embedding, c.file_id, c.chunk_file_order
|
||||
FROM chunks c".to_string(),
|
||||
Vec::new()
|
||||
)
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&query)?;
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
let mut rows = if param_refs.is_empty() {
|
||||
stmt.query([])?
|
||||
} else {
|
||||
stmt.query(&*param_refs)?
|
||||
};
|
||||
let mut results: Vec<SearchResult> = Vec::new();
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: String = row.get(0)?;
|
||||
let text: String = row.get(1)?;
|
||||
let embedding_bytes: Vec<u8> = row.get(2)?;
|
||||
let file_id: String = row.get(3)?;
|
||||
let chunk_file_order: i64 = row.get(4)?;
|
||||
|
||||
let emb = from_le_bytes_vec(&embedding_bytes);
|
||||
let score = cosine_similarity(query_embedding, &emb)?;
|
||||
|
||||
if score >= threshold {
|
||||
results.push(SearchResult {
|
||||
id,
|
||||
text,
|
||||
score: Some(score),
|
||||
file_id,
|
||||
chunk_file_order,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| {
|
||||
match (b.score, a.score) {
|
||||
(Some(b_score), Some(a_score)) => b_score.partial_cmp(&a_score).unwrap_or(std::cmp::Ordering::Equal),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
}
|
||||
});
|
||||
let take: Vec<SearchResult> = results.into_iter().take(limit).collect();
|
||||
println!("[VectorDB] Linear search returned {} results", take.len());
|
||||
Ok(take)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// List Operations
|
||||
// ============================================================================
|
||||
|
||||
pub fn list_attachments(
|
||||
conn: &Connection,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<AttachmentFileInfo>, VectorDBError> {
|
||||
let query = if let Some(lim) = limit {
|
||||
format!("SELECT id, path, name, type, size, chunk_count FROM files LIMIT {}", lim)
|
||||
} else {
|
||||
"SELECT id, path, name, type, size, chunk_count FROM files".to_string()
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&query)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut out = Vec::new();
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: String = row.get(0)?;
|
||||
let path: Option<String> = row.get(1)?;
|
||||
let name: Option<String> = row.get(2)?;
|
||||
let file_type: Option<String> = row.get(3)?;
|
||||
let size: Option<i64> = row.get(4)?;
|
||||
let chunk_count: i64 = row.get(5)?;
|
||||
out.push(AttachmentFileInfo {
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
file_type,
|
||||
size,
|
||||
chunk_count,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Delete Operations
|
||||
// ============================================================================
|
||||
|
||||
pub fn delete_chunks(conn: &Connection, ids: Vec<String>) -> Result<(), VectorDBError> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for id in ids {
|
||||
tx.execute("DELETE FROM chunks WHERE id = ?1", params![id])?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Chunks by Order
|
||||
// ============================================================================
|
||||
|
||||
pub fn get_chunks(
|
||||
conn: &Connection,
|
||||
file_id: String,
|
||||
start_order: i64,
|
||||
end_order: i64,
|
||||
) -> Result<Vec<SearchResult>, VectorDBError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, text, chunk_file_order FROM chunks
|
||||
WHERE file_id = ?1 AND chunk_file_order >= ?2 AND chunk_file_order <= ?3
|
||||
ORDER BY chunk_file_order"
|
||||
)?;
|
||||
let mut rows = stmt.query(params![&file_id, start_order, end_order])?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
results.push(SearchResult {
|
||||
id: row.get(0)?,
|
||||
text: row.get(1)?,
|
||||
score: None,
|
||||
file_id: file_id.clone(),
|
||||
chunk_file_order: row.get(2)?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Operations
|
||||
// ============================================================================
|
||||
|
||||
pub fn chunk_text(text: String, chunk_size: usize, chunk_overlap: usize) -> Vec<String> {
|
||||
if chunk_size == 0 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut start = 0usize;
|
||||
|
||||
while start < chars.len() {
|
||||
let end = (start + chunk_size).min(chars.len());
|
||||
let ch: String = chars[start..end].iter().collect();
|
||||
chunks.push(ch);
|
||||
if end >= chars.len() {
|
||||
break;
|
||||
}
|
||||
let advance = if chunk_overlap >= chunk_size {
|
||||
1
|
||||
} else {
|
||||
chunk_size - chunk_overlap
|
||||
};
|
||||
start += advance;
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
23
src-tauri/plugins/tauri-plugin-vector-db/src/error.rs
Normal file
23
src-tauri/plugins/tauri-plugin-vector-db/src/error.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
|
||||
pub enum VectorDBError {
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for VectorDBError {
|
||||
fn from(err: rusqlite::Error) -> Self {
|
||||
VectorDBError::DatabaseError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for VectorDBError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
VectorDBError::DatabaseError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
36
src-tauri/plugins/tauri-plugin-vector-db/src/lib.rs
Normal file
36
src-tauri/plugins/tauri-plugin-vector-db/src/lib.rs
Normal file
@ -0,0 +1,36 @@
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Runtime,
|
||||
Manager,
|
||||
};
|
||||
|
||||
mod commands;
|
||||
mod db;
|
||||
mod error;
|
||||
mod state;
|
||||
mod utils;
|
||||
|
||||
pub use error::VectorDBError;
|
||||
pub use state::VectorDBState;
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("vector-db")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::create_collection,
|
||||
commands::insert_chunks,
|
||||
commands::create_file,
|
||||
commands::search_collection,
|
||||
commands::delete_chunks,
|
||||
commands::delete_file,
|
||||
commands::delete_collection,
|
||||
commands::chunk_text,
|
||||
commands::get_status,
|
||||
commands::list_attachments,
|
||||
commands::get_chunks,
|
||||
])
|
||||
.setup(|app, _api| {
|
||||
app.manage(state::VectorDBState::new());
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
17
src-tauri/plugins/tauri-plugin-vector-db/src/state.rs
Normal file
17
src-tauri/plugins/tauri-plugin-vector-db/src/state.rs
Normal file
@ -0,0 +1,17 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct VectorDBState {
|
||||
pub base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl VectorDBState {
|
||||
pub fn new() -> Self {
|
||||
// Default vector db path: /Jan/data/db
|
||||
let mut base = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
base.push("Jan");
|
||||
base.push("data");
|
||||
base.push("db");
|
||||
std::fs::create_dir_all(&base).ok();
|
||||
Self { base_dir: base }
|
||||
}
|
||||
}
|
||||
27
src-tauri/plugins/tauri-plugin-vector-db/src/utils.rs
Normal file
27
src-tauri/plugins/tauri-plugin-vector-db/src/utils.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use crate::VectorDBError;
|
||||
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> Result<f32, VectorDBError> {
|
||||
if a.len() != b.len() {
|
||||
return Err(VectorDBError::InvalidInput(
|
||||
"Vector dimensions don't match".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
|
||||
let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if mag_a == 0.0 || mag_b == 0.0 { return Ok(0.0); }
|
||||
Ok(dot / (mag_a * mag_b))
|
||||
}
|
||||
|
||||
pub fn to_le_bytes_vec(v: &[f32]) -> Vec<u8> {
|
||||
v.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>()
|
||||
}
|
||||
|
||||
pub fn from_le_bytes_vec(bytes: &[u8]) -> Vec<f32> {
|
||||
bytes
|
||||
.chunks_exact(4)
|
||||
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
.collect::<Vec<f32>>()
|
||||
}
|
||||
|
||||
15
src-tauri/plugins/tauri-plugin-vector-db/tsconfig.json
Normal file
15
src-tauri/plugins/tauri-plugin-vector-db/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
||||
@ -19,10 +19,7 @@ pub fn get_app_configurations<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Ap
|
||||
let default_data_folder = default_data_folder_path(app_handle.clone());
|
||||
|
||||
if !configuration_file.exists() {
|
||||
log::info!(
|
||||
"App config not found, creating default config at {:?}",
|
||||
configuration_file
|
||||
);
|
||||
log::info!("App config not found, creating default config at {configuration_file:?}");
|
||||
|
||||
app_default_configuration.data_folder = default_data_folder;
|
||||
|
||||
@ -30,7 +27,7 @@ pub fn get_app_configurations<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Ap
|
||||
&configuration_file,
|
||||
serde_json::to_string(&app_default_configuration).unwrap(),
|
||||
) {
|
||||
log::error!("Failed to create default config: {}", err);
|
||||
log::error!("Failed to create default config: {err}");
|
||||
}
|
||||
|
||||
return app_default_configuration;
|
||||
@ -40,18 +37,12 @@ pub fn get_app_configurations<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Ap
|
||||
Ok(content) => match serde_json::from_str::<AppConfiguration>(&content) {
|
||||
Ok(app_configurations) => app_configurations,
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Failed to parse app config, returning default config instead. Error: {}",
|
||||
err
|
||||
);
|
||||
log::error!("Failed to parse app config, returning default config instead. Error: {err}");
|
||||
app_default_configuration
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Failed to read app config, returning default config instead. Error: {}",
|
||||
err
|
||||
);
|
||||
log::error!("Failed to read app config, returning default config instead. Error: {err}");
|
||||
app_default_configuration
|
||||
}
|
||||
}
|
||||
@ -63,10 +54,7 @@ pub fn update_app_configuration<R: Runtime>(
|
||||
configuration: AppConfiguration,
|
||||
) -> Result<(), String> {
|
||||
let configuration_file = get_configuration_file_path(app_handle);
|
||||
log::info!(
|
||||
"update_app_configuration, configuration_file: {:?}",
|
||||
configuration_file
|
||||
);
|
||||
log::info!("update_app_configuration, configuration_file: {configuration_file:?}");
|
||||
|
||||
fs::write(
|
||||
configuration_file,
|
||||
@ -95,8 +83,7 @@ pub fn get_jan_data_folder_path<R: Runtime>(app_handle: tauri::AppHandle<R>) ->
|
||||
pub fn get_configuration_file_path<R: Runtime>(app_handle: tauri::AppHandle<R>) -> PathBuf {
|
||||
let app_path = app_handle.path().app_data_dir().unwrap_or_else(|err| {
|
||||
log::error!(
|
||||
"Failed to get app data directory: {}. Using home directory instead.",
|
||||
err
|
||||
"Failed to get app data directory: {err}. Using home directory instead."
|
||||
);
|
||||
|
||||
let home_dir = std::env::var(if cfg!(target_os = "windows") {
|
||||
@ -130,9 +117,9 @@ pub fn get_configuration_file_path<R: Runtime>(app_handle: tauri::AppHandle<R>)
|
||||
.join(package_name);
|
||||
|
||||
if old_data_dir.exists() {
|
||||
return old_data_dir.join(CONFIGURATION_FILE_NAME);
|
||||
old_data_dir.join(CONFIGURATION_FILE_NAME)
|
||||
} else {
|
||||
return app_path.join(CONFIGURATION_FILE_NAME);
|
||||
app_path.join(CONFIGURATION_FILE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,7 +143,7 @@ pub fn default_data_folder_path<R: Runtime>(app_handle: tauri::AppHandle<R>) ->
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_user_home_path<R: Runtime>(app: AppHandle<R>) -> String {
|
||||
return get_app_configurations(app.clone()).data_folder;
|
||||
get_app_configurations(app.clone()).data_folder
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@ -171,16 +158,12 @@ pub fn change_app_data_folder<R: Runtime>(
|
||||
// Create the new data folder if it doesn't exist
|
||||
if !new_data_folder_path.exists() {
|
||||
fs::create_dir_all(&new_data_folder_path)
|
||||
.map_err(|e| format!("Failed to create new data folder: {}", e))?;
|
||||
.map_err(|e| format!("Failed to create new data folder: {e}"))?;
|
||||
}
|
||||
|
||||
// Copy all files from the old folder to the new one
|
||||
if current_data_folder.exists() {
|
||||
log::info!(
|
||||
"Copying data from {:?} to {:?}",
|
||||
current_data_folder,
|
||||
new_data_folder_path
|
||||
);
|
||||
log::info!("Copying data from {current_data_folder:?} to {new_data_folder_path:?}");
|
||||
|
||||
// Check if this is a parent directory to avoid infinite recursion
|
||||
if new_data_folder_path.starts_with(¤t_data_folder) {
|
||||
@ -193,7 +176,7 @@ pub fn change_app_data_folder<R: Runtime>(
|
||||
&new_data_folder_path,
|
||||
&[".uvx", ".npx"],
|
||||
)
|
||||
.map_err(|e| format!("Failed to copy data to new folder: {}", e))?;
|
||||
.map_err(|e| format!("Failed to copy data to new folder: {e}"))?;
|
||||
} else {
|
||||
log::info!("Current data folder does not exist, nothing to copy");
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ pub async fn download_files<R: Runtime>(
|
||||
{
|
||||
let mut download_manager = state.download_manager.lock().await;
|
||||
if download_manager.cancel_tokens.contains_key(task_id) {
|
||||
return Err(format!("task_id {} exists", task_id));
|
||||
return Err(format!("task_id {task_id} exists"));
|
||||
}
|
||||
download_manager
|
||||
.cancel_tokens
|
||||
@ -60,9 +60,9 @@ pub async fn cancel_download_task(state: State<'_, AppState>, task_id: &str) ->
|
||||
let mut download_manager = state.download_manager.lock().await;
|
||||
if let Some(token) = download_manager.cancel_tokens.remove(task_id) {
|
||||
token.cancel();
|
||||
log::info!("Cancelled download task: {}", task_id);
|
||||
log::info!("Cancelled download task: {task_id}");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("No download task: {}", task_id))
|
||||
Err(format!("No download task: {task_id}"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ use url::Url;
|
||||
// ===== UTILITY FUNCTIONS =====
|
||||
|
||||
pub fn err_to_string<E: std::fmt::Display>(e: E) -> String {
|
||||
format!("Error: {}", e)
|
||||
format!("Error: {e}")
|
||||
}
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ async fn validate_downloaded_file(
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
log::info!("Starting validation for model: {}", model_id);
|
||||
log::info!("Starting validation for model: {model_id}");
|
||||
|
||||
// Validate size if provided (fast check first)
|
||||
if let Some(expected_size) = &item.size {
|
||||
@ -73,8 +73,7 @@ async fn validate_downloaded_file(
|
||||
actual_size
|
||||
);
|
||||
return Err(format!(
|
||||
"Size verification failed. Expected {} bytes but got {} bytes.",
|
||||
expected_size, actual_size
|
||||
"Size verification failed. Expected {expected_size} bytes but got {actual_size} bytes."
|
||||
));
|
||||
}
|
||||
|
||||
@ -90,7 +89,7 @@ async fn validate_downloaded_file(
|
||||
save_path.display(),
|
||||
e
|
||||
);
|
||||
return Err(format!("Failed to verify file size: {}", e));
|
||||
return Err(format!("Failed to verify file size: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -115,9 +114,7 @@ async fn validate_downloaded_file(
|
||||
computed_sha256
|
||||
);
|
||||
|
||||
return Err(format!(
|
||||
"Hash verification failed. The downloaded file is corrupted or has been tampered with."
|
||||
));
|
||||
return Err("Hash verification failed. The downloaded file is corrupted or has been tampered with.".to_string());
|
||||
}
|
||||
|
||||
log::info!("Hash verification successful for {}", item.url);
|
||||
@ -128,7 +125,7 @@ async fn validate_downloaded_file(
|
||||
save_path.display(),
|
||||
e
|
||||
);
|
||||
return Err(format!("Failed to verify file integrity: {}", e));
|
||||
return Err(format!("Failed to verify file integrity: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -140,14 +137,14 @@ async fn validate_downloaded_file(
|
||||
pub fn validate_proxy_config(config: &ProxyConfig) -> Result<(), String> {
|
||||
// Validate proxy URL format
|
||||
if let Err(e) = Url::parse(&config.url) {
|
||||
return Err(format!("Invalid proxy URL '{}': {}", config.url, e));
|
||||
return Err(format!("Invalid proxy URL '{}': {e}", config.url));
|
||||
}
|
||||
|
||||
// Check if proxy URL has valid scheme
|
||||
let url = Url::parse(&config.url).unwrap(); // Safe to unwrap as we just validated it
|
||||
match url.scheme() {
|
||||
"http" | "https" | "socks4" | "socks5" => {}
|
||||
scheme => return Err(format!("Unsupported proxy scheme: {}", scheme)),
|
||||
scheme => return Err(format!("Unsupported proxy scheme: {scheme}")),
|
||||
}
|
||||
|
||||
// Validate authentication credentials
|
||||
@ -167,7 +164,7 @@ pub fn validate_proxy_config(config: &ProxyConfig) -> Result<(), String> {
|
||||
}
|
||||
// Basic validation for wildcard patterns
|
||||
if entry.starts_with("*.") && entry.len() < 3 {
|
||||
return Err(format!("Invalid wildcard pattern: {}", entry));
|
||||
return Err(format!("Invalid wildcard pattern: {entry}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -214,8 +211,7 @@ pub fn should_bypass_proxy(url: &str, no_proxy: &[String]) -> bool {
|
||||
}
|
||||
|
||||
// Simple wildcard matching
|
||||
if entry.starts_with("*.") {
|
||||
let domain = &entry[2..];
|
||||
if let Some(domain) = entry.strip_prefix("*.") {
|
||||
if host.ends_with(domain) {
|
||||
return true;
|
||||
}
|
||||
@ -305,7 +301,7 @@ pub async fn _download_files_internal(
|
||||
resume: bool,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Start download task: {}", task_id);
|
||||
log::info!("Start download task: {task_id}");
|
||||
|
||||
let header_map = _convert_headers(headers).map_err(err_to_string)?;
|
||||
|
||||
@ -320,9 +316,9 @@ pub async fn _download_files_internal(
|
||||
}
|
||||
|
||||
let total_size: u64 = file_sizes.values().sum();
|
||||
log::info!("Total download size: {}", total_size);
|
||||
log::info!("Total download size: {total_size}");
|
||||
|
||||
let evt_name = format!("download-{}", task_id);
|
||||
let evt_name = format!("download-{task_id}");
|
||||
|
||||
// Create progress tracker
|
||||
let progress_tracker = ProgressTracker::new(items, file_sizes.clone());
|
||||
@ -352,7 +348,7 @@ pub async fn _download_files_internal(
|
||||
let cancel_token_clone = cancel_token.clone();
|
||||
let evt_name_clone = evt_name.clone();
|
||||
let progress_tracker_clone = progress_tracker.clone();
|
||||
let file_id = format!("{}-{}", task_id, index);
|
||||
let file_id = format!("{task_id}-{index}");
|
||||
let file_size = file_sizes.get(&item.url).copied().unwrap_or(0);
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
@ -377,7 +373,7 @@ pub async fn _download_files_internal(
|
||||
// Wait for all downloads to complete
|
||||
let mut validation_tasks = Vec::new();
|
||||
for (task, item) in download_tasks.into_iter().zip(items.iter()) {
|
||||
let result = task.await.map_err(|e| format!("Task join error: {}", e))?;
|
||||
let result = task.await.map_err(|e| format!("Task join error: {e}"))?;
|
||||
|
||||
match result {
|
||||
Ok(downloaded_path) => {
|
||||
@ -399,7 +395,7 @@ pub async fn _download_files_internal(
|
||||
for (validation_task, save_path, _item) in validation_tasks {
|
||||
let validation_result = validation_task
|
||||
.await
|
||||
.map_err(|e| format!("Validation task join error: {}", e))?;
|
||||
.map_err(|e| format!("Validation task join error: {e}"))?;
|
||||
|
||||
if let Err(validation_error) = validation_result {
|
||||
// Clean up the file if validation fails
|
||||
@ -448,7 +444,7 @@ async fn download_single_file(
|
||||
if current_extension.is_empty() {
|
||||
ext.to_string()
|
||||
} else {
|
||||
format!("{}.{}", current_extension, ext)
|
||||
format!("{current_extension}.{ext}")
|
||||
}
|
||||
};
|
||||
let tmp_save_path = save_path.with_extension(append_extension("tmp"));
|
||||
@ -469,8 +465,8 @@ async fn download_single_file(
|
||||
let decoded_url = url::Url::parse(&item.url)
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_else(|_| item.url.clone());
|
||||
log::info!("Started downloading: {}", decoded_url);
|
||||
let client = _get_client_for_item(item, &header_map).map_err(err_to_string)?;
|
||||
log::info!("Started downloading: {decoded_url}");
|
||||
let client = _get_client_for_item(item, header_map).map_err(err_to_string)?;
|
||||
let mut download_delta = 0u64;
|
||||
let mut initial_progress = 0u64;
|
||||
|
||||
@ -503,7 +499,7 @@ async fn download_single_file(
|
||||
}
|
||||
Err(e) => {
|
||||
// fallback to normal download
|
||||
log::warn!("Failed to resume download: {}", e);
|
||||
log::warn!("Failed to resume download: {e}");
|
||||
should_resume = false;
|
||||
_get_maybe_resume(&client, &item.url, 0).await?
|
||||
}
|
||||
@ -592,7 +588,7 @@ async fn download_single_file(
|
||||
let decoded_url = url::Url::parse(&item.url)
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_else(|_| item.url.clone());
|
||||
log::info!("Finished downloading: {}", decoded_url);
|
||||
log::info!("Finished downloading: {decoded_url}");
|
||||
Ok(save_path.to_path_buf())
|
||||
}
|
||||
|
||||
@ -606,7 +602,7 @@ pub async fn _get_maybe_resume(
|
||||
if start_bytes > 0 {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("Range", format!("bytes={}-", start_bytes))
|
||||
.header("Range", format!("bytes={start_bytes}-"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err_to_string)?;
|
||||
|
||||
@ -13,41 +13,51 @@ pub fn get_jan_extensions_path<R: Runtime>(app_handle: tauri::AppHandle<R>) -> P
|
||||
#[tauri::command]
|
||||
pub fn install_extensions<R: Runtime>(app: AppHandle<R>) {
|
||||
if let Err(err) = setup::install_extensions(app, true) {
|
||||
log::error!("Failed to install extensions: {}", err);
|
||||
log::error!("Failed to install extensions: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_active_extensions<R: Runtime>(app: AppHandle<R>) -> Vec<serde_json::Value> {
|
||||
let mut path = get_jan_extensions_path(app);
|
||||
path.push("extensions.json");
|
||||
log::info!("get jan extensions, path: {:?}", path);
|
||||
// On mobile platforms, extensions are pre-bundled in the frontend
|
||||
// Return empty array so frontend's MobileCoreService handles it
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(path);
|
||||
let contents: Vec<serde_json::Value> = match contents {
|
||||
Ok(data) => match serde_json::from_str::<Vec<serde_json::Value>>(&data) {
|
||||
Ok(exts) => exts
|
||||
.into_iter()
|
||||
.map(|ext| {
|
||||
serde_json::json!({
|
||||
"url": ext["url"],
|
||||
"name": ext["name"],
|
||||
"productName": ext["productName"],
|
||||
"active": ext["_active"],
|
||||
"description": ext["description"],
|
||||
"version": ext["version"]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
let mut path = get_jan_extensions_path(app);
|
||||
path.push("extensions.json");
|
||||
log::info!("get jan extensions, path: {path:?}");
|
||||
|
||||
let contents = fs::read_to_string(path);
|
||||
let contents: Vec<serde_json::Value> = match contents {
|
||||
Ok(data) => match serde_json::from_str::<Vec<serde_json::Value>>(&data) {
|
||||
Ok(exts) => exts
|
||||
.into_iter()
|
||||
.map(|ext| {
|
||||
serde_json::json!({
|
||||
"url": ext["url"],
|
||||
"name": ext["name"],
|
||||
"productName": ext["productName"],
|
||||
"active": ext["_active"],
|
||||
"description": ext["description"],
|
||||
"version": ext["version"]
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
.collect(),
|
||||
Err(error) => {
|
||||
log::error!("Failed to parse extensions.json: {error}");
|
||||
vec![]
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
log::error!("Failed to parse extensions.json: {}", error);
|
||||
log::error!("Failed to read extensions.json: {error}");
|
||||
vec![]
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
log::error!("Failed to read extensions.json: {}", error);
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
return contents;
|
||||
};
|
||||
return contents;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ fn test_rm() {
|
||||
let app = mock_app();
|
||||
let path = "test_rm_dir";
|
||||
fs::create_dir_all(get_jan_data_folder_path(app.handle().clone()).join(path)).unwrap();
|
||||
let args = vec![format!("file://{}", path).to_string()];
|
||||
let args = vec![format!("file://{path}").to_string()];
|
||||
let result = rm(app.handle().clone(), args);
|
||||
assert!(result.is_ok());
|
||||
assert!(!get_jan_data_folder_path(app.handle().clone())
|
||||
@ -21,7 +21,7 @@ fn test_rm() {
|
||||
fn test_mkdir() {
|
||||
let app = mock_app();
|
||||
let path = "test_mkdir_dir";
|
||||
let args = vec![format!("file://{}", path).to_string()];
|
||||
let args = vec![format!("file://{path}").to_string()];
|
||||
let result = mkdir(app.handle().clone(), args);
|
||||
assert!(result.is_ok());
|
||||
assert!(get_jan_data_folder_path(app.handle().clone())
|
||||
@ -39,7 +39,7 @@ fn test_join_path() {
|
||||
assert_eq!(
|
||||
result,
|
||||
get_jan_data_folder_path(app.handle().clone())
|
||||
.join(&format!("test_dir{}test_file", std::path::MAIN_SEPARATOR))
|
||||
.join(format!("test_dir{}test_file", std::path::MAIN_SEPARATOR))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
);
|
||||
|
||||
@ -30,28 +30,28 @@ pub async fn activate_mcp_server<R: Runtime>(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn deactivate_mcp_server(state: State<'_, AppState>, name: String) -> Result<(), String> {
|
||||
log::info!("Deactivating MCP server: {}", name);
|
||||
log::info!("Deactivating MCP server: {name}");
|
||||
|
||||
// First, mark server as manually deactivated to prevent restart
|
||||
// Remove from active servers list to prevent restart
|
||||
{
|
||||
let mut active_servers = state.mcp_active_servers.lock().await;
|
||||
active_servers.remove(&name);
|
||||
log::info!("Removed MCP server {} from active servers list", name);
|
||||
log::info!("Removed MCP server {name} from active servers list");
|
||||
}
|
||||
|
||||
// Mark as not successfully connected to prevent restart logic
|
||||
{
|
||||
let mut connected = state.mcp_successfully_connected.lock().await;
|
||||
connected.insert(name.clone(), false);
|
||||
log::info!("Marked MCP server {} as not successfully connected", name);
|
||||
log::info!("Marked MCP server {name} as not successfully connected");
|
||||
}
|
||||
|
||||
// Reset restart count
|
||||
{
|
||||
let mut counts = state.mcp_restart_counts.lock().await;
|
||||
counts.remove(&name);
|
||||
log::info!("Reset restart count for MCP server {}", name);
|
||||
log::info!("Reset restart count for MCP server {name}");
|
||||
}
|
||||
|
||||
// Now remove and stop the server
|
||||
@ -60,7 +60,7 @@ pub async fn deactivate_mcp_server(state: State<'_, AppState>, name: String) ->
|
||||
|
||||
let service = servers_map
|
||||
.remove(&name)
|
||||
.ok_or_else(|| format!("Server {} not found", name))?;
|
||||
.ok_or_else(|| format!("Server {name} not found"))?;
|
||||
|
||||
// Release the lock before calling cancel
|
||||
drop(servers_map);
|
||||
@ -89,7 +89,7 @@ pub async fn restart_mcp_servers<R: Runtime>(app: AppHandle<R>, state: State<'_,
|
||||
restart_active_mcp_servers(&app, servers).await?;
|
||||
|
||||
app.emit("mcp-update", "MCP servers updated")
|
||||
.map_err(|e| format!("Failed to emit event: {}", e))?;
|
||||
.map_err(|e| format!("Failed to emit event: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -110,9 +110,7 @@ pub async fn reset_mcp_restart_count(
|
||||
let old_count = *count;
|
||||
*count = 0;
|
||||
log::info!(
|
||||
"MCP server {} restart count reset from {} to 0.",
|
||||
server_name,
|
||||
old_count
|
||||
"MCP server {server_name} restart count reset from {old_count} to 0."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@ -219,7 +217,7 @@ pub async fn call_tool(
|
||||
continue; // Tool not found in this server, try next
|
||||
}
|
||||
|
||||
println!("Found tool {} in server", tool_name);
|
||||
println!("Found tool {tool_name} in server");
|
||||
|
||||
// Call the tool with timeout and cancellation support
|
||||
let tool_call = service.call_tool(CallToolRequestParam {
|
||||
@ -234,22 +232,20 @@ pub async fn call_tool(
|
||||
match result {
|
||||
Ok(call_result) => call_result.map_err(|e| e.to_string()),
|
||||
Err(_) => Err(format!(
|
||||
"Tool call '{}' timed out after {} seconds",
|
||||
tool_name,
|
||||
"Tool call '{tool_name}' timed out after {} seconds",
|
||||
MCP_TOOL_CALL_TIMEOUT.as_secs()
|
||||
)),
|
||||
}
|
||||
}
|
||||
_ = cancel_rx => {
|
||||
Err(format!("Tool call '{}' was cancelled", tool_name))
|
||||
Err(format!("Tool call '{tool_name}' was cancelled"))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match timeout(MCP_TOOL_CALL_TIMEOUT, tool_call).await {
|
||||
Ok(call_result) => call_result.map_err(|e| e.to_string()),
|
||||
Err(_) => Err(format!(
|
||||
"Tool call '{}' timed out after {} seconds",
|
||||
tool_name,
|
||||
"Tool call '{tool_name}' timed out after {} seconds",
|
||||
MCP_TOOL_CALL_TIMEOUT.as_secs()
|
||||
)),
|
||||
}
|
||||
@ -264,7 +260,7 @@ pub async fn call_tool(
|
||||
return result;
|
||||
}
|
||||
|
||||
Err(format!("Tool {} not found", tool_name))
|
||||
Err(format!("Tool {tool_name} not found"))
|
||||
}
|
||||
|
||||
/// Cancels a running tool call by its cancellation token
|
||||
@ -285,10 +281,10 @@ pub async fn cancel_tool_call(
|
||||
if let Some(cancel_tx) = cancellations.remove(&cancellation_token) {
|
||||
// Send cancellation signal - ignore if receiver is already dropped
|
||||
let _ = cancel_tx.send(());
|
||||
println!("Tool call with token {} cancelled", cancellation_token);
|
||||
println!("Tool call with token {cancellation_token} cancelled");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Cancellation token {} not found", cancellation_token))
|
||||
Err(format!("Cancellation token {cancellation_token} not found"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -301,7 +297,7 @@ pub async fn get_mcp_configs<R: Runtime>(app: AppHandle<R>) -> Result<String, St
|
||||
if !path.exists() {
|
||||
log::info!("mcp_config.json not found, creating default empty config");
|
||||
fs::write(&path, DEFAULT_MCP_CONFIG)
|
||||
.map_err(|e| format!("Failed to create default MCP config: {}", e))?;
|
||||
.map_err(|e| format!("Failed to create default MCP config: {e}"))?;
|
||||
}
|
||||
|
||||
fs::read_to_string(path).map_err(|e| e.to_string())
|
||||
@ -311,7 +307,7 @@ pub async fn get_mcp_configs<R: Runtime>(app: AppHandle<R>) -> Result<String, St
|
||||
pub async fn save_mcp_configs<R: Runtime>(app: AppHandle<R>, configs: String) -> Result<(), String> {
|
||||
let mut path = get_jan_data_folder_path(app);
|
||||
path.push("mcp_config.json");
|
||||
log::info!("save mcp configs, path: {:?}", path);
|
||||
log::info!("save mcp configs, path: {path:?}");
|
||||
|
||||
fs::write(path, configs).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@ -56,22 +56,13 @@ pub fn calculate_exponential_backoff_delay(attempt: u32) -> u64 {
|
||||
let hash = hasher.finish();
|
||||
|
||||
// Convert hash to jitter value in range [-jitter_range, +jitter_range]
|
||||
let jitter_offset = (hash % (jitter_range * 2)) as i64 - jitter_range as i64;
|
||||
jitter_offset
|
||||
(hash % (jitter_range * 2)) as i64 - jitter_range as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Apply jitter while ensuring delay stays positive and within bounds
|
||||
let final_delay = cmp::max(
|
||||
100, // Minimum 100ms delay
|
||||
cmp::min(
|
||||
MCP_MAX_RESTART_DELAY_MS,
|
||||
(capped_delay as i64 + jitter) as u64,
|
||||
),
|
||||
);
|
||||
|
||||
final_delay
|
||||
((capped_delay as i64 + jitter) as u64).clamp(100, MCP_MAX_RESTART_DELAY_MS)
|
||||
}
|
||||
|
||||
/// Runs MCP commands by reading configuration from a JSON file and initializing servers
|
||||
@ -135,9 +126,7 @@ pub async fn run_mcp_commands<R: Runtime>(
|
||||
// If initial startup failed, we still want to continue with other servers
|
||||
if let Err(e) = &result {
|
||||
log::error!(
|
||||
"Initial startup failed for MCP server {}: {}",
|
||||
name_clone,
|
||||
e
|
||||
"Initial startup failed for MCP server {name_clone}: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
@ -155,25 +144,23 @@ pub async fn run_mcp_commands<R: Runtime>(
|
||||
match handle.await {
|
||||
Ok((name, result)) => match result {
|
||||
Ok(_) => {
|
||||
log::info!("MCP server {} initialized successfully", name);
|
||||
log::info!("MCP server {name} initialized successfully");
|
||||
successful_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("MCP server {} failed to initialize: {}", name, e);
|
||||
log::error!("MCP server {name} failed to initialize: {e}");
|
||||
failed_count += 1;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to join startup task: {}", e);
|
||||
log::error!("Failed to join startup task: {e}");
|
||||
failed_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"MCP server initialization complete: {} successful, {} failed",
|
||||
successful_count,
|
||||
failed_count
|
||||
"MCP server initialization complete: {successful_count} successful, {failed_count} failed"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@ -184,7 +171,7 @@ pub async fn monitor_mcp_server_handle(
|
||||
servers_state: SharedMcpServers,
|
||||
name: String,
|
||||
) -> Option<rmcp::service::QuitReason> {
|
||||
log::info!("Monitoring MCP server {} health", name);
|
||||
log::info!("Monitoring MCP server {name} health");
|
||||
|
||||
// Monitor server health with periodic checks
|
||||
loop {
|
||||
@ -202,17 +189,17 @@ pub async fn monitor_mcp_server_handle(
|
||||
true
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("MCP server {} health check failed: {}", name, e);
|
||||
log::warn!("MCP server {name} health check failed: {e}");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("MCP server {} health check timed out", name);
|
||||
log::warn!("MCP server {name} health check timed out");
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Server was removed from HashMap (e.g., by deactivate_mcp_server)
|
||||
log::info!("MCP server {} no longer in running services", name);
|
||||
log::info!("MCP server {name} no longer in running services");
|
||||
return Some(rmcp::service::QuitReason::Closed);
|
||||
}
|
||||
};
|
||||
@ -220,8 +207,7 @@ pub async fn monitor_mcp_server_handle(
|
||||
if !health_check_result {
|
||||
// Server failed health check - remove it and return
|
||||
log::error!(
|
||||
"MCP server {} failed health check, removing from active servers",
|
||||
name
|
||||
"MCP server {name} failed health check, removing from active servers"
|
||||
);
|
||||
let mut servers = servers_state.lock().await;
|
||||
if let Some(service) = servers.remove(&name) {
|
||||
@ -262,7 +248,7 @@ pub async fn start_mcp_server_with_restart<R: Runtime>(
|
||||
let max_restarts = max_restarts.unwrap_or(5);
|
||||
|
||||
// Try the first start attempt and return its result
|
||||
log::info!("Starting MCP server {} (Initial attempt)", name);
|
||||
log::info!("Starting MCP server {name} (Initial attempt)");
|
||||
let first_start_result = schedule_mcp_start_task(
|
||||
app.clone(),
|
||||
servers_state.clone(),
|
||||
@ -273,7 +259,7 @@ pub async fn start_mcp_server_with_restart<R: Runtime>(
|
||||
|
||||
match first_start_result {
|
||||
Ok(_) => {
|
||||
log::info!("MCP server {} started successfully on first attempt", name);
|
||||
log::info!("MCP server {name} started successfully on first attempt");
|
||||
reset_restart_count(&restart_counts, &name).await;
|
||||
|
||||
// Check if server was marked as successfully connected (passed verification)
|
||||
@ -298,18 +284,15 @@ pub async fn start_mcp_server_with_restart<R: Runtime>(
|
||||
Ok(())
|
||||
} else {
|
||||
// Server failed verification, don't monitor for restarts
|
||||
log::error!("MCP server {} failed verification after startup", name);
|
||||
log::error!("MCP server {name} failed verification after startup");
|
||||
Err(format!(
|
||||
"MCP server {} failed verification after startup",
|
||||
name
|
||||
"MCP server {name} failed verification after startup"
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to start MCP server {} on first attempt: {}",
|
||||
name,
|
||||
e
|
||||
"Failed to start MCP server {name} on first attempt: {e}"
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
@ -336,9 +319,7 @@ pub async fn start_restart_loop<R: Runtime>(
|
||||
|
||||
if current_restart_count > max_restarts {
|
||||
log::error!(
|
||||
"MCP server {} reached maximum restart attempts ({}). Giving up.",
|
||||
name,
|
||||
max_restarts
|
||||
"MCP server {name} reached maximum restart attempts ({max_restarts}). Giving up."
|
||||
);
|
||||
if let Err(e) = app.emit(
|
||||
"mcp_max_restarts_reached",
|
||||
@ -353,19 +334,13 @@ pub async fn start_restart_loop<R: Runtime>(
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Restarting MCP server {} (Attempt {}/{})",
|
||||
name,
|
||||
current_restart_count,
|
||||
max_restarts
|
||||
"Restarting MCP server {name} (Attempt {current_restart_count}/{max_restarts})"
|
||||
);
|
||||
|
||||
// Calculate exponential backoff delay
|
||||
let delay_ms = calculate_exponential_backoff_delay(current_restart_count);
|
||||
log::info!(
|
||||
"Waiting {}ms before restart attempt {} for MCP server {}",
|
||||
delay_ms,
|
||||
current_restart_count,
|
||||
name
|
||||
"Waiting {delay_ms}ms before restart attempt {current_restart_count} for MCP server {name}"
|
||||
);
|
||||
sleep(Duration::from_millis(delay_ms)).await;
|
||||
|
||||
@ -380,7 +355,7 @@ pub async fn start_restart_loop<R: Runtime>(
|
||||
|
||||
match start_result {
|
||||
Ok(_) => {
|
||||
log::info!("MCP server {} restarted successfully.", name);
|
||||
log::info!("MCP server {name} restarted successfully.");
|
||||
|
||||
// Check if server passed verification (was marked as successfully connected)
|
||||
let passed_verification = {
|
||||
@ -390,8 +365,7 @@ pub async fn start_restart_loop<R: Runtime>(
|
||||
|
||||
if !passed_verification {
|
||||
log::error!(
|
||||
"MCP server {} failed verification after restart - stopping permanently",
|
||||
name
|
||||
"MCP server {name} failed verification after restart - stopping permanently"
|
||||
);
|
||||
break;
|
||||
}
|
||||
@ -402,9 +376,7 @@ pub async fn start_restart_loop<R: Runtime>(
|
||||
if let Some(count) = counts.get_mut(&name) {
|
||||
if *count > 0 {
|
||||
log::info!(
|
||||
"MCP server {} restarted successfully, resetting restart count from {} to 0.",
|
||||
name,
|
||||
*count
|
||||
"MCP server {name} restarted successfully, resetting restart count from {count} to 0."
|
||||
);
|
||||
*count = 0;
|
||||
}
|
||||
@ -415,7 +387,7 @@ pub async fn start_restart_loop<R: Runtime>(
|
||||
let quit_reason =
|
||||
monitor_mcp_server_handle(servers_state.clone(), name.clone()).await;
|
||||
|
||||
log::info!("MCP server {} quit with reason: {:?}", name, quit_reason);
|
||||
log::info!("MCP server {name} quit with reason: {quit_reason:?}");
|
||||
|
||||
// Check if server was marked as successfully connected
|
||||
let was_connected = {
|
||||
@ -426,8 +398,7 @@ pub async fn start_restart_loop<R: Runtime>(
|
||||
// Only continue restart loop if server was previously connected
|
||||
if !was_connected {
|
||||
log::error!(
|
||||
"MCP server {} failed before establishing successful connection - stopping permanently",
|
||||
name
|
||||
"MCP server {name} failed before establishing successful connection - stopping permanently"
|
||||
);
|
||||
break;
|
||||
}
|
||||
@ -435,11 +406,11 @@ pub async fn start_restart_loop<R: Runtime>(
|
||||
// Determine if we should restart based on quit reason
|
||||
let should_restart = match quit_reason {
|
||||
Some(reason) => {
|
||||
log::warn!("MCP server {} terminated unexpectedly: {:?}", name, reason);
|
||||
log::warn!("MCP server {name} terminated unexpectedly: {reason:?}");
|
||||
true
|
||||
}
|
||||
None => {
|
||||
log::info!("MCP server {} was manually stopped - not restarting", name);
|
||||
log::info!("MCP server {name} was manually stopped - not restarting");
|
||||
false
|
||||
}
|
||||
};
|
||||
@ -450,7 +421,7 @@ pub async fn start_restart_loop<R: Runtime>(
|
||||
// Continue the loop for another restart attempt
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to restart MCP server {}: {}", name, e);
|
||||
log::error!("Failed to restart MCP server {name}: {e}");
|
||||
|
||||
// Check if server was marked as successfully connected before
|
||||
let was_connected = {
|
||||
@ -461,8 +432,7 @@ pub async fn start_restart_loop<R: Runtime>(
|
||||
// Only continue restart attempts if server was previously connected
|
||||
if !was_connected {
|
||||
log::error!(
|
||||
"MCP server {} failed restart and was never successfully connected - stopping permanently",
|
||||
name
|
||||
"MCP server {name} failed restart and was never successfully connected - stopping permanently"
|
||||
);
|
||||
break;
|
||||
}
|
||||
@ -526,10 +496,13 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
||||
client_info: Implementation {
|
||||
name: "Jan Streamable Client".to_string(),
|
||||
version: "0.0.1".to_string(),
|
||||
title: None,
|
||||
website_url: None,
|
||||
icons: None,
|
||||
},
|
||||
};
|
||||
let client = client_info.serve(transport).await.inspect_err(|e| {
|
||||
log::error!("client error: {:?}", e);
|
||||
log::error!("client error: {e:?}");
|
||||
});
|
||||
|
||||
match client {
|
||||
@ -545,12 +518,12 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
||||
let app_state = app.state::<AppState>();
|
||||
let mut connected = app_state.mcp_successfully_connected.lock().await;
|
||||
connected.insert(name.clone(), true);
|
||||
log::info!("Marked MCP server {} as successfully connected", name);
|
||||
log::info!("Marked MCP server {name} as successfully connected");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to connect to server: {}", e);
|
||||
return Err(format!("Failed to connect to server: {}", e));
|
||||
log::error!("Failed to connect to server: {e}");
|
||||
return Err(format!("Failed to connect to server: {e}"));
|
||||
}
|
||||
}
|
||||
} else if config_params.transport_type.as_deref() == Some("sse") && config_params.url.is_some()
|
||||
@ -587,8 +560,8 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("transport error: {:?}", e);
|
||||
format!("Failed to start SSE transport: {}", e)
|
||||
log::error!("transport error: {e:?}");
|
||||
format!("Failed to start SSE transport: {e}")
|
||||
})?;
|
||||
|
||||
let client_info = ClientInfo {
|
||||
@ -597,10 +570,13 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
||||
client_info: Implementation {
|
||||
name: "Jan SSE Client".to_string(),
|
||||
version: "0.0.1".to_string(),
|
||||
title: None,
|
||||
website_url: None,
|
||||
icons: None,
|
||||
},
|
||||
};
|
||||
let client = client_info.serve(transport).await.map_err(|e| {
|
||||
log::error!("client error: {:?}", e);
|
||||
log::error!("client error: {e:?}");
|
||||
e.to_string()
|
||||
});
|
||||
|
||||
@ -617,12 +593,12 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
||||
let app_state = app.state::<AppState>();
|
||||
let mut connected = app_state.mcp_successfully_connected.lock().await;
|
||||
connected.insert(name.clone(), true);
|
||||
log::info!("Marked MCP server {} as successfully connected", name);
|
||||
log::info!("Marked MCP server {name} as successfully connected");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to connect to server: {}", e);
|
||||
return Err(format!("Failed to connect to server: {}", e));
|
||||
log::error!("Failed to connect to server: {e}");
|
||||
return Err(format!("Failed to connect to server: {e}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -639,7 +615,7 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
||||
cache_dir.push(".npx");
|
||||
cmd = Command::new(bun_x_path.display().to_string());
|
||||
cmd.arg("x");
|
||||
cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string());
|
||||
cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap());
|
||||
}
|
||||
|
||||
let uv_path = if cfg!(windows) {
|
||||
@ -654,7 +630,7 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
||||
cmd = Command::new(uv_path);
|
||||
cmd.arg("tool");
|
||||
cmd.arg("run");
|
||||
cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap().to_string());
|
||||
cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap());
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@ -726,8 +702,7 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
||||
|
||||
if !server_still_running {
|
||||
return Err(format!(
|
||||
"MCP server {} quit immediately after starting",
|
||||
name
|
||||
"MCP server {name} quit immediately after starting"
|
||||
));
|
||||
}
|
||||
// Mark server as successfully connected (for restart policy)
|
||||
@ -735,7 +710,7 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
||||
let app_state = app.state::<AppState>();
|
||||
let mut connected = app_state.mcp_successfully_connected.lock().await;
|
||||
connected.insert(name.clone(), true);
|
||||
log::info!("Marked MCP server {} as successfully connected", name);
|
||||
log::info!("Marked MCP server {name} as successfully connected");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@ -792,7 +767,7 @@ pub async fn restart_active_mcp_servers<R: Runtime>(
|
||||
);
|
||||
|
||||
for (name, config) in active_servers.iter() {
|
||||
log::info!("Restarting MCP server: {}", name);
|
||||
log::info!("Restarting MCP server: {name}");
|
||||
|
||||
// Start server with restart monitoring - spawn async task
|
||||
let app_clone = app.clone();
|
||||
@ -891,9 +866,7 @@ pub async fn spawn_server_monitoring_task<R: Runtime>(
|
||||
monitor_mcp_server_handle(servers_clone.clone(), name_clone.clone()).await;
|
||||
|
||||
log::info!(
|
||||
"MCP server {} quit with reason: {:?}",
|
||||
name_clone,
|
||||
quit_reason
|
||||
"MCP server {name_clone} quit with reason: {quit_reason:?}"
|
||||
);
|
||||
|
||||
// Check if we should restart based on connection status and quit reason
|
||||
@ -928,8 +901,7 @@ pub async fn should_restart_server(
|
||||
// Only restart if server was previously connected
|
||||
if !was_connected {
|
||||
log::error!(
|
||||
"MCP server {} failed before establishing successful connection - stopping permanently",
|
||||
name
|
||||
"MCP server {name} failed before establishing successful connection - stopping permanently"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@ -937,11 +909,11 @@ pub async fn should_restart_server(
|
||||
// Determine if we should restart based on quit reason
|
||||
match quit_reason {
|
||||
Some(reason) => {
|
||||
log::warn!("MCP server {} terminated unexpectedly: {:?}", name, reason);
|
||||
log::warn!("MCP server {name} terminated unexpectedly: {reason:?}");
|
||||
true
|
||||
}
|
||||
None => {
|
||||
log::info!("MCP server {} was manually stopped - not restarting", name);
|
||||
log::info!("MCP server {name} was manually stopped - not restarting");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user