Merge pull request #6766 from menloresearch/feat/file-attachment
feat: file attachment
This commit is contained in:
commit
746dbc632b
@ -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'
|
||||
|
||||
@ -45,7 +45,7 @@ export default class JanProviderWeb extends AIEngine {
|
||||
// Verify Jan models capabilities in localStorage
|
||||
private validateJanModelsLocalStorage() {
|
||||
try {
|
||||
console.log("Validating Jan models in localStorage...")
|
||||
console.log('Validating Jan models in localStorage...')
|
||||
const storageKey = 'model-provider'
|
||||
const data = localStorage.getItem(storageKey)
|
||||
if (!data) return
|
||||
@ -60,9 +60,14 @@ export default class JanProviderWeb extends AIEngine {
|
||||
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)) {
|
||||
if (
|
||||
JSON.stringify(model.capabilities) !==
|
||||
JSON.stringify(JAN_MODEL_CAPABILITIES)
|
||||
) {
|
||||
hasInvalidModel = true
|
||||
console.log(`Found invalid Jan model: ${model.id}, clearing localStorage`)
|
||||
console.log(
|
||||
`Found invalid Jan model: ${model.id}, clearing localStorage`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
@ -79,9 +84,17 @@ export default class JanProviderWeb extends AIEngine {
|
||||
// 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 }))
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
state: { providers: [] },
|
||||
version: parsed.version || 3,
|
||||
})
|
||||
)
|
||||
}
|
||||
console.log('Cleared model-provider from localStorage due to invalid Jan capabilities')
|
||||
console.log(
|
||||
'Cleared model-provider from localStorage due to invalid Jan capabilities'
|
||||
)
|
||||
// Force a page reload to ensure clean state
|
||||
window.location.reload()
|
||||
}
|
||||
@ -159,6 +172,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 +207,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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -333,14 +333,12 @@ export default class llamacpp_extension extends AIEngine {
|
||||
)
|
||||
// Clear the invalid stored preference
|
||||
this.clearStoredBackendType()
|
||||
bestAvailableBackendString = await this.determineBestBackend(
|
||||
version_backends
|
||||
)
|
||||
bestAvailableBackendString =
|
||||
await this.determineBestBackend(version_backends)
|
||||
}
|
||||
} else {
|
||||
bestAvailableBackendString = await this.determineBestBackend(
|
||||
version_backends
|
||||
)
|
||||
bestAvailableBackendString =
|
||||
await this.determineBestBackend(version_backends)
|
||||
}
|
||||
|
||||
let settings = structuredClone(SETTINGS)
|
||||
@ -1530,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
|
||||
@ -1537,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[] = []
|
||||
@ -1638,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))
|
||||
@ -1677,6 +1699,7 @@ export default class llamacpp_extension extends AIEngine {
|
||||
libraryPath,
|
||||
args,
|
||||
envs,
|
||||
isEmbedding,
|
||||
}
|
||||
)
|
||||
return sInfo
|
||||
@ -2083,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()
|
||||
@ -2096,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()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -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.')
|
||||
}
|
||||
|
||||
|
||||
1801
src-tauri/Cargo.lock
generated
1801
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -63,7 +63,9 @@ log = "0.4"
|
||||
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,6 +79,8 @@ 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"
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -25,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",
|
||||
@ -62,4 +64,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
@ -496,6 +496,9 @@ 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| {
|
||||
@ -567,6 +570,9 @@ 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| {
|
||||
|
||||
@ -31,7 +31,9 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_llamacpp::init());
|
||||
.plugin(tauri_plugin_llamacpp::init())
|
||||
.plugin(tauri_plugin_vector_db::init())
|
||||
.plugin(tauri_plugin_rag::init());
|
||||
|
||||
#[cfg(feature = "deep-link")]
|
||||
{
|
||||
|
||||
@ -10,6 +10,7 @@ export const route = {
|
||||
model_providers: '/settings/providers',
|
||||
providers: '/settings/providers/$providerName',
|
||||
general: '/settings/general',
|
||||
attachments: '/settings/attachments',
|
||||
appearance: '/settings/appearance',
|
||||
privacy: '/settings/privacy',
|
||||
shortcuts: '/settings/shortcuts',
|
||||
|
||||
@ -21,6 +21,9 @@ import {
|
||||
IconCodeCircle2,
|
||||
IconPlayerStopFilled,
|
||||
IconX,
|
||||
IconPaperclip,
|
||||
IconLoader2,
|
||||
IconCheck,
|
||||
} from '@tabler/icons-react'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
import { useGeneralSetting } from '@/hooks/useGeneralSetting'
|
||||
@ -38,8 +41,19 @@ import { TokenCounter } from '@/components/TokenCounter'
|
||||
import { useMessages } from '@/hooks/useMessages'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { McpExtensionToolLoader } from './McpExtensionToolLoader'
|
||||
import { ExtensionTypeEnum, MCPExtension } from '@janhq/core'
|
||||
import { ExtensionTypeEnum, MCPExtension, fs, RAGExtension } from '@janhq/core'
|
||||
import { ExtensionManager } from '@/lib/extension'
|
||||
import { useAttachments } from '@/hooks/useAttachments'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import { PlatformFeatures } from '@/lib/platform/const'
|
||||
import { PlatformFeature } from '@/lib/platform/types'
|
||||
|
||||
import {
|
||||
Attachment,
|
||||
createImageAttachment,
|
||||
createDocumentAttachment,
|
||||
} from '@/types/attachment'
|
||||
|
||||
type ChatInputProps = {
|
||||
className?: string
|
||||
@ -91,29 +105,39 @@ const ChatInput = ({
|
||||
const [message, setMessage] = useState('')
|
||||
const [dropdownToolsAvailable, setDropdownToolsAvailable] = useState(false)
|
||||
const [tooltipToolsAvailable, setTooltipToolsAvailable] = useState(false)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<
|
||||
Array<{
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
base64: string
|
||||
dataUrl: string
|
||||
}>
|
||||
>([])
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [connectedServers, setConnectedServers] = useState<string[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [hasMmproj, setHasMmproj] = useState(false)
|
||||
const [hasActiveModels, setHasActiveModels] = useState(false)
|
||||
const attachmentsEnabled = useAttachments((s) => s.enabled)
|
||||
// Determine whether to show the Attach documents button (simple gating)
|
||||
const showAttachmentButton =
|
||||
attachmentsEnabled && PlatformFeatures[PlatformFeature.ATTACHMENTS]
|
||||
// Derived: any document currently processing (ingestion in progress)
|
||||
const ingestingDocs = attachments.some(
|
||||
(a) => a.type === 'document' && a.processing
|
||||
)
|
||||
const ingestingAny = attachments.some((a) => a.processing)
|
||||
|
||||
// Check for connected MCP servers
|
||||
useEffect(() => {
|
||||
const checkConnectedServers = async () => {
|
||||
try {
|
||||
const servers = await serviceHub.mcp().getConnectedServers()
|
||||
setConnectedServers(servers)
|
||||
// Only update state if the servers list has actually changed
|
||||
setConnectedServers((prev) => {
|
||||
if (JSON.stringify(prev) === JSON.stringify(servers)) {
|
||||
return prev
|
||||
}
|
||||
return servers
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get connected servers:', error)
|
||||
setConnectedServers([])
|
||||
setConnectedServers((prev) => {
|
||||
if (prev.length === 0) return prev
|
||||
return []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,10 +159,22 @@ const ChatInput = ({
|
||||
const hasMatchingActiveModel = activeModels.some(
|
||||
(model) => String(model) === selectedModel?.id
|
||||
)
|
||||
setHasActiveModels(activeModels.length > 0 && hasMatchingActiveModel)
|
||||
const newHasActiveModels =
|
||||
activeModels.length > 0 && hasMatchingActiveModel
|
||||
|
||||
// Only update state if the value has actually changed
|
||||
setHasActiveModels((prev) => {
|
||||
if (prev === newHasActiveModels) {
|
||||
return prev
|
||||
}
|
||||
return newHasActiveModels
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get active models:', error)
|
||||
setHasActiveModels(false)
|
||||
setHasActiveModels((prev) => {
|
||||
if (prev === false) return prev
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -184,18 +220,45 @@ const ChatInput = ({
|
||||
setMessage('Please select a model to start chatting.')
|
||||
return
|
||||
}
|
||||
if (!prompt.trim() && uploadedFiles.length === 0) {
|
||||
if (!prompt.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
setMessage('')
|
||||
|
||||
// Callback to update attachment processing state
|
||||
const updateAttachmentProcessing = (
|
||||
fileName: string,
|
||||
status: 'processing' | 'done' | 'error' | 'clear_docs' | 'clear_all'
|
||||
) => {
|
||||
if (status === 'clear_docs') {
|
||||
setAttachments((prev) => prev.filter((a) => a.type !== 'document'))
|
||||
return
|
||||
}
|
||||
if (status === 'clear_all') {
|
||||
setAttachments([])
|
||||
return
|
||||
}
|
||||
setAttachments((prev) =>
|
||||
prev.map((att) =>
|
||||
att.name === fileName
|
||||
? {
|
||||
...att,
|
||||
processing: status === 'processing',
|
||||
processed: status === 'done' ? true : att.processed,
|
||||
}
|
||||
: att
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
sendMessage(
|
||||
prompt,
|
||||
true,
|
||||
uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
||||
projectId
|
||||
attachments.length > 0 ? attachments : undefined,
|
||||
projectId,
|
||||
updateAttachmentProcessing
|
||||
)
|
||||
setUploadedFiles([])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -264,10 +327,160 @@ const ChatInput = ({
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleRemoveFile = (indexToRemove: number) => {
|
||||
setUploadedFiles((prev) =>
|
||||
prev.filter((_, index) => index !== indexToRemove)
|
||||
)
|
||||
const handleAttachDocsIngest = async () => {
|
||||
try {
|
||||
if (!attachmentsEnabled) {
|
||||
toast.info('Attachments are disabled in Settings')
|
||||
return
|
||||
}
|
||||
const selection = await open({
|
||||
multiple: true,
|
||||
filters: [
|
||||
{
|
||||
name: 'Documents',
|
||||
extensions: [
|
||||
'pdf',
|
||||
'docx',
|
||||
'txt',
|
||||
'md',
|
||||
'csv',
|
||||
'xlsx',
|
||||
'xls',
|
||||
'ods',
|
||||
'pptx',
|
||||
'html',
|
||||
'htm',
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (!selection) return
|
||||
const paths = Array.isArray(selection) ? selection : [selection]
|
||||
if (!paths.length) return
|
||||
|
||||
// Check for duplicates and fetch file sizes
|
||||
const existingPaths = new Set(
|
||||
attachments
|
||||
.filter((a) => a.type === 'document' && a.path)
|
||||
.map((a) => a.path)
|
||||
)
|
||||
|
||||
const duplicates: string[] = []
|
||||
const newDocAttachments: Attachment[] = []
|
||||
|
||||
for (const p of paths) {
|
||||
if (existingPaths.has(p)) {
|
||||
duplicates.push(p.split(/[\\/]/).pop() || p)
|
||||
continue
|
||||
}
|
||||
|
||||
const name = p.split(/[\\/]/).pop() || p
|
||||
const fileType = name.split('.').pop()?.toLowerCase()
|
||||
let size: number | undefined = undefined
|
||||
try {
|
||||
const stat = await fs.fileStat(p)
|
||||
size = stat?.size ? Number(stat.size) : undefined
|
||||
} catch (e) {
|
||||
console.warn('Failed to read file size for', p, e)
|
||||
}
|
||||
newDocAttachments.push(
|
||||
createDocumentAttachment({
|
||||
name,
|
||||
path: p,
|
||||
fileType,
|
||||
size,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
toast.warning('Files already attached', {
|
||||
description: `${duplicates.join(', ')} ${duplicates.length === 1 ? 'is' : 'are'} already in the list`,
|
||||
})
|
||||
}
|
||||
|
||||
if (newDocAttachments.length > 0) {
|
||||
// Add to state first with processing flag
|
||||
setAttachments((prev) => [...prev, ...newDocAttachments])
|
||||
|
||||
// If thread exists, ingest immediately
|
||||
if (currentThreadId) {
|
||||
const ragExtension = ExtensionManager.getInstance().get(
|
||||
ExtensionTypeEnum.RAG
|
||||
) as RAGExtension | undefined
|
||||
if (!ragExtension) {
|
||||
toast.error('RAG extension not available')
|
||||
return
|
||||
}
|
||||
|
||||
// Ingest each document
|
||||
for (const doc of newDocAttachments) {
|
||||
try {
|
||||
// Mark as processing
|
||||
setAttachments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.path === doc.path && a.type === 'document'
|
||||
? { ...a, processing: true }
|
||||
: a
|
||||
)
|
||||
)
|
||||
|
||||
const result = await ragExtension.ingestAttachments(
|
||||
currentThreadId,
|
||||
[
|
||||
{
|
||||
path: doc.path!,
|
||||
name: doc.name,
|
||||
type: doc.fileType,
|
||||
size: doc.size,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
const fileInfo = result.files?.[0]
|
||||
if (fileInfo?.id) {
|
||||
// Mark as processed with ID
|
||||
setAttachments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.path === doc.path && a.type === 'document'
|
||||
? {
|
||||
...a,
|
||||
processing: false,
|
||||
processed: true,
|
||||
id: fileInfo.id,
|
||||
chunkCount: fileInfo.chunk_count,
|
||||
}
|
||||
: a
|
||||
)
|
||||
)
|
||||
} else {
|
||||
throw new Error('No file ID returned from ingestion')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to ingest document:', error)
|
||||
// Remove failed document
|
||||
setAttachments((prev) =>
|
||||
prev.filter(
|
||||
(a) => !(a.path === doc.path && a.type === 'document')
|
||||
)
|
||||
)
|
||||
toast.error(`Failed to ingest ${doc.name}`, {
|
||||
description:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to attach documents:', e)
|
||||
const desc = e instanceof Error ? e.message : String(e)
|
||||
toast.error('Failed to attach documents', { description: desc })
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveAttachment = (indexToRemove: number) => {
|
||||
setAttachments((prev) => prev.filter((_, index) => index !== indexToRemove))
|
||||
}
|
||||
|
||||
const getFileTypeFromExtension = (fileName: string): string => {
|
||||
@ -283,20 +496,36 @@ const ChatInput = ({
|
||||
}
|
||||
}
|
||||
|
||||
const formatBytes = (bytes?: number): string => {
|
||||
if (!bytes || bytes <= 0) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
let val = bytes
|
||||
while (val >= 1024 && i < units.length - 1) {
|
||||
val /= 1024
|
||||
i++
|
||||
}
|
||||
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const maxSize = 10 * 1024 * 1024 // 10MB in bytes
|
||||
const newFiles: Array<{
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
base64: string
|
||||
dataUrl: string
|
||||
}> = []
|
||||
const newFiles: Attachment[] = []
|
||||
const duplicates: string[] = []
|
||||
const existingImageNames = new Set(
|
||||
attachments.filter((a) => a.type === 'image').map((a) => a.name)
|
||||
)
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
// Check for duplicate image names
|
||||
if (existingImageNames.has(file.name)) {
|
||||
duplicates.push(file.name)
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (file.size > maxSize) {
|
||||
setMessage(`File is too large. Maximum size is 10MB.`)
|
||||
@ -330,26 +559,93 @@ const ChatInput = ({
|
||||
const result = reader.result
|
||||
if (typeof result === 'string') {
|
||||
const base64String = result.split(',')[1]
|
||||
const fileData = {
|
||||
const att = createImageAttachment({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: actualType,
|
||||
mimeType: actualType,
|
||||
base64: base64String,
|
||||
dataUrl: result,
|
||||
}
|
||||
newFiles.push(fileData)
|
||||
})
|
||||
newFiles.push(att)
|
||||
// Update state
|
||||
if (
|
||||
newFiles.length ===
|
||||
Array.from(files).filter((f) => {
|
||||
const fType = getFileTypeFromExtension(f.name) || f.type
|
||||
return f.size <= maxSize && allowedTypes.includes(fType)
|
||||
return (
|
||||
f.size <= maxSize &&
|
||||
allowedTypes.includes(fType) &&
|
||||
!existingImageNames.has(f.name)
|
||||
)
|
||||
}).length
|
||||
) {
|
||||
setUploadedFiles((prev) => {
|
||||
const updated = [...prev, ...newFiles]
|
||||
return updated
|
||||
})
|
||||
if (newFiles.length > 0) {
|
||||
setAttachments((prev) => {
|
||||
const updated = [...prev, ...newFiles]
|
||||
return updated
|
||||
})
|
||||
|
||||
// If thread exists, ingest images immediately
|
||||
if (currentThreadId) {
|
||||
void (async () => {
|
||||
for (const img of newFiles) {
|
||||
try {
|
||||
// Mark as processing
|
||||
setAttachments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.name === img.name && a.type === 'image'
|
||||
? { ...a, processing: true }
|
||||
: a
|
||||
)
|
||||
)
|
||||
|
||||
const result = await serviceHub
|
||||
.uploads()
|
||||
.ingestImage(currentThreadId, img)
|
||||
|
||||
if (result?.id) {
|
||||
// Mark as processed with ID
|
||||
setAttachments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.name === img.name && a.type === 'image'
|
||||
? {
|
||||
...a,
|
||||
processing: false,
|
||||
processed: true,
|
||||
id: result.id,
|
||||
}
|
||||
: a
|
||||
)
|
||||
)
|
||||
} else {
|
||||
throw new Error('No ID returned from image ingestion')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to ingest image:', error)
|
||||
// Remove failed image
|
||||
setAttachments((prev) =>
|
||||
prev.filter(
|
||||
(a) => !(a.name === img.name && a.type === 'image')
|
||||
)
|
||||
)
|
||||
toast.error(`Failed to ingest ${img.name}`, {
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
toast.warning('Some images already attached', {
|
||||
description: `${duplicates.join(', ')} ${duplicates.length === 1 ? 'is' : 'are'} already in the list`,
|
||||
})
|
||||
}
|
||||
|
||||
// Reset the file input value to allow re-uploading the same file
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
@ -563,33 +859,103 @@ const ChatInput = ({
|
||||
onDragOver={hasMmproj ? handleDragOver : undefined}
|
||||
onDrop={hasMmproj ? handleDrop : undefined}
|
||||
>
|
||||
{uploadedFiles.length > 0 && (
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex gap-3 items-center p-2 pb-0">
|
||||
{uploadedFiles.map((file, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'relative border border-main-view-fg/5 rounded-lg',
|
||||
file.type.startsWith('image/') ? 'size-14' : 'h-14 '
|
||||
)}
|
||||
>
|
||||
{file.type.startsWith('image/') && (
|
||||
<img
|
||||
className="object-cover w-full h-full rounded-lg"
|
||||
src={file.dataUrl}
|
||||
alt={`${file.name} - ${index}`}
|
||||
/>
|
||||
)}
|
||||
{attachments
|
||||
.map((att, idx) => ({ att, idx }))
|
||||
.map(({ att, idx }) => {
|
||||
const isImage = att.type === 'image'
|
||||
const ext = att.fileType || att.mimeType?.split('/')[1]
|
||||
return (
|
||||
<div
|
||||
className="absolute -top-1 -right-2.5 bg-destructive size-5 flex rounded-full items-center justify-center cursor-pointer"
|
||||
onClick={() => handleRemoveFile(index)}
|
||||
key={`${att.type}-${idx}-${att.name}`}
|
||||
className="relative"
|
||||
>
|
||||
<IconX className="text-destructive-fg" size={16} />
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'relative border border-main-view-fg/5 rounded-lg size-14 overflow-hidden bg-main-view/40',
|
||||
'flex items-center justify-center'
|
||||
)}
|
||||
>
|
||||
{/* Inner content by state */}
|
||||
{isImage && att.dataUrl ? (
|
||||
<img
|
||||
className="object-cover w-full h-full"
|
||||
src={att.dataUrl}
|
||||
alt={`${att.name}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-main-view-fg/70">
|
||||
<IconPaperclip size={18} />
|
||||
{ext && (
|
||||
<span className="text-[10px] leading-none mt-0.5 uppercase opacity-70">
|
||||
.{ext}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay spinner when processing */}
|
||||
{att.processing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/10">
|
||||
<IconLoader2
|
||||
size={18}
|
||||
className="text-main-view-fg/80 animate-spin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay success check when processed */}
|
||||
{att.processed && !att.processing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/5">
|
||||
<div className="bg-green-600/90 rounded-full p-1">
|
||||
<IconCheck
|
||||
size={14}
|
||||
className="text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-xs">
|
||||
<div
|
||||
className="font-medium truncate max-w-52"
|
||||
title={att.name}
|
||||
>
|
||||
{att.name}
|
||||
</div>
|
||||
<div className="opacity-70">
|
||||
{isImage
|
||||
? att.mimeType || 'image'
|
||||
: ext
|
||||
? `.${ext}`
|
||||
: 'document'}
|
||||
{att.size
|
||||
? ` · ${formatBytes(att.size)}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Remove button disabled while processing - outside overflow-hidden container */}
|
||||
{!att.processing && (
|
||||
<div
|
||||
className="absolute -top-1 -right-2.5 bg-destructive size-5 flex rounded-full items-center justify-center cursor-pointer"
|
||||
onClick={() => handleRemoveAttachment(idx)}
|
||||
>
|
||||
<IconX className="text-destructive-fg" size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<TextareaAutosize
|
||||
@ -654,7 +1020,7 @@ const ChatInput = ({
|
||||
useLastUsedModel={initialMessage}
|
||||
/>
|
||||
)}
|
||||
{/* File attachment - show only for models with mmproj */}
|
||||
{/* Vision image attachment - show only for models with mmproj */}
|
||||
{hasMmproj && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@ -682,6 +1048,39 @@ const ChatInput = ({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{/* RAG document attachments - desktop-only via dialog; shown when feature enabled */}
|
||||
{selectedModel?.capabilities?.includes('tools') &&
|
||||
showAttachmentButton && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={handleAttachDocsIngest}
|
||||
className="h-7 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1 cursor-pointer"
|
||||
>
|
||||
{ingestingDocs ? (
|
||||
<IconLoader2
|
||||
size={18}
|
||||
className="text-main-view-fg/50 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<IconPaperclip
|
||||
size={18}
|
||||
className="text-main-view-fg/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{ingestingDocs
|
||||
? 'Indexing documents…'
|
||||
: 'Attach documents'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{/* Microphone - always available - Temp Hide */}
|
||||
{/* <div className="h-7 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
|
||||
<IconMicrophone size={18} className="text-main-view-fg/50" />
|
||||
@ -821,7 +1220,15 @@ const ChatInput = ({
|
||||
<TokenCounter
|
||||
messages={threadMessages || []}
|
||||
compact={true}
|
||||
uploadedFiles={uploadedFiles}
|
||||
uploadedFiles={attachments
|
||||
.filter((a) => a.type === 'image' && a.dataUrl)
|
||||
.map((a) => ({
|
||||
name: a.name,
|
||||
type: a.mimeType || getFileTypeFromExtension(a.name),
|
||||
size: a.size || 0,
|
||||
base64: a.base64 || '',
|
||||
dataUrl: a.dataUrl!,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -838,17 +1245,13 @@ const ChatInput = ({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={
|
||||
!prompt.trim() && uploadedFiles.length === 0
|
||||
? null
|
||||
: 'default'
|
||||
}
|
||||
variant={!prompt.trim() ? null : 'default'}
|
||||
size="icon"
|
||||
disabled={!prompt.trim() && uploadedFiles.length === 0}
|
||||
disabled={!prompt.trim() || ingestingAny}
|
||||
data-test-id="send-message-button"
|
||||
onClick={() => handleSendMessage(prompt)}
|
||||
>
|
||||
{streamingContent ? (
|
||||
{streamingContent || ingestingAny ? (
|
||||
<span className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
|
||||
) : (
|
||||
<ArrowRight className="text-primary-fg" />
|
||||
@ -887,7 +1290,15 @@ const ChatInput = ({
|
||||
<TokenCounter
|
||||
messages={threadMessages || []}
|
||||
compact={false}
|
||||
uploadedFiles={uploadedFiles}
|
||||
uploadedFiles={attachments
|
||||
.filter((a) => a.type === 'image' && a.dataUrl)
|
||||
.map((a) => ({
|
||||
name: a.name,
|
||||
type: a.mimeType || getFileTypeFromExtension(a.name),
|
||||
size: a.size || 0,
|
||||
base64: a.base64 || '',
|
||||
dataUrl: a.dataUrl!,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -73,6 +73,12 @@ const SettingsMenu = () => {
|
||||
hasSubMenu: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
title: 'common:attachments',
|
||||
route: route.settings.attachments,
|
||||
hasSubMenu: false,
|
||||
isEnabled: PlatformFeatures[PlatformFeature.ATTACHMENTS],
|
||||
},
|
||||
{
|
||||
title: 'common:appearance',
|
||||
route: route.settings.appearance,
|
||||
|
||||
@ -26,6 +26,8 @@ import TokenSpeedIndicator from '@/containers/TokenSpeedIndicator'
|
||||
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
import { useModelProvider } from '@/hooks/useModelProvider'
|
||||
import { extractFilesFromPrompt } from '@/lib/fileMetadata'
|
||||
import { createImageAttachment } from '@/types/attachment'
|
||||
|
||||
const CopyButton = ({ text }: { text: string }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
@ -102,6 +104,14 @@ export const ThreadContent = memo(
|
||||
[item.content]
|
||||
)
|
||||
|
||||
// Extract file metadata from user message text
|
||||
const { files: attachedFiles, cleanPrompt } = useMemo(() => {
|
||||
if (item.role === 'user') {
|
||||
return extractFilesFromPrompt(text)
|
||||
}
|
||||
return { files: [], cleanPrompt: text }
|
||||
}, [text, item.role])
|
||||
|
||||
const { reasoningSegment, textSegment } = useMemo(() => {
|
||||
// Check for thinking formats
|
||||
const hasThinkTag = text.includes('<think>') && !text.includes('</think>')
|
||||
@ -153,9 +163,9 @@ export const ThreadContent = memo(
|
||||
if (toSendMessage) {
|
||||
deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '')
|
||||
// Extract text content and any attachments
|
||||
const textContent =
|
||||
toSendMessage.content?.find((c) => c.type === 'text')?.text?.value ||
|
||||
''
|
||||
const rawText =
|
||||
toSendMessage.content?.find((c) => c.type === 'text')?.text?.value || ''
|
||||
const { cleanPrompt: textContent } = extractFilesFromPrompt(rawText)
|
||||
const attachments = toSendMessage.content
|
||||
?.filter((c) => (c.type === 'image_url' && c.image_url?.url) || false)
|
||||
.map((c) => {
|
||||
@ -164,23 +174,18 @@ export const ThreadContent = memo(
|
||||
const [mimeType, base64] = url
|
||||
.replace('data:', '')
|
||||
.split(';base64,')
|
||||
return {
|
||||
name: 'image', // We don't have the original filename
|
||||
type: mimeType,
|
||||
size: 0, // We don't have the original size
|
||||
return createImageAttachment({
|
||||
name: 'image', // Original filename unavailable
|
||||
mimeType,
|
||||
size: 0,
|
||||
base64: base64,
|
||||
dataUrl: url,
|
||||
}
|
||||
})
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
base64: string
|
||||
dataUrl: string
|
||||
}>
|
||||
.filter((v) => v !== null)
|
||||
// Keep embedded document metadata in the message for regenerate
|
||||
sendMessage(textContent, true, attachments)
|
||||
}
|
||||
}, [deleteMessage, getMessages, item, sendMessage])
|
||||
@ -225,7 +230,56 @@ export const ThreadContent = memo(
|
||||
<Fragment>
|
||||
{item.role === 'user' && (
|
||||
<div className="w-full">
|
||||
{/* Render attachments above the message bubble */}
|
||||
{/* Render text content in the message bubble */}
|
||||
{cleanPrompt && (
|
||||
<div className="flex justify-end w-full h-full text-start break-words whitespace-normal">
|
||||
<div className="bg-main-view-fg/4 relative text-main-view-fg p-2 rounded-md inline-block max-w-[80%] ">
|
||||
<div className="select-text">
|
||||
<RenderMarkdown
|
||||
content={cleanPrompt}
|
||||
components={linkComponents}
|
||||
isUser
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render document file attachments (extracted from message text) - below text */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="flex justify-end w-full mt-2 mb-2">
|
||||
<div className="flex flex-wrap gap-2 max-w-[80%] justify-end">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<div
|
||||
key={file.id || index}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-main-view-fg/5 rounded-md border border-main-view-fg/10 text-xs"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-main-view-fg/50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-main-view-fg">{file.name}</span>
|
||||
{file.type && (
|
||||
<span className="text-main-view-fg/40 text-[10px]">
|
||||
.{file.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render image attachments - below files */}
|
||||
{item.content?.some(
|
||||
(c) => (c.type === 'image_url' && c.image_url?.url) || false
|
||||
) && (
|
||||
@ -258,33 +312,9 @@ export const ThreadContent = memo(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render text content in the message bubble */}
|
||||
{item.content?.some((c) => c.type === 'text' && c.text?.value) && (
|
||||
<div className="flex justify-end w-full h-full text-start break-words whitespace-normal">
|
||||
<div className="bg-main-view-fg/4 relative text-main-view-fg p-2 rounded-md inline-block max-w-[80%] ">
|
||||
<div className="select-text">
|
||||
{item.content
|
||||
?.filter((c) => c.type === 'text' && c.text?.value)
|
||||
.map((contentPart, index) => (
|
||||
<div key={index}>
|
||||
<RenderMarkdown
|
||||
content={contentPart.text!.value}
|
||||
components={linkComponents}
|
||||
isUser
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 text-main-view-fg/60 text-xs mt-2">
|
||||
<EditMessageDialog
|
||||
message={
|
||||
item.content?.find((c) => c.type === 'text')?.text?.value ||
|
||||
''
|
||||
}
|
||||
message={cleanPrompt || ''}
|
||||
imageUrls={
|
||||
item.content
|
||||
?.filter((c) => c.type === 'image_url' && c.image_url?.url)
|
||||
|
||||
194
web-app/src/hooks/useAttachments.ts
Normal file
194
web-app/src/hooks/useAttachments.ts
Normal file
@ -0,0 +1,194 @@
|
||||
import { create } from 'zustand'
|
||||
import { ExtensionManager } from '@/lib/extension'
|
||||
import { ExtensionTypeEnum, type RAGExtension, type SettingComponentProps } from '@janhq/core'
|
||||
|
||||
export type AttachmentsSettings = {
|
||||
enabled: boolean
|
||||
maxFileSizeMB: number
|
||||
retrievalLimit: number
|
||||
retrievalThreshold: number
|
||||
chunkSizeTokens: number
|
||||
overlapTokens: number
|
||||
searchMode: 'auto' | 'ann' | 'linear'
|
||||
}
|
||||
|
||||
type AttachmentsStore = AttachmentsSettings & {
|
||||
// Dynamic controller definitions for rendering UI
|
||||
settingsDefs: SettingComponentProps[]
|
||||
loadSettingsDefs: () => Promise<void>
|
||||
setEnabled: (v: boolean) => void
|
||||
setMaxFileSizeMB: (v: number) => void
|
||||
setRetrievalLimit: (v: number) => void
|
||||
setRetrievalThreshold: (v: number) => void
|
||||
setChunkSizeTokens: (v: number) => void
|
||||
setOverlapTokens: (v: number) => void
|
||||
setSearchMode: (v: 'auto' | 'ann' | 'linear') => void
|
||||
}
|
||||
|
||||
const getRagExtension = (): RAGExtension | undefined => {
|
||||
try {
|
||||
return ExtensionManager.getInstance().get<RAGExtension>(ExtensionTypeEnum.RAG)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const useAttachments = create<AttachmentsStore>()((set) => ({
|
||||
enabled: true,
|
||||
maxFileSizeMB: 20,
|
||||
retrievalLimit: 3,
|
||||
retrievalThreshold: 0.3,
|
||||
chunkSizeTokens: 512,
|
||||
overlapTokens: 64,
|
||||
searchMode: 'auto',
|
||||
settingsDefs: [],
|
||||
loadSettingsDefs: async () => {
|
||||
const ext = getRagExtension()
|
||||
if (!ext?.getSettings) return
|
||||
try {
|
||||
const defs = await ext.getSettings()
|
||||
if (Array.isArray(defs)) set({ settingsDefs: defs })
|
||||
} catch (e) {
|
||||
console.debug('Failed to load attachment settings defs:', e)
|
||||
}
|
||||
},
|
||||
setEnabled: async (v) => {
|
||||
const ext = getRagExtension()
|
||||
if (ext?.updateSettings) {
|
||||
await ext.updateSettings([
|
||||
{ key: 'enabled', controllerProps: { value: !!v } } as Partial<SettingComponentProps>,
|
||||
])
|
||||
}
|
||||
set((s) => ({
|
||||
enabled: v,
|
||||
settingsDefs: s.settingsDefs.map((d) =>
|
||||
d.key === 'enabled'
|
||||
? ({ ...d, controllerProps: { ...d.controllerProps, value: !!v } } as SettingComponentProps)
|
||||
: d
|
||||
),
|
||||
}))
|
||||
},
|
||||
setMaxFileSizeMB: async (val) => {
|
||||
const ext = getRagExtension()
|
||||
if (ext?.updateSettings) {
|
||||
await ext.updateSettings([
|
||||
{ key: 'max_file_size_mb', controllerProps: { value: val } } as Partial<SettingComponentProps>,
|
||||
])
|
||||
}
|
||||
set((s) => ({
|
||||
maxFileSizeMB: val,
|
||||
settingsDefs: s.settingsDefs.map((d) =>
|
||||
d.key === 'max_file_size_mb'
|
||||
? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps)
|
||||
: d
|
||||
),
|
||||
}))
|
||||
},
|
||||
setRetrievalLimit: async (val) => {
|
||||
const ext = getRagExtension()
|
||||
if (ext?.updateSettings) {
|
||||
await ext.updateSettings([
|
||||
{ key: 'retrieval_limit', controllerProps: { value: val } } as Partial<SettingComponentProps>,
|
||||
])
|
||||
}
|
||||
set((s) => ({
|
||||
retrievalLimit: val,
|
||||
settingsDefs: s.settingsDefs.map((d) =>
|
||||
d.key === 'retrieval_limit'
|
||||
? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps)
|
||||
: d
|
||||
),
|
||||
}))
|
||||
},
|
||||
setRetrievalThreshold: async (val) => {
|
||||
const ext = getRagExtension()
|
||||
if (ext?.updateSettings) {
|
||||
await ext.updateSettings([
|
||||
{ key: 'retrieval_threshold', controllerProps: { value: val } } as Partial<SettingComponentProps>,
|
||||
])
|
||||
}
|
||||
set((s) => ({
|
||||
retrievalThreshold: val,
|
||||
settingsDefs: s.settingsDefs.map((d) =>
|
||||
d.key === 'retrieval_threshold'
|
||||
? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps)
|
||||
: d
|
||||
),
|
||||
}))
|
||||
},
|
||||
setChunkSizeTokens: async (val) => {
|
||||
const ext = getRagExtension()
|
||||
if (ext?.updateSettings) {
|
||||
await ext.updateSettings([
|
||||
{ key: 'chunk_size_tokens', controllerProps: { value: val } } as Partial<SettingComponentProps>,
|
||||
])
|
||||
}
|
||||
set((s) => ({
|
||||
chunkSizeTokens: val,
|
||||
settingsDefs: s.settingsDefs.map((d) =>
|
||||
d.key === 'chunk_size_tokens'
|
||||
? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps)
|
||||
: d
|
||||
),
|
||||
}))
|
||||
},
|
||||
setOverlapTokens: async (val) => {
|
||||
const ext = getRagExtension()
|
||||
if (ext?.updateSettings) {
|
||||
await ext.updateSettings([
|
||||
{ key: 'overlap_tokens', controllerProps: { value: val } } as Partial<SettingComponentProps>,
|
||||
])
|
||||
}
|
||||
set((s) => ({
|
||||
overlapTokens: val,
|
||||
settingsDefs: s.settingsDefs.map((d) =>
|
||||
d.key === 'overlap_tokens'
|
||||
? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps)
|
||||
: d
|
||||
),
|
||||
}))
|
||||
},
|
||||
setSearchMode: async (v) => {
|
||||
const ext = getRagExtension()
|
||||
if (ext?.updateSettings) {
|
||||
await ext.updateSettings([
|
||||
{ key: 'search_mode', controllerProps: { value: v } } as Partial<SettingComponentProps>,
|
||||
])
|
||||
}
|
||||
set((s) => ({
|
||||
searchMode: v,
|
||||
settingsDefs: s.settingsDefs.map((d) =>
|
||||
d.key === 'search_mode'
|
||||
? ({ ...d, controllerProps: { ...d.controllerProps, value: v } } as SettingComponentProps)
|
||||
: d
|
||||
),
|
||||
}))
|
||||
},
|
||||
}))
|
||||
|
||||
// Initialize from extension settings once on import
|
||||
;(async () => {
|
||||
try {
|
||||
const ext = getRagExtension()
|
||||
if (!ext?.getSettings) return
|
||||
const settings = await ext.getSettings()
|
||||
if (!Array.isArray(settings)) return
|
||||
const map = new Map<string, unknown>()
|
||||
for (const s of settings) map.set(s.key, s?.controllerProps?.value)
|
||||
// seed defs and values
|
||||
useAttachments.setState((prev) => ({
|
||||
settingsDefs: settings,
|
||||
enabled: (map.get('enabled') as boolean | undefined) ?? prev.enabled,
|
||||
maxFileSizeMB: (map.get('max_file_size_mb') as number | undefined) ?? prev.maxFileSizeMB,
|
||||
retrievalLimit: (map.get('retrieval_limit') as number | undefined) ?? prev.retrievalLimit,
|
||||
retrievalThreshold:
|
||||
(map.get('retrieval_threshold') as number | undefined) ?? prev.retrievalThreshold,
|
||||
chunkSizeTokens: (map.get('chunk_size_tokens') as number | undefined) ?? prev.chunkSizeTokens,
|
||||
overlapTokens: (map.get('overlap_tokens') as number | undefined) ?? prev.overlapTokens,
|
||||
searchMode:
|
||||
(map.get('search_mode') as 'auto' | 'ann' | 'linear' | undefined) ?? prev.searchMode,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.debug('Failed to initialize attachment settings from extension:', e)
|
||||
}
|
||||
})()
|
||||
@ -37,6 +37,8 @@ import {
|
||||
import { useAssistant } from './useAssistant'
|
||||
import { useShallow } from 'zustand/shallow'
|
||||
import { TEMPORARY_CHAT_QUERY_ID, TEMPORARY_CHAT_ID } from '@/constants/chat'
|
||||
import { toast } from 'sonner'
|
||||
import { Attachment } from '@/types/attachment'
|
||||
|
||||
export const useChat = () => {
|
||||
const [
|
||||
@ -257,14 +259,12 @@ export const useChat = () => {
|
||||
async (
|
||||
message: string,
|
||||
troubleshooting = true,
|
||||
attachments?: Array<{
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
base64: string
|
||||
dataUrl: string
|
||||
}>,
|
||||
projectId?: string
|
||||
attachments?: Attachment[],
|
||||
projectId?: string,
|
||||
updateAttachmentProcessing?: (
|
||||
fileName: string,
|
||||
status: 'processing' | 'done' | 'error' | 'clear_docs' | 'clear_all'
|
||||
) => void
|
||||
) => {
|
||||
const activeThread = await getCurrentThread(projectId)
|
||||
const selectedProvider = useModelProvider.getState().selectedProvider
|
||||
@ -272,14 +272,124 @@ export const useChat = () => {
|
||||
|
||||
resetTokenSpeed()
|
||||
if (!activeThread || !activeProvider) return
|
||||
|
||||
// Separate images and documents
|
||||
const images = attachments?.filter((a) => a.type === 'image') || []
|
||||
const documents = attachments?.filter((a) => a.type === 'document') || []
|
||||
|
||||
// Process attachments BEFORE sending
|
||||
const processedAttachments: Attachment[] = []
|
||||
|
||||
// 1) Images ingestion (placeholder/no-op for now)
|
||||
// Track attachment ingestion; all must succeed before sending
|
||||
|
||||
if (images.length > 0) {
|
||||
for (const img of images) {
|
||||
try {
|
||||
// Skip if already processed (ingested in ChatInput when thread existed)
|
||||
if (img.processed && img.id) {
|
||||
processedAttachments.push(img)
|
||||
continue
|
||||
}
|
||||
|
||||
if (updateAttachmentProcessing) {
|
||||
updateAttachmentProcessing(img.name, 'processing')
|
||||
}
|
||||
// Upload image, get id/URL
|
||||
const res = await serviceHub.uploads().ingestImage(activeThread.id, img)
|
||||
processedAttachments.push({
|
||||
...img,
|
||||
id: res.id,
|
||||
processed: true,
|
||||
processing: false,
|
||||
})
|
||||
if (updateAttachmentProcessing) {
|
||||
updateAttachmentProcessing(img.name, 'done')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to ingest image ${img.name}:`, err)
|
||||
if (updateAttachmentProcessing) {
|
||||
updateAttachmentProcessing(img.name, 'error')
|
||||
}
|
||||
const desc = err instanceof Error ? err.message : String(err)
|
||||
toast.error('Failed to ingest image attachment', { description: desc })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (documents.length > 0) {
|
||||
try {
|
||||
for (const doc of documents) {
|
||||
// Skip if already processed (ingested in ChatInput when thread existed)
|
||||
if (doc.processed && doc.id) {
|
||||
processedAttachments.push(doc)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update UI to show spinner on this file
|
||||
if (updateAttachmentProcessing) {
|
||||
updateAttachmentProcessing(doc.name, 'processing')
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await serviceHub
|
||||
.uploads()
|
||||
.ingestFileAttachment(activeThread.id, doc)
|
||||
|
||||
// Add processed document with ID
|
||||
processedAttachments.push({
|
||||
...doc,
|
||||
id: res.id,
|
||||
size: res.size ?? doc.size,
|
||||
chunkCount: res.chunkCount ?? doc.chunkCount,
|
||||
processing: false,
|
||||
processed: true,
|
||||
})
|
||||
|
||||
// Update UI to show done state
|
||||
if (updateAttachmentProcessing) {
|
||||
updateAttachmentProcessing(doc.name, 'done')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to ingest ${doc.name}:`, err)
|
||||
if (updateAttachmentProcessing) {
|
||||
updateAttachmentProcessing(doc.name, 'error')
|
||||
}
|
||||
throw err // Re-throw to handle in outer catch
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to ingest documents:', err)
|
||||
const desc = err instanceof Error ? err.message : String(err)
|
||||
toast.error('Failed to index attachments', { description: desc })
|
||||
// Don't continue with message send if ingestion failed
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// All attachments prepared successfully
|
||||
|
||||
const messages = getMessages(activeThread.id)
|
||||
const abortController = new AbortController()
|
||||
setAbortController(activeThread.id, abortController)
|
||||
updateStreamingContent(emptyThreadContent)
|
||||
updatePromptProgress(undefined)
|
||||
// Do not add new message on retry
|
||||
if (troubleshooting)
|
||||
addMessage(newUserThreadContent(activeThread.id, message, attachments))
|
||||
// All attachments (images + docs) ingested successfully.
|
||||
// Build the user content once; use it for both the outbound request
|
||||
// and persisting to the store so both are identical.
|
||||
if (updateAttachmentProcessing) {
|
||||
updateAttachmentProcessing('__CLEAR_ALL__', 'clear_all')
|
||||
}
|
||||
const userContent = newUserThreadContent(
|
||||
activeThread.id,
|
||||
message,
|
||||
processedAttachments
|
||||
)
|
||||
if (troubleshooting) {
|
||||
addMessage(userContent)
|
||||
}
|
||||
updateThreadTimestamp(activeThread.id)
|
||||
usePrompt.getState().setPrompt('')
|
||||
const selectedModel = useModelProvider.getState().selectedModel
|
||||
@ -296,7 +406,8 @@ export const useChat = () => {
|
||||
? renderInstructions(currentAssistant.instructions)
|
||||
: undefined
|
||||
)
|
||||
if (troubleshooting) builder.addUserMessage(message, attachments)
|
||||
// Using addUserMessage to respect legacy code. Should be using the userContent above.
|
||||
if (troubleshooting) builder.addUserMessage(userContent)
|
||||
|
||||
let isCompleted = false
|
||||
|
||||
|
||||
@ -54,6 +54,7 @@ export const useThreadScrolling = (
|
||||
}
|
||||
}, [scrollContainerRef])
|
||||
|
||||
|
||||
const handleScroll = useCallback((e: Event) => {
|
||||
const target = e.target as HTMLDivElement
|
||||
const { scrollTop, scrollHeight, clientHeight } = target
|
||||
@ -68,7 +69,7 @@ export const useThreadScrolling = (
|
||||
setIsAtBottom(isBottom)
|
||||
setHasScrollbar(hasScroll)
|
||||
lastScrollTopRef.current = scrollTop
|
||||
}, [streamingContent, setIsAtBottom, setHasScrollbar])
|
||||
}, [streamingContent])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
@ -77,7 +78,7 @@ export const useThreadScrolling = (
|
||||
return () =>
|
||||
scrollContainer.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [handleScroll, scrollContainerRef])
|
||||
}, [handleScroll])
|
||||
|
||||
const checkScrollState = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
@ -89,7 +90,7 @@ export const useThreadScrolling = (
|
||||
|
||||
setIsAtBottom(isBottom)
|
||||
setHasScrollbar(hasScroll)
|
||||
}, [scrollContainerRef, setIsAtBottom, setHasScrollbar])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current) return
|
||||
@ -100,7 +101,7 @@ export const useThreadScrolling = (
|
||||
scrollToBottom(false)
|
||||
checkScrollState()
|
||||
}
|
||||
}, [checkScrollState, scrollToBottom, scrollContainerRef])
|
||||
}, [checkScrollState, scrollToBottom])
|
||||
|
||||
|
||||
const prevCountRef = useRef(messageCount)
|
||||
@ -145,7 +146,7 @@ export const useThreadScrolling = (
|
||||
}
|
||||
|
||||
prevCountRef.current = messageCount
|
||||
}, [messageCount, lastMessageRole, getDOMElements, setPaddingHeight])
|
||||
}, [messageCount, lastMessageRole])
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyStreaming = wasStreamingRef.current
|
||||
@ -196,7 +197,7 @@ export const useThreadScrolling = (
|
||||
}
|
||||
|
||||
wasStreamingRef.current = currentlyStreaming
|
||||
}, [streamingContent, threadId, getDOMElements, setPaddingHeight])
|
||||
}, [streamingContent, threadId])
|
||||
|
||||
useEffect(() => {
|
||||
userIntendedPositionRef.current = null
|
||||
@ -206,7 +207,7 @@ export const useThreadScrolling = (
|
||||
prevCountRef.current = messageCount
|
||||
scrollToBottom(false)
|
||||
checkScrollState()
|
||||
}, [threadId, messageCount, scrollToBottom, checkScrollState, setPaddingHeight])
|
||||
}, [threadId])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@ -3,6 +3,8 @@ import { ulid } from 'ulidx'
|
||||
import { getServiceHub } from '@/hooks/useServiceHub'
|
||||
import { Fzf } from 'fzf'
|
||||
import { TEMPORARY_CHAT_ID } from '@/constants/chat'
|
||||
import { ExtensionManager } from '@/lib/extension'
|
||||
import { ExtensionTypeEnum, VectorDBExtension } from '@janhq/core'
|
||||
|
||||
type ThreadState = {
|
||||
threads: Record<string, Thread>
|
||||
@ -33,6 +35,18 @@ type ThreadState = {
|
||||
searchIndex: Fzf<Thread[]> | null
|
||||
}
|
||||
|
||||
// Helper function to clean up vector DB collection for a thread
|
||||
const cleanupVectorDB = async (threadId: string) => {
|
||||
try {
|
||||
const vec = ExtensionManager.getInstance().get<VectorDBExtension>(ExtensionTypeEnum.VectorDB)
|
||||
if (vec?.deleteCollection) {
|
||||
await vec.deleteCollection(`attachments_${threadId}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[Threads] Failed to delete vector DB collection for thread ${threadId}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
export const useThreads = create<ThreadState>()((set, get) => ({
|
||||
threads: {},
|
||||
searchIndex: null,
|
||||
@ -130,7 +144,11 @@ export const useThreads = create<ThreadState>()((set, get) => ({
|
||||
set((state) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [threadId]: _, ...remainingThreads } = state.threads
|
||||
|
||||
// Clean up vector DB collection
|
||||
cleanupVectorDB(threadId)
|
||||
getServiceHub().threads().deleteThread(threadId)
|
||||
|
||||
return {
|
||||
threads: remainingThreads,
|
||||
searchIndex: new Fzf<Thread[]>(Object.values(remainingThreads).filter(t => t.id !== TEMPORARY_CHAT_ID), {
|
||||
@ -157,8 +175,9 @@ export const useThreads = create<ThreadState>()((set, get) => ({
|
||||
!state.threads[threadId].metadata?.project
|
||||
)
|
||||
|
||||
// Delete threads that are not favorites and not in projects
|
||||
// Delete threads and clean up their vector DB collections
|
||||
threadsToDeleteIds.forEach((threadId) => {
|
||||
cleanupVectorDB(threadId)
|
||||
getServiceHub().threads().deleteThread(threadId)
|
||||
})
|
||||
|
||||
@ -183,8 +202,9 @@ export const useThreads = create<ThreadState>()((set, get) => ({
|
||||
set((state) => {
|
||||
const allThreadIds = Object.keys(state.threads)
|
||||
|
||||
// Delete all threads from server
|
||||
// Delete all threads and clean up their vector DB collections
|
||||
allThreadIds.forEach((threadId) => {
|
||||
cleanupVectorDB(threadId)
|
||||
getServiceHub().threads().deleteThread(threadId)
|
||||
})
|
||||
|
||||
|
||||
@ -19,11 +19,11 @@ export const useTools = () => {
|
||||
)
|
||||
|
||||
// Fetch tools
|
||||
const data = await getServiceHub().mcp().getTools()
|
||||
updateTools(data)
|
||||
const mcpTools = await getServiceHub().mcp().getTools()
|
||||
updateTools(mcpTools)
|
||||
|
||||
// Initialize default disabled tools for new users (only once)
|
||||
if (!isDefaultsInitialized() && data.length > 0 && mcpExtension?.getDefaultDisabledTools) {
|
||||
if (!isDefaultsInitialized() && mcpTools.length > 0 && mcpExtension?.getDefaultDisabledTools) {
|
||||
const defaultDisabled = await mcpExtension.getDefaultDisabledTools()
|
||||
if (defaultDisabled.length > 0) {
|
||||
setDefaultDisabledTools(defaultDisabled)
|
||||
|
||||
@ -137,7 +137,9 @@ describe('CompletionMessagesBuilder', () => {
|
||||
it('should add user message to messages array', () => {
|
||||
const builder = new CompletionMessagesBuilder([])
|
||||
|
||||
builder.addUserMessage('Hello, how are you?')
|
||||
builder.addUserMessage(
|
||||
createMockThreadMessage('user', 'Hello, how are you?')
|
||||
)
|
||||
|
||||
const result = builder.getMessages()
|
||||
expect(result).toHaveLength(1)
|
||||
@ -150,8 +152,8 @@ describe('CompletionMessagesBuilder', () => {
|
||||
it('should not add consecutive user messages', () => {
|
||||
const builder = new CompletionMessagesBuilder([])
|
||||
|
||||
builder.addUserMessage('First message')
|
||||
builder.addUserMessage('Second message')
|
||||
builder.addUserMessage(createMockThreadMessage('user', 'First message'))
|
||||
builder.addUserMessage(createMockThreadMessage('user', 'Second message'))
|
||||
|
||||
const result = builder.getMessages()
|
||||
expect(result).toHaveLength(1)
|
||||
@ -161,7 +163,7 @@ describe('CompletionMessagesBuilder', () => {
|
||||
it('should handle empty user message', () => {
|
||||
const builder = new CompletionMessagesBuilder([])
|
||||
|
||||
builder.addUserMessage('')
|
||||
builder.addUserMessage(createMockThreadMessage('user', ''))
|
||||
|
||||
const result = builder.getMessages()
|
||||
expect(result).toHaveLength(1)
|
||||
@ -338,7 +340,7 @@ describe('CompletionMessagesBuilder', () => {
|
||||
'You are helpful'
|
||||
)
|
||||
|
||||
builder.addUserMessage('How are you?')
|
||||
builder.addUserMessage(createMockThreadMessage('user', 'How are you?'))
|
||||
builder.addAssistantMessage('I am well, thank you!')
|
||||
builder.addToolMessage('Tool response', 'call_123')
|
||||
|
||||
@ -353,7 +355,7 @@ describe('CompletionMessagesBuilder', () => {
|
||||
it('should return the same array reference (not immutable)', () => {
|
||||
const builder = new CompletionMessagesBuilder([])
|
||||
|
||||
builder.addUserMessage('Test message')
|
||||
builder.addUserMessage(createMockThreadMessage('user', 'Test message'))
|
||||
const result1 = builder.getMessages()
|
||||
|
||||
builder.addAssistantMessage('Response')
|
||||
|
||||
@ -12,6 +12,9 @@ import {
|
||||
Tool,
|
||||
} from '@janhq/core'
|
||||
import { getServiceHub } from '@/hooks/useServiceHub'
|
||||
import { useAttachments } from '@/hooks/useAttachments'
|
||||
import { PlatformFeatures } from '@/lib/platform/const'
|
||||
import { PlatformFeature } from '@/lib/platform/types'
|
||||
import {
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionTool,
|
||||
@ -33,6 +36,8 @@ import { CompletionMessagesBuilder } from './messages'
|
||||
import { ChatCompletionMessageToolCall } from 'openai/resources'
|
||||
import { ExtensionManager } from './extension'
|
||||
import { useAppState } from '@/hooks/useAppState'
|
||||
import { injectFilesIntoPrompt } from './fileMetadata'
|
||||
import { Attachment } from '@/types/attachment'
|
||||
|
||||
export type ChatCompletionResponse =
|
||||
| chatCompletion
|
||||
@ -51,38 +56,48 @@ export type ChatCompletionResponse =
|
||||
export const newUserThreadContent = (
|
||||
threadId: string,
|
||||
content: string,
|
||||
attachments?: Array<{
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
base64: string
|
||||
dataUrl: string
|
||||
}>
|
||||
attachments?: Attachment[]
|
||||
): ThreadMessage => {
|
||||
// Separate images and documents
|
||||
const images = attachments?.filter((a) => a.type === 'image') || []
|
||||
const documents = attachments?.filter((a) => a.type === 'document') || []
|
||||
|
||||
// Inject document metadata into the text content (id, name, fileType only - no path)
|
||||
const docMetadata = documents
|
||||
.filter((doc) => doc.id) // Only include processed documents
|
||||
.map((doc) => ({
|
||||
id: doc.id!,
|
||||
name: doc.name,
|
||||
type: doc.fileType,
|
||||
size: typeof doc.size === 'number' ? doc.size : undefined,
|
||||
chunkCount: typeof doc.chunkCount === 'number' ? doc.chunkCount : undefined,
|
||||
}))
|
||||
|
||||
const textWithFiles =
|
||||
docMetadata.length > 0 ? injectFilesIntoPrompt(content, docMetadata) : content
|
||||
|
||||
const contentParts = [
|
||||
{
|
||||
type: ContentType.Text,
|
||||
text: {
|
||||
value: content,
|
||||
value: textWithFiles,
|
||||
annotations: [],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Add attachments to content array
|
||||
if (attachments) {
|
||||
attachments.forEach((attachment) => {
|
||||
if (attachment.type.startsWith('image/')) {
|
||||
contentParts.push({
|
||||
type: ContentType.Image,
|
||||
image_url: {
|
||||
url: `data:${attachment.type};base64,${attachment.base64}`,
|
||||
detail: 'auto',
|
||||
},
|
||||
} as any)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Add image attachments to content array
|
||||
images.forEach((img) => {
|
||||
if (img.base64 && img.mimeType) {
|
||||
contentParts.push({
|
||||
type: ContentType.Image,
|
||||
image_url: {
|
||||
url: `data:${img.mimeType};base64,${img.base64}`,
|
||||
detail: 'auto',
|
||||
},
|
||||
} as any)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
@ -216,6 +231,21 @@ export const sendCompletion = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Inject RAG tools on-demand (not in global tools list)
|
||||
let usableTools = tools
|
||||
try {
|
||||
const attachmentsEnabled = useAttachments.getState().enabled
|
||||
if (attachmentsEnabled && PlatformFeatures[PlatformFeature.ATTACHMENTS]) {
|
||||
const ragTools = await getServiceHub().rag().getTools().catch(() => [])
|
||||
if (Array.isArray(ragTools) && ragTools.length) {
|
||||
usableTools = [...tools, ...ragTools]
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore RAG tool injection errors during completion setup
|
||||
console.debug('Skipping RAG tools injection:', e)
|
||||
}
|
||||
|
||||
const engine = ExtensionManager.getInstance().getEngine(provider.provider)
|
||||
|
||||
const completion = engine
|
||||
@ -224,8 +254,8 @@ export const sendCompletion = async (
|
||||
messages: messages as chatCompletionRequestMessage[],
|
||||
model: thread.model?.id,
|
||||
thread_id: thread.id,
|
||||
tools: normalizeTools(tools),
|
||||
tool_choice: tools.length ? 'auto' : undefined,
|
||||
tools: normalizeTools(usableTools),
|
||||
tool_choice: usableTools.length ? 'auto' : undefined,
|
||||
stream: true,
|
||||
...params,
|
||||
},
|
||||
@ -239,8 +269,8 @@ export const sendCompletion = async (
|
||||
provider: providerName as any,
|
||||
model: thread.model?.id,
|
||||
messages,
|
||||
tools: normalizeTools(tools),
|
||||
tool_choice: tools.length ? 'auto' : undefined,
|
||||
tools: normalizeTools(usableTools),
|
||||
tool_choice: usableTools.length ? 'auto' : undefined,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
@ -252,8 +282,8 @@ export const sendCompletion = async (
|
||||
provider: providerName,
|
||||
model: thread.model?.id,
|
||||
messages,
|
||||
tools: normalizeTools(tools),
|
||||
tool_choice: tools.length ? 'auto' : undefined,
|
||||
tools: normalizeTools(usableTools),
|
||||
tool_choice: usableTools.length ? 'auto' : undefined,
|
||||
...params,
|
||||
})
|
||||
return completion
|
||||
@ -373,6 +403,16 @@ export const postMessageProcessing = async (
|
||||
) => {
|
||||
// Handle completed tool calls
|
||||
if (calls.length) {
|
||||
// Fetch RAG tool names from RAG service
|
||||
let ragToolNames = new Set<string>()
|
||||
try {
|
||||
const names = await getServiceHub().rag().getToolNames()
|
||||
ragToolNames = new Set(names)
|
||||
} catch (e) {
|
||||
console.error('Failed to load RAG tool names:', e)
|
||||
}
|
||||
const ragFeatureAvailable =
|
||||
useAttachments.getState().enabled && PlatformFeatures[PlatformFeature.ATTACHMENTS]
|
||||
for (const toolCall of calls) {
|
||||
if (abortController.signal.aborted) break
|
||||
const toolId = ulid()
|
||||
@ -411,23 +451,46 @@ export const postMessageProcessing = async (
|
||||
)
|
||||
}
|
||||
}
|
||||
const approved =
|
||||
allowAllMCPPermissions ||
|
||||
approvedTools[message.thread_id]?.includes(toolCall.function.name) ||
|
||||
(showModal
|
||||
? await showModal(
|
||||
toolCall.function.name,
|
||||
message.thread_id,
|
||||
toolParameters
|
||||
)
|
||||
: true)
|
||||
|
||||
const { promise, cancel } = getServiceHub()
|
||||
.mcp()
|
||||
.callToolWithCancellation({
|
||||
toolName: toolCall.function.name,
|
||||
arguments: toolCall.function.arguments.length ? toolParameters : {},
|
||||
})
|
||||
const toolName = toolCall.function.name
|
||||
const toolArgs = toolCall.function.arguments.length ? toolParameters : {}
|
||||
const isRagTool = ragToolNames.has(toolName)
|
||||
|
||||
// Auto-approve RAG tools (local/safe operations), require permission for MCP tools
|
||||
const approved = isRagTool
|
||||
? true
|
||||
: allowAllMCPPermissions ||
|
||||
approvedTools[message.thread_id]?.includes(toolCall.function.name) ||
|
||||
(showModal
|
||||
? await showModal(
|
||||
toolCall.function.name,
|
||||
message.thread_id,
|
||||
toolParameters
|
||||
)
|
||||
: true)
|
||||
|
||||
const { promise, cancel } = isRagTool
|
||||
? ragFeatureAvailable
|
||||
? {
|
||||
promise: getServiceHub().rag().callTool({ toolName, arguments: toolArgs, threadId: message.thread_id }),
|
||||
cancel: async () => {},
|
||||
}
|
||||
: {
|
||||
promise: Promise.resolve({
|
||||
error: 'attachments_unavailable',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Attachments feature is disabled or unavailable on this platform.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
cancel: async () => {},
|
||||
}
|
||||
: getServiceHub().mcp().callToolWithCancellation({
|
||||
toolName,
|
||||
arguments: toolArgs,
|
||||
})
|
||||
|
||||
useAppState.getState().setCancelToolCall(cancel)
|
||||
|
||||
@ -441,7 +504,7 @@ export const postMessageProcessing = async (
|
||||
text: `Error calling tool ${toolCall.function.name}: ${e.message ?? e}`,
|
||||
},
|
||||
],
|
||||
error: true,
|
||||
error: String(e?.message ?? e ?? 'Tool call failed'),
|
||||
}
|
||||
})
|
||||
: {
|
||||
@ -451,6 +514,7 @@ export const postMessageProcessing = async (
|
||||
text: 'The user has chosen to disallow the tool call.',
|
||||
},
|
||||
],
|
||||
error: 'disallowed',
|
||||
}
|
||||
|
||||
if (typeof result === 'string') {
|
||||
@ -461,6 +525,7 @@ export const postMessageProcessing = async (
|
||||
text: result,
|
||||
},
|
||||
],
|
||||
error: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
106
web-app/src/lib/fileMetadata.ts
Normal file
106
web-app/src/lib/fileMetadata.ts
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Utility functions for embedding and extracting file metadata from user prompts
|
||||
*/
|
||||
|
||||
export interface FileMetadata {
|
||||
id: string
|
||||
name: string
|
||||
type?: string
|
||||
size?: number
|
||||
chunkCount?: number
|
||||
}
|
||||
|
||||
const FILE_METADATA_START = '[ATTACHED_FILES]'
|
||||
const FILE_METADATA_END = '[/ATTACHED_FILES]'
|
||||
|
||||
/**
|
||||
* Inject file metadata into user prompt at the end
|
||||
* @param prompt - The user's message
|
||||
* @param files - Array of file metadata
|
||||
* @returns Prompt with embedded file metadata
|
||||
*/
|
||||
export function injectFilesIntoPrompt(
|
||||
prompt: string,
|
||||
files: FileMetadata[]
|
||||
): string {
|
||||
if (!files || files.length === 0) return prompt
|
||||
|
||||
const fileLines = files
|
||||
.map((file) => {
|
||||
const parts = [`file_id: ${file.id}`, `name: ${file.name}`]
|
||||
if (file.type) parts.push(`type: ${file.type}`)
|
||||
if (typeof file.size === 'number') parts.push(`size: ${file.size}`)
|
||||
if (typeof file.chunkCount === 'number') parts.push(`chunks: ${file.chunkCount}`)
|
||||
return `- ${parts.join(', ')}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const fileBlock = `\n\n${FILE_METADATA_START}\n${fileLines}\n${FILE_METADATA_END}`
|
||||
|
||||
return prompt + fileBlock
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file metadata from user prompt
|
||||
* @param prompt - The prompt potentially containing file metadata
|
||||
* @returns Object containing extracted files and clean prompt
|
||||
*/
|
||||
export function extractFilesFromPrompt(prompt: string): {
|
||||
files: FileMetadata[]
|
||||
cleanPrompt: string
|
||||
} {
|
||||
if (!prompt.includes(FILE_METADATA_START)) {
|
||||
return { files: [], cleanPrompt: prompt }
|
||||
}
|
||||
|
||||
const startIndex = prompt.indexOf(FILE_METADATA_START)
|
||||
const endIndex = prompt.indexOf(FILE_METADATA_END)
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||
return { files: [], cleanPrompt: prompt }
|
||||
}
|
||||
|
||||
// Extract the file metadata block
|
||||
const fileBlock = prompt.substring(
|
||||
startIndex + FILE_METADATA_START.length,
|
||||
endIndex
|
||||
)
|
||||
|
||||
// Parse file metadata (flexible key:value parser)
|
||||
const files: FileMetadata[] = []
|
||||
const lines = fileBlock.trim().split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.replace(/^\s*-\s*/, '').trim()
|
||||
const parts = trimmed.split(',')
|
||||
const map: Record<string, string> = {}
|
||||
for (const part of parts) {
|
||||
const [k, ...rest] = part.split(':')
|
||||
if (!k || rest.length === 0) continue
|
||||
map[k.trim()] = rest.join(':').trim()
|
||||
}
|
||||
const id = map['file_id']
|
||||
const name = map['name']
|
||||
if (!id || !name) continue
|
||||
const type = map['type']
|
||||
const size = map['size'] ? Number(map['size']) : undefined
|
||||
const chunkCount = map['chunks'] ? Number(map['chunks']) : undefined
|
||||
const fileObj: FileMetadata = { id, name };
|
||||
if (type) {
|
||||
fileObj.type = type;
|
||||
}
|
||||
if (typeof size === 'number' && !Number.isNaN(size)) {
|
||||
fileObj.size = size;
|
||||
}
|
||||
if (typeof chunkCount === 'number' && !Number.isNaN(chunkCount)) {
|
||||
fileObj.chunkCount = chunkCount;
|
||||
}
|
||||
files.push(fileObj);
|
||||
}
|
||||
|
||||
// Extract clean prompt (everything before [ATTACHED_FILES])
|
||||
const cleanPrompt = prompt
|
||||
.substring(0, startIndex)
|
||||
.trim()
|
||||
|
||||
return { files, cleanPrompt }
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ChatCompletionMessageParam } from 'token.js'
|
||||
import { ChatCompletionMessageToolCall } from 'openai/resources'
|
||||
import { ThreadMessage } from '@janhq/core'
|
||||
import { ThreadMessage, ContentType } from '@janhq/core'
|
||||
import { removeReasoningContent } from '@/utils/reasoning'
|
||||
// Attachments are now handled upstream in newUserThreadContent
|
||||
|
||||
type ThreadContent = NonNullable<ThreadMessage['content']>[number]
|
||||
|
||||
/**
|
||||
* @fileoverview Helper functions for creating chat completion request.
|
||||
@ -22,105 +24,69 @@ export class CompletionMessagesBuilder {
|
||||
...messages
|
||||
.filter((e) => !e.metadata?.error)
|
||||
.map<ChatCompletionMessageParam>((msg) => {
|
||||
if (msg.role === 'assistant') {
|
||||
return {
|
||||
role: msg.role,
|
||||
content: removeReasoningContent(
|
||||
msg.content[0]?.text?.value || '.'
|
||||
),
|
||||
} as ChatCompletionMessageParam
|
||||
} else {
|
||||
// For user messages, handle multimodal content
|
||||
if (msg.content.length > 1) {
|
||||
// Multiple content parts (text + images + files)
|
||||
|
||||
const content = msg.content.map((contentPart) => {
|
||||
if (contentPart.type === 'text') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: contentPart.text?.value || '',
|
||||
}
|
||||
} else if (contentPart.type === 'image_url') {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: contentPart.image_url?.url || '',
|
||||
detail: contentPart.image_url?.detail || 'auto',
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return contentPart
|
||||
}
|
||||
})
|
||||
return {
|
||||
role: msg.role,
|
||||
content,
|
||||
} as ChatCompletionMessageParam
|
||||
} else {
|
||||
// Single text content
|
||||
return {
|
||||
role: msg.role,
|
||||
content: msg.content[0]?.text?.value || '.',
|
||||
} as ChatCompletionMessageParam
|
||||
}
|
||||
const param = this.toCompletionParamFromThread(msg)
|
||||
// In constructor context, normalize empty user text to a placeholder
|
||||
if (param.role === 'user' && typeof param.content === 'string' && param.content === '') {
|
||||
return { ...param, content: '.' }
|
||||
}
|
||||
return param
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Normalize a ThreadMessage into a ChatCompletionMessageParam for Token.js
|
||||
private toCompletionParamFromThread(msg: ThreadMessage): ChatCompletionMessageParam {
|
||||
if (msg.role === 'assistant') {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: removeReasoningContent(msg.content?.[0]?.text?.value || '.'),
|
||||
} as ChatCompletionMessageParam
|
||||
}
|
||||
|
||||
// System messages are uncommon here; normalize to plain text
|
||||
if (msg.role === 'system') {
|
||||
return {
|
||||
role: 'system',
|
||||
content: msg.content?.[0]?.text?.value || '.',
|
||||
} as ChatCompletionMessageParam
|
||||
}
|
||||
|
||||
// User messages: handle multimodal content
|
||||
if (Array.isArray(msg.content) && msg.content.length > 1) {
|
||||
const content = msg.content.map((part: ThreadContent) => {
|
||||
if (part.type === ContentType.Text) {
|
||||
return { type: 'text' as const, text: part.text?.value ?? '' }
|
||||
}
|
||||
if (part.type === ContentType.Image) {
|
||||
return {
|
||||
type: 'image_url' as const,
|
||||
image_url: { url: part.image_url?.url || '', detail: part.image_url?.detail || 'auto' },
|
||||
}
|
||||
}
|
||||
// Fallback for unknown content types
|
||||
return { type: 'text' as const, text: '' }
|
||||
})
|
||||
return { role: 'user', content } as ChatCompletionMessageParam
|
||||
}
|
||||
// Single text part
|
||||
const text = msg?.content?.[0]?.text?.value ?? '.'
|
||||
return { role: 'user', content: text }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a user message to the messages array.
|
||||
* @param content - The content of the user message.
|
||||
* @param attachments - Optional attachments for the message.
|
||||
* Add a user message to the messages array from a parsed ThreadMessage.
|
||||
* Upstream code should construct the message via newUserThreadContent
|
||||
* and pass it here to avoid duplicated logic.
|
||||
*/
|
||||
addUserMessage(
|
||||
content: string,
|
||||
attachments?: Array<{
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
base64: string
|
||||
dataUrl: string
|
||||
}>
|
||||
) {
|
||||
addUserMessage(message: ThreadMessage) {
|
||||
if (message.role !== 'user') {
|
||||
throw new Error('addUserMessage expects a user ThreadMessage')
|
||||
}
|
||||
// Ensure no consecutive user messages
|
||||
if (this.messages[this.messages.length - 1]?.role === 'user') {
|
||||
this.messages.pop()
|
||||
}
|
||||
|
||||
// Handle multimodal content with attachments
|
||||
if (attachments && attachments.length > 0) {
|
||||
const messageContent: any[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: content,
|
||||
},
|
||||
]
|
||||
|
||||
// Add attachments (images and PDFs)
|
||||
attachments.forEach((attachment) => {
|
||||
if (attachment.type.startsWith('image/')) {
|
||||
messageContent.push({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${attachment.type};base64,${attachment.base64}`,
|
||||
detail: 'auto',
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.messages.push({
|
||||
role: 'user',
|
||||
content: messageContent,
|
||||
} as any)
|
||||
} else {
|
||||
// Text-only message
|
||||
this.messages.push({
|
||||
role: 'user',
|
||||
content: content,
|
||||
})
|
||||
}
|
||||
this.messages.push(this.toCompletionParamFromThread(message))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -78,6 +78,10 @@ export const PlatformFeatures: Record<PlatformFeature, boolean> = {
|
||||
// First message persisted thread - enabled for web and mobile platforms
|
||||
[PlatformFeature.FIRST_MESSAGE_PERSISTED_THREAD]: !isPlatformTauri() || isPlatformIOS() || isPlatformAndroid(),
|
||||
|
||||
// Temporary chat mode - enabled for web only
|
||||
// Temporary chat mode - enabled for web only
|
||||
[PlatformFeature.TEMPORARY_CHAT]: !isPlatformTauri(),
|
||||
|
||||
// Attachments/RAG UI and tooling - desktop only for now
|
||||
[PlatformFeature.ATTACHMENTS]:
|
||||
isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(),
|
||||
}
|
||||
|
||||
@ -71,4 +71,7 @@ export enum PlatformFeature {
|
||||
|
||||
// Temporary chat mode - web-only feature for ephemeral conversations like ChatGPT
|
||||
TEMPORARY_CHAT = 'temporaryChat',
|
||||
|
||||
// Attachments/RAG UI and tooling (desktop-only for now)
|
||||
ATTACHMENTS = 'attachments',
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"local_api_server": "Local API Server",
|
||||
"https_proxy": "HTTPS Proxy",
|
||||
"extensions": "Extensions",
|
||||
"attachments": "Attachments",
|
||||
"general": "General",
|
||||
"settings": "Settings",
|
||||
"modelProviders": "Model Providers",
|
||||
@ -372,4 +373,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -256,6 +256,30 @@
|
||||
"extensions": {
|
||||
"title": "Extensions"
|
||||
},
|
||||
"attachments": {
|
||||
"subtitle": "Configure document attachments, size limits, and retrieval behavior.",
|
||||
"featureTitle": "Feature",
|
||||
"enable": "Enable Attachments",
|
||||
"enableDesc": "Allow uploading and indexing documents for retrieval.",
|
||||
"limitsTitle": "Limits",
|
||||
"maxFile": "Max File Size (MB)",
|
||||
"maxFileDesc": "Maximum size per file. Enforced at upload and processing time.",
|
||||
"retrievalTitle": "Retrieval",
|
||||
"topK": "Top-K",
|
||||
"topKDesc": "Maximum citations to return.",
|
||||
"threshold": "Affinity Threshold",
|
||||
"thresholdDesc": "Minimum similarity score (0-1). Only used for linear cosine search, not ANN.",
|
||||
"searchMode": "Vector Search Mode",
|
||||
"searchModeDesc": "Choose between sqlite-vec ANN, linear cosine, or auto.",
|
||||
"searchModeAuto": "Auto (recommended)",
|
||||
"searchModeAnn": "ANN (sqlite-vec)",
|
||||
"searchModeLinear": "Linear",
|
||||
"chunkingTitle": "Chunking",
|
||||
"chunkSize": "Chunk Size (tokens)",
|
||||
"chunkSizeDesc": "Approximate max tokens per chunk for embeddings.",
|
||||
"chunkOverlap": "Overlap (tokens)",
|
||||
"chunkOverlapDesc": "Token overlap between consecutive chunks."
|
||||
},
|
||||
"dialogs": {
|
||||
"changeDataFolder": {
|
||||
"title": "Change Data Folder Location",
|
||||
|
||||
@ -26,6 +26,7 @@ import { Route as SettingsHttpsProxyImport } from './routes/settings/https-proxy
|
||||
import { Route as SettingsHardwareImport } from './routes/settings/hardware'
|
||||
import { Route as SettingsGeneralImport } from './routes/settings/general'
|
||||
import { Route as SettingsExtensionsImport } from './routes/settings/extensions'
|
||||
import { Route as SettingsAttachmentsImport } from './routes/settings/attachments'
|
||||
import { Route as SettingsAppearanceImport } from './routes/settings/appearance'
|
||||
import { Route as ProjectProjectIdImport } from './routes/project/$projectId'
|
||||
import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs'
|
||||
@ -126,6 +127,12 @@ const SettingsExtensionsRoute = SettingsExtensionsImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const SettingsAttachmentsRoute = SettingsAttachmentsImport.update({
|
||||
id: '/settings/attachments',
|
||||
path: '/settings/attachments',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const SettingsAppearanceRoute = SettingsAppearanceImport.update({
|
||||
id: '/settings/appearance',
|
||||
path: '/settings/appearance',
|
||||
@ -229,6 +236,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SettingsAppearanceImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/settings/attachments': {
|
||||
id: '/settings/attachments'
|
||||
path: '/settings/attachments'
|
||||
fullPath: '/settings/attachments'
|
||||
preLoaderRoute: typeof SettingsAttachmentsImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/settings/extensions': {
|
||||
id: '/settings/extensions'
|
||||
path: '/settings/extensions'
|
||||
@ -341,6 +355,7 @@ export interface FileRoutesByFullPath {
|
||||
'/local-api-server/logs': typeof LocalApiServerLogsRoute
|
||||
'/project/$projectId': typeof ProjectProjectIdRoute
|
||||
'/settings/appearance': typeof SettingsAppearanceRoute
|
||||
'/settings/attachments': typeof SettingsAttachmentsRoute
|
||||
'/settings/extensions': typeof SettingsExtensionsRoute
|
||||
'/settings/general': typeof SettingsGeneralRoute
|
||||
'/settings/hardware': typeof SettingsHardwareRoute
|
||||
@ -366,6 +381,7 @@ export interface FileRoutesByTo {
|
||||
'/local-api-server/logs': typeof LocalApiServerLogsRoute
|
||||
'/project/$projectId': typeof ProjectProjectIdRoute
|
||||
'/settings/appearance': typeof SettingsAppearanceRoute
|
||||
'/settings/attachments': typeof SettingsAttachmentsRoute
|
||||
'/settings/extensions': typeof SettingsExtensionsRoute
|
||||
'/settings/general': typeof SettingsGeneralRoute
|
||||
'/settings/hardware': typeof SettingsHardwareRoute
|
||||
@ -392,6 +408,7 @@ export interface FileRoutesById {
|
||||
'/local-api-server/logs': typeof LocalApiServerLogsRoute
|
||||
'/project/$projectId': typeof ProjectProjectIdRoute
|
||||
'/settings/appearance': typeof SettingsAppearanceRoute
|
||||
'/settings/attachments': typeof SettingsAttachmentsRoute
|
||||
'/settings/extensions': typeof SettingsExtensionsRoute
|
||||
'/settings/general': typeof SettingsGeneralRoute
|
||||
'/settings/hardware': typeof SettingsHardwareRoute
|
||||
@ -419,6 +436,7 @@ export interface FileRouteTypes {
|
||||
| '/local-api-server/logs'
|
||||
| '/project/$projectId'
|
||||
| '/settings/appearance'
|
||||
| '/settings/attachments'
|
||||
| '/settings/extensions'
|
||||
| '/settings/general'
|
||||
| '/settings/hardware'
|
||||
@ -443,6 +461,7 @@ export interface FileRouteTypes {
|
||||
| '/local-api-server/logs'
|
||||
| '/project/$projectId'
|
||||
| '/settings/appearance'
|
||||
| '/settings/attachments'
|
||||
| '/settings/extensions'
|
||||
| '/settings/general'
|
||||
| '/settings/hardware'
|
||||
@ -467,6 +486,7 @@ export interface FileRouteTypes {
|
||||
| '/local-api-server/logs'
|
||||
| '/project/$projectId'
|
||||
| '/settings/appearance'
|
||||
| '/settings/attachments'
|
||||
| '/settings/extensions'
|
||||
| '/settings/general'
|
||||
| '/settings/hardware'
|
||||
@ -493,6 +513,7 @@ export interface RootRouteChildren {
|
||||
LocalApiServerLogsRoute: typeof LocalApiServerLogsRoute
|
||||
ProjectProjectIdRoute: typeof ProjectProjectIdRoute
|
||||
SettingsAppearanceRoute: typeof SettingsAppearanceRoute
|
||||
SettingsAttachmentsRoute: typeof SettingsAttachmentsRoute
|
||||
SettingsExtensionsRoute: typeof SettingsExtensionsRoute
|
||||
SettingsGeneralRoute: typeof SettingsGeneralRoute
|
||||
SettingsHardwareRoute: typeof SettingsHardwareRoute
|
||||
@ -518,6 +539,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
LocalApiServerLogsRoute: LocalApiServerLogsRoute,
|
||||
ProjectProjectIdRoute: ProjectProjectIdRoute,
|
||||
SettingsAppearanceRoute: SettingsAppearanceRoute,
|
||||
SettingsAttachmentsRoute: SettingsAttachmentsRoute,
|
||||
SettingsExtensionsRoute: SettingsExtensionsRoute,
|
||||
SettingsGeneralRoute: SettingsGeneralRoute,
|
||||
SettingsHardwareRoute: SettingsHardwareRoute,
|
||||
@ -552,6 +574,7 @@ export const routeTree = rootRoute
|
||||
"/local-api-server/logs",
|
||||
"/project/$projectId",
|
||||
"/settings/appearance",
|
||||
"/settings/attachments",
|
||||
"/settings/extensions",
|
||||
"/settings/general",
|
||||
"/settings/hardware",
|
||||
@ -592,6 +615,9 @@ export const routeTree = rootRoute
|
||||
"/settings/appearance": {
|
||||
"filePath": "settings/appearance.tsx"
|
||||
},
|
||||
"/settings/attachments": {
|
||||
"filePath": "settings/attachments.tsx"
|
||||
},
|
||||
"/settings/extensions": {
|
||||
"filePath": "settings/extensions.tsx"
|
||||
},
|
||||
|
||||
233
web-app/src/routes/settings/attachments.tsx
Normal file
233
web-app/src/routes/settings/attachments.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import SettingsMenu from '@/containers/SettingsMenu'
|
||||
import HeaderPage from '@/containers/HeaderPage'
|
||||
import { Card, CardItem } from '@/containers/Card'
|
||||
import { useAttachments } from '@/hooks/useAttachments'
|
||||
import type { SettingComponentProps } from '@janhq/core'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
import { PlatformGuard } from '@/lib/platform/PlatformGuard'
|
||||
import { DynamicControllerSetting } from '@/containers/dynamicControllerSetting'
|
||||
import { PlatformFeature } from '@/lib/platform/types'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
export const Route = createFileRoute('/settings/attachments')({
|
||||
component: AttachmentsSettings,
|
||||
})
|
||||
|
||||
// Helper to extract constraints from settingsDefs
|
||||
function getConstraints(def: SettingComponentProps) {
|
||||
const props = def.controllerProps as Partial<{ min: number; max: number; step: number }>
|
||||
return {
|
||||
min: props.min ?? -Infinity,
|
||||
max: props.max ?? Infinity,
|
||||
step: props.step ?? 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to validate and clamp numeric values
|
||||
function clampValue(val: unknown, def: SettingComponentProps, currentValue: number): number {
|
||||
const num = typeof val === 'number' ? val : Number(val)
|
||||
if (!Number.isFinite(num)) return currentValue
|
||||
const { min, max, step } = getConstraints(def)
|
||||
// Floor integer values, preserve decimals for threshold
|
||||
const adjusted = step >= 1 ? Math.floor(num) : num
|
||||
return Math.max(min, Math.min(max, adjusted))
|
||||
}
|
||||
|
||||
function AttachmentsSettings() {
|
||||
const { t } = useTranslation()
|
||||
const hookDefs = useAttachments((s) => s.settingsDefs)
|
||||
const loadDefs = useAttachments((s) => s.loadSettingsDefs)
|
||||
const [defs, setDefs] = useState<SettingComponentProps[]>([])
|
||||
|
||||
// Load schema from extension via the hook once
|
||||
useEffect(() => {
|
||||
loadDefs()
|
||||
}, [loadDefs])
|
||||
|
||||
// Mirror the hook's defs into local state for display
|
||||
useEffect(() => {
|
||||
setDefs(hookDefs)
|
||||
}, [hookDefs])
|
||||
|
||||
// Track values for live updates
|
||||
const sel = useAttachments(
|
||||
useShallow((s) => ({
|
||||
enabled: s.enabled,
|
||||
maxFileSizeMB: s.maxFileSizeMB,
|
||||
retrievalLimit: s.retrievalLimit,
|
||||
retrievalThreshold: s.retrievalThreshold,
|
||||
chunkSizeTokens: s.chunkSizeTokens,
|
||||
overlapTokens: s.overlapTokens,
|
||||
searchMode: s.searchMode,
|
||||
setEnabled: s.setEnabled,
|
||||
setMaxFileSizeMB: s.setMaxFileSizeMB,
|
||||
setRetrievalLimit: s.setRetrievalLimit,
|
||||
setRetrievalThreshold: s.setRetrievalThreshold,
|
||||
setChunkSizeTokens: s.setChunkSizeTokens,
|
||||
setOverlapTokens: s.setOverlapTokens,
|
||||
setSearchMode: s.setSearchMode,
|
||||
}))
|
||||
)
|
||||
|
||||
// Local state for inputs to allow intermediate values while typing
|
||||
const [localValues, setLocalValues] = useState<Record<string, string | number | boolean | string[]>>({})
|
||||
|
||||
// Debounce timers
|
||||
const timersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current
|
||||
return () => {
|
||||
Object.values(timers).forEach(clearTimeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Debounced setter with validation
|
||||
const debouncedSet = useCallback((key: string, val: unknown, def: SettingComponentProps) => {
|
||||
// Clear existing timer for this key
|
||||
if (timersRef.current[key]) {
|
||||
clearTimeout(timersRef.current[key])
|
||||
}
|
||||
|
||||
// Set local value immediately for responsive UI
|
||||
setLocalValues((prev) => ({
|
||||
...prev,
|
||||
[key]: val as string | number | boolean | string[]
|
||||
}))
|
||||
|
||||
// For non-numeric inputs, apply immediately without debounce
|
||||
if (key === 'enabled' || key === 'search_mode') {
|
||||
if (key === 'enabled') sel.setEnabled(!!val)
|
||||
else if (key === 'search_mode') sel.setSearchMode(val as 'auto' | 'ann' | 'linear')
|
||||
return
|
||||
}
|
||||
|
||||
// For numeric inputs, debounce the validation and sync
|
||||
timersRef.current[key] = setTimeout(() => {
|
||||
const currentStoreValue = (() => {
|
||||
switch (key) {
|
||||
case 'max_file_size_mb': return sel.maxFileSizeMB
|
||||
case 'retrieval_limit': return sel.retrievalLimit
|
||||
case 'retrieval_threshold': return sel.retrievalThreshold
|
||||
case 'chunk_size_tokens': return sel.chunkSizeTokens
|
||||
case 'overlap_tokens': return sel.overlapTokens
|
||||
default: return 0
|
||||
}
|
||||
})()
|
||||
|
||||
const validated = clampValue(val, def, currentStoreValue)
|
||||
|
||||
switch (key) {
|
||||
case 'max_file_size_mb':
|
||||
sel.setMaxFileSizeMB(validated)
|
||||
break
|
||||
case 'retrieval_limit':
|
||||
sel.setRetrievalLimit(validated)
|
||||
break
|
||||
case 'retrieval_threshold':
|
||||
sel.setRetrievalThreshold(validated)
|
||||
break
|
||||
case 'chunk_size_tokens':
|
||||
sel.setChunkSizeTokens(validated)
|
||||
break
|
||||
case 'overlap_tokens':
|
||||
sel.setOverlapTokens(validated)
|
||||
break
|
||||
}
|
||||
|
||||
// Update local value to validated one
|
||||
setLocalValues((prev) => ({
|
||||
...prev,
|
||||
[key]: validated as string | number | boolean | string[]
|
||||
}))
|
||||
}, 500) // 500ms debounce
|
||||
}, [sel])
|
||||
|
||||
return (
|
||||
<PlatformGuard feature={PlatformFeature.ATTACHMENTS}>
|
||||
<div className="flex flex-col h-full pb-[calc(env(safe-area-inset-bottom)+env(safe-area-inset-top))]">
|
||||
<HeaderPage>
|
||||
<h1 className="font-medium">{t('common:settings')}</h1>
|
||||
</HeaderPage>
|
||||
<div className="flex h-full w-full flex-col sm:flex-row">
|
||||
<SettingsMenu />
|
||||
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
|
||||
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
|
||||
<Card title={t('common:attachments') || 'Attachments'}>
|
||||
{defs.map((d) => {
|
||||
// Use local value if typing, else use store value
|
||||
const storeValue = (() => {
|
||||
switch (d.key) {
|
||||
case 'enabled':
|
||||
return sel.enabled
|
||||
case 'max_file_size_mb':
|
||||
return sel.maxFileSizeMB
|
||||
case 'retrieval_limit':
|
||||
return sel.retrievalLimit
|
||||
case 'retrieval_threshold':
|
||||
return sel.retrievalThreshold
|
||||
case 'chunk_size_tokens':
|
||||
return sel.chunkSizeTokens
|
||||
case 'overlap_tokens':
|
||||
return sel.overlapTokens
|
||||
case 'search_mode':
|
||||
return sel.searchMode
|
||||
default:
|
||||
return d?.controllerProps?.value
|
||||
}
|
||||
})()
|
||||
|
||||
const currentValue =
|
||||
localValues[d.key] !== undefined ? localValues[d.key] : storeValue
|
||||
|
||||
// Convert to DynamicControllerSetting compatible props
|
||||
const baseProps = d.controllerProps
|
||||
const normalizedValue: string | number | boolean = (() => {
|
||||
if (Array.isArray(currentValue)) {
|
||||
return currentValue.join(',')
|
||||
}
|
||||
return currentValue as string | number | boolean
|
||||
})()
|
||||
|
||||
const props = {
|
||||
value: normalizedValue,
|
||||
placeholder: 'placeholder' in baseProps ? baseProps.placeholder : undefined,
|
||||
type: 'type' in baseProps ? baseProps.type : undefined,
|
||||
options: 'options' in baseProps ? baseProps.options : undefined,
|
||||
input_actions: 'inputActions' in baseProps ? baseProps.inputActions : undefined,
|
||||
rows: undefined,
|
||||
min: 'min' in baseProps ? baseProps.min : undefined,
|
||||
max: 'max' in baseProps ? baseProps.max : undefined,
|
||||
step: 'step' in baseProps ? baseProps.step : undefined,
|
||||
recommended: 'recommended' in baseProps ? baseProps.recommended : undefined,
|
||||
}
|
||||
|
||||
const title = d.titleKey ? t(d.titleKey) : d.title
|
||||
const description = d.descriptionKey ? t(d.descriptionKey) : d.description
|
||||
|
||||
return (
|
||||
<CardItem
|
||||
key={d.key}
|
||||
title={title}
|
||||
description={description}
|
||||
actions={
|
||||
<DynamicControllerSetting
|
||||
controllerType={d.controllerType}
|
||||
controllerProps={props}
|
||||
onChange={(val) => debouncedSet(d.key, val, d)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformGuard>
|
||||
)
|
||||
}
|
||||
@ -27,6 +27,10 @@ import { DefaultPathService } from './path/default'
|
||||
import { DefaultCoreService } from './core/default'
|
||||
import { DefaultDeepLinkService } from './deeplink/default'
|
||||
import { DefaultProjectsService } from './projects/default'
|
||||
import { DefaultRAGService } from './rag/default'
|
||||
import type { RAGService } from './rag/types'
|
||||
import { DefaultUploadsService } from './uploads/default'
|
||||
import type { UploadsService } from './uploads/types'
|
||||
|
||||
// Import service types
|
||||
import type { ThemeService } from './theme/types'
|
||||
@ -70,6 +74,8 @@ export interface ServiceHub {
|
||||
core(): CoreService
|
||||
deeplink(): DeepLinkService
|
||||
projects(): ProjectsService
|
||||
rag(): RAGService
|
||||
uploads(): UploadsService
|
||||
}
|
||||
|
||||
class PlatformServiceHub implements ServiceHub {
|
||||
@ -92,6 +98,8 @@ class PlatformServiceHub implements ServiceHub {
|
||||
private coreService: CoreService = new DefaultCoreService()
|
||||
private deepLinkService: DeepLinkService = new DefaultDeepLinkService()
|
||||
private projectsService: ProjectsService = new DefaultProjectsService()
|
||||
private ragService: RAGService = new DefaultRAGService()
|
||||
private uploadsService: UploadsService = new DefaultUploadsService()
|
||||
private initialized = false
|
||||
|
||||
/**
|
||||
@ -343,6 +351,16 @@ class PlatformServiceHub implements ServiceHub {
|
||||
this.ensureInitialized()
|
||||
return this.projectsService
|
||||
}
|
||||
|
||||
rag(): RAGService {
|
||||
this.ensureInitialized()
|
||||
return this.ragService
|
||||
}
|
||||
|
||||
uploads(): UploadsService {
|
||||
this.ensureInitialized()
|
||||
return this.uploadsService
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeServiceHub(): Promise<ServiceHub> {
|
||||
|
||||
50
web-app/src/services/rag/default.ts
Normal file
50
web-app/src/services/rag/default.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import type { RAGService } from './types'
|
||||
import type { MCPTool, MCPToolCallResult, RAGExtension } from '@janhq/core'
|
||||
import { ExtensionManager } from '@/lib/extension'
|
||||
import { ExtensionTypeEnum } from '@janhq/core'
|
||||
|
||||
export class DefaultRAGService implements RAGService {
|
||||
async getTools(): Promise<MCPTool[]> {
|
||||
const ext = ExtensionManager.getInstance().get<RAGExtension>(ExtensionTypeEnum.RAG)
|
||||
if (ext?.getTools) {
|
||||
try {
|
||||
return await ext.getTools()
|
||||
} catch (e) {
|
||||
console.error('RAG extension getTools failed:', e)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
async callTool(args: { toolName: string; arguments: Record<string, unknown>; threadId?: string }): Promise<MCPToolCallResult> {
|
||||
const ext = ExtensionManager.getInstance().get<RAGExtension>(ExtensionTypeEnum.RAG)
|
||||
if (!ext?.callTool) {
|
||||
return { error: 'RAG extension not available', content: [{ type: 'text', text: 'RAG extension not available' }] }
|
||||
}
|
||||
try {
|
||||
// Inject thread context when scope requires it
|
||||
type ToolCallArgs = Record<string, unknown> & { scope?: string; thread_id?: string }
|
||||
const a: ToolCallArgs = { ...(args.arguments as Record<string, unknown>) }
|
||||
if (!a.scope) a.scope = 'thread'
|
||||
if (a.scope === 'thread' && !a.thread_id) {
|
||||
a.thread_id = args.threadId
|
||||
}
|
||||
return await ext.callTool(args.toolName, a)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return { error: msg, content: [{ type: 'text', text: `RAG tool failed: ${msg}` }] }
|
||||
}
|
||||
}
|
||||
|
||||
async getToolNames(): Promise<string[]> {
|
||||
try {
|
||||
const ext = ExtensionManager.getInstance().get<RAGExtension>(ExtensionTypeEnum.RAG)
|
||||
if (ext?.getToolNames) return await ext.getToolNames()
|
||||
// No fallback to full tool list; return empty to save bandwidth
|
||||
return []
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch RAG tool names:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
11
web-app/src/services/rag/types.ts
Normal file
11
web-app/src/services/rag/types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { MCPTool } from '@janhq/core'
|
||||
import type { MCPToolCallResult } from '@janhq/core'
|
||||
|
||||
export interface RAGService {
|
||||
// Return tools exposed by RAG-related extensions (e.g., retrieval, list_attachments)
|
||||
getTools(): Promise<MCPTool[]>
|
||||
// Execute a RAG tool call (retrieve, list_attachments)
|
||||
callTool(args: { toolName: string; arguments: object; threadId?: string }): Promise<MCPToolCallResult>
|
||||
// Convenience: return tool names for routing
|
||||
getToolNames(): Promise<string[]>
|
||||
}
|
||||
32
web-app/src/services/uploads/default.ts
Normal file
32
web-app/src/services/uploads/default.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { UploadsService, UploadResult } from './types'
|
||||
import type { Attachment } from '@/types/attachment'
|
||||
import { ulid } from 'ulidx'
|
||||
import { ExtensionManager } from '@/lib/extension'
|
||||
import { ExtensionTypeEnum, type RAGExtension, type IngestAttachmentsResult } from '@janhq/core'
|
||||
|
||||
export class DefaultUploadsService implements UploadsService {
|
||||
async ingestImage(_threadId: string, attachment: Attachment): Promise<UploadResult> {
|
||||
if (attachment.type !== 'image') throw new Error('ingestImage: attachment is not image')
|
||||
// Placeholder upload flow; swap for real API call when backend is ready
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
return { id: ulid() }
|
||||
}
|
||||
|
||||
async ingestFileAttachment(threadId: string, attachment: Attachment): Promise<UploadResult> {
|
||||
if (attachment.type !== 'document') throw new Error('ingestFileAttachment: attachment is not document')
|
||||
const ext = ExtensionManager.getInstance().get<RAGExtension>(ExtensionTypeEnum.RAG)
|
||||
if (!ext?.ingestAttachments) throw new Error('RAG extension not available')
|
||||
const res: IngestAttachmentsResult = await ext.ingestAttachments(threadId, [
|
||||
{ path: attachment.path!, name: attachment.name, type: attachment.fileType, size: attachment.size },
|
||||
])
|
||||
const files = res.files
|
||||
if (Array.isArray(files) && files[0]?.id) {
|
||||
return {
|
||||
id: files[0].id,
|
||||
size: typeof files[0].size === 'number' ? Number(files[0].size) : undefined,
|
||||
chunkCount: typeof files[0].chunk_count === 'number' ? Number(files[0].chunk_count) : undefined,
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to resolve ingested attachment id')
|
||||
}
|
||||
}
|
||||
16
web-app/src/services/uploads/types.ts
Normal file
16
web-app/src/services/uploads/types.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Attachment } from '@/types/attachment'
|
||||
|
||||
export type UploadResult = {
|
||||
id: string
|
||||
url?: string
|
||||
size?: number
|
||||
chunkCount?: number
|
||||
}
|
||||
|
||||
export interface UploadsService {
|
||||
// Ingest an image attachment (placeholder upload)
|
||||
ingestImage(threadId: string, attachment: Attachment): Promise<UploadResult>
|
||||
|
||||
// Ingest a document attachment in the context of a thread
|
||||
ingestFileAttachment(threadId: string, attachment: Attachment): Promise<UploadResult>
|
||||
}
|
||||
57
web-app/src/types/attachment.ts
Normal file
57
web-app/src/types/attachment.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Unified attachment type for both images and documents
|
||||
*/
|
||||
export type Attachment = {
|
||||
name: string
|
||||
type: 'image' | 'document'
|
||||
|
||||
// Common fields
|
||||
size?: number
|
||||
chunkCount?: number
|
||||
processing?: boolean
|
||||
processed?: boolean
|
||||
error?: string
|
||||
|
||||
// For images (before upload)
|
||||
base64?: string
|
||||
dataUrl?: string
|
||||
mimeType?: string
|
||||
|
||||
// For documents (local files)
|
||||
path?: string
|
||||
fileType?: string // e.g., 'pdf', 'docx'
|
||||
|
||||
// After processing (images uploaded, documents ingested)
|
||||
id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create image attachment
|
||||
*/
|
||||
export function createImageAttachment(data: {
|
||||
name: string
|
||||
base64: string
|
||||
dataUrl: string
|
||||
mimeType: string
|
||||
size: number
|
||||
}): Attachment {
|
||||
return {
|
||||
...data,
|
||||
type: 'image',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create document attachment
|
||||
*/
|
||||
export function createDocumentAttachment(data: {
|
||||
name: string
|
||||
path: string
|
||||
fileType?: string
|
||||
size?: number
|
||||
}): Attachment {
|
||||
return {
|
||||
...data,
|
||||
type: 'document',
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user