Merge pull request #3158 from janhq/dev

chore: release 0.5.2 to main
This commit is contained in:
Van Pham 2024-07-15 13:50:36 +07:00 committed by GitHub
commit 506cbb8834
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 638 additions and 306 deletions

View File

@ -55,20 +55,6 @@ const unlinkSync = (...args: any[]) => globalThis.core.api?.unlinkSync(...args)
*/ */
const appendFileSync = (...args: any[]) => globalThis.core.api?.appendFileSync(...args) const appendFileSync = (...args: any[]) => globalThis.core.api?.appendFileSync(...args)
/**
* Synchronizes a file from a source path to a destination path.
* @param {string} src - The source path of the file to be synchronized.
* @param {string} dest - The destination path where the file will be synchronized to.
* @returns {Promise<any>} - A promise that resolves when the file has been successfully synchronized.
*/
const syncFile: (src: string, dest: string) => Promise<any> = (src, dest) =>
globalThis.core.api?.syncFile(src, dest)
/**
* Copy file sync.
*/
const copyFileSync = (...args: any[]) => globalThis.core.api?.copyFileSync(...args)
const copyFile: (src: string, dest: string) => Promise<void> = (src, dest) => const copyFile: (src: string, dest: string) => Promise<void> = (src, dest) =>
globalThis.core.api?.copyFile(src, dest) globalThis.core.api?.copyFile(src, dest)
@ -95,9 +81,7 @@ export const fs = {
rm, rm,
unlinkSync, unlinkSync,
appendFileSync, appendFileSync,
copyFileSync,
copyFile, copyFile,
syncFile,
fileStat, fileStat,
writeBlob, writeBlob,
} }

View File

@ -1,6 +1,6 @@
import { resolve, sep } from 'path' import { resolve, sep } from 'path'
import { DownloadEvent } from '../../../types/api' import { DownloadEvent } from '../../../types/api'
import { normalizeFilePath } from '../../helper/path' import { normalizeFilePath, validatePath } from '../../helper/path'
import { getJanDataFolderPath } from '../../helper' import { getJanDataFolderPath } from '../../helper'
import { DownloadManager } from '../../helper/download' import { DownloadManager } from '../../helper/download'
import { createWriteStream, renameSync } from 'fs' import { createWriteStream, renameSync } from 'fs'
@ -37,6 +37,7 @@ export class Downloader implements Processor {
const modelId = array.pop() ?? '' const modelId = array.pop() ?? ''
const destination = resolve(getJanDataFolderPath(), normalizedPath) const destination = resolve(getJanDataFolderPath(), normalizedPath)
validatePath(destination)
const rq = request({ url, strictSSL, proxy }) const rq = request({ url, strictSSL, proxy })
// Put request to download manager instance // Put request to download manager instance

View File

@ -1,5 +1,5 @@
import { join } from 'path' import { join, resolve } from 'path'
import { normalizeFilePath } from '../../helper/path' import { normalizeFilePath, validatePath } from '../../helper/path'
import { getJanDataFolderPath } from '../../helper' import { getJanDataFolderPath } from '../../helper'
import { Processor } from './Processor' import { Processor } from './Processor'
import fs from 'fs' import fs from 'fs'
@ -15,17 +15,29 @@ export class FileSystem implements Processor {
process(route: string, ...args: any): any { process(route: string, ...args: any): any {
const instance = this as any const instance = this as any
const func = instance[route] const func = instance[route]
if (func) { if (func) {
return func(...args) return func(...args)
} else { } else {
return import(FileSystem.moduleName).then((mdl) => return import(FileSystem.moduleName).then((mdl) =>
mdl[route]( mdl[route](
...args.map((arg: any) => { ...args.map((arg: any, index: number) => {
return typeof arg === 'string' && if(index !== 0) {
(arg.startsWith(`file:/`) || arg.startsWith(`file:\\`)) return arg
? join(getJanDataFolderPath(), normalizeFilePath(arg)) }
: arg if (index === 0 && typeof arg !== 'string') {
throw new Error(`Invalid argument ${JSON.stringify(args)}`)
}
const path =
(arg.startsWith(`file:/`) || arg.startsWith(`file:\\`))
? join(getJanDataFolderPath(), normalizeFilePath(arg))
: arg
if(path.startsWith(`http://`) || path.startsWith(`https://`)) {
return path
}
const absolutePath = resolve(path)
validatePath(absolutePath)
return absolutePath
}) })
) )
) )
@ -42,8 +54,11 @@ export class FileSystem implements Processor {
path = join(getJanDataFolderPath(), normalizeFilePath(path)) path = join(getJanDataFolderPath(), normalizeFilePath(path))
} }
const absolutePath = resolve(path)
validatePath(absolutePath)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fs.rm(path, { recursive: true, force: true }, (err) => { fs.rm(absolutePath, { recursive: true, force: true }, (err) => {
if (err) { if (err) {
reject(err) reject(err)
} else { } else {
@ -63,8 +78,11 @@ export class FileSystem implements Processor {
path = join(getJanDataFolderPath(), normalizeFilePath(path)) path = join(getJanDataFolderPath(), normalizeFilePath(path))
} }
const absolutePath = resolve(path)
validatePath(absolutePath)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fs.mkdir(path, { recursive: true }, (err) => { fs.mkdir(absolutePath, { recursive: true }, (err) => {
if (err) { if (err) {
reject(err) reject(err)
} else { } else {
@ -73,4 +91,5 @@ export class FileSystem implements Processor {
}) })
}) })
} }
} }

View File

@ -1,6 +1,6 @@
import { join } from 'path' import { join } from 'path'
import fs from 'fs' import fs from 'fs'
import { appResourcePath, normalizeFilePath } from '../../helper/path' import { appResourcePath, normalizeFilePath, validatePath } from '../../helper/path'
import { getJanDataFolderPath, getJanDataFolderPath as getPath } from '../../helper' import { getJanDataFolderPath, getJanDataFolderPath as getPath } from '../../helper'
import { Processor } from './Processor' import { Processor } from './Processor'
import { FileStat } from '../../../types' import { FileStat } from '../../../types'
@ -18,19 +18,6 @@ export class FSExt implements Processor {
return func(...args) return func(...args)
} }
// Handles the 'syncFile' IPC event. This event is triggered to synchronize a file from a source path to a destination path.
syncFile(src: string, dest: string) {
const reflect = require('@alumna/reflect')
return reflect({
src,
dest,
recursive: true,
delete: false,
overwrite: true,
errorOnExist: false,
})
}
// Handles the 'getJanDataFolderPath' IPC event. This event is triggered to get the user space path. // Handles the 'getJanDataFolderPath' IPC event. This event is triggered to get the user space path.
getJanDataFolderPath() { getJanDataFolderPath() {
return Promise.resolve(getPath()) return Promise.resolve(getPath())
@ -70,14 +57,18 @@ export class FSExt implements Processor {
writeBlob(path: string, data: any) { writeBlob(path: string, data: any) {
try { try {
const normalizedPath = normalizeFilePath(path) const normalizedPath = normalizeFilePath(path)
const dataBuffer = Buffer.from(data, 'base64') const dataBuffer = Buffer.from(data, 'base64')
fs.writeFileSync(join(getJanDataFolderPath(), normalizedPath), dataBuffer) const writePath = join(getJanDataFolderPath(), normalizedPath)
validatePath(writePath)
fs.writeFileSync(writePath, dataBuffer)
} catch (err) { } catch (err) {
console.error(`writeFile ${path} result: ${err}`) console.error(`writeFile ${path} result: ${err}`)
} }
} }
copyFile(src: string, dest: string): Promise<void> { copyFile(src: string, dest: string): Promise<void> {
validatePath(dest)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fs.copyFile(src, dest, (err) => { fs.copyFile(src, dest, (err) => {
if (err) { if (err) {

View File

@ -7,7 +7,8 @@ import childProcess from 'child_process'
const configurationFileName = 'settings.json' const configurationFileName = 'settings.json'
// TODO: do no specify app name in framework module // TODO: do no specify app name in framework module
const defaultJanDataFolder = join(os.homedir(), 'jan') // TODO: do not default the os.homedir
const defaultJanDataFolder = join(os?.homedir() || '', 'jan')
const defaultAppConfig: AppConfiguration = { const defaultAppConfig: AppConfiguration = {
data_folder: defaultJanDataFolder, data_folder: defaultJanDataFolder,
quick_ask: false, quick_ask: false,

View File

@ -11,7 +11,7 @@ export class DownloadManager {
// store the download information with key is model id // store the download information with key is model id
public downloadProgressMap: Record<string, DownloadState> = {} public downloadProgressMap: Record<string, DownloadState> = {}
// store the download infomation with key is normalized file path // store the download information with key is normalized file path
public downloadInfo: Record<string, DownloadState> = {} public downloadInfo: Record<string, DownloadState> = {}
constructor() { constructor() {

View File

@ -1,4 +1,5 @@
import { join } from 'path' import { join, resolve } from 'path'
import { getJanDataFolderPath } from './config'
/** /**
* Normalize file path * Normalize file path
@ -33,3 +34,11 @@ export async function appResourcePath(): Promise<string> {
// server // server
return join(global.core.appPath(), '../../..') return join(global.core.appPath(), '../../..')
} }
export function validatePath(path: string) {
const janDataFolderPath = getJanDataFolderPath()
const absolutePath = resolve(__dirname, path)
if (!absolutePath.startsWith(janDataFolderPath)) {
throw new Error(`Invalid path: ${absolutePath}`)
}
}

View File

@ -90,7 +90,6 @@ export enum ExtensionRoute {
} }
export enum FileSystemRoute { export enum FileSystemRoute {
appendFileSync = 'appendFileSync', appendFileSync = 'appendFileSync',
copyFileSync = 'copyFileSync',
unlinkSync = 'unlinkSync', unlinkSync = 'unlinkSync',
existsSync = 'existsSync', existsSync = 'existsSync',
readdirSync = 'readdirSync', readdirSync = 'readdirSync',
@ -100,7 +99,6 @@ export enum FileSystemRoute {
writeFileSync = 'writeFileSync', writeFileSync = 'writeFileSync',
} }
export enum FileManagerRoute { export enum FileManagerRoute {
syncFile = 'syncFile',
copyFile = 'copyFile', copyFile = 'copyFile',
getJanDataFolderPath = 'getJanDataFolderPath', getJanDataFolderPath = 'getJanDataFolderPath',
getResourcePath = 'getResourcePath', getResourcePath = 'getResourcePath',

View File

@ -6,6 +6,7 @@
export type AssistantTool = { export type AssistantTool = {
type: string type: string
enabled: boolean enabled: boolean
useTimeWeightedRetriever?: boolean
settings: any settings: any
} }

View File

@ -83,6 +83,8 @@ export enum MessageStatus {
export enum ErrorCode { export enum ErrorCode {
InvalidApiKey = 'invalid_api_key', InvalidApiKey = 'invalid_api_key',
AuthenticationError = 'authentication_error',
InsufficientQuota = 'insufficient_quota', InsufficientQuota = 'insufficient_quota',
InvalidRequestError = 'invalid_request_error', InvalidRequestError = 'invalid_request_error',

View File

@ -3,8 +3,9 @@
* @module preload * @module preload
*/ */
import { APIEvents, APIRoutes } from '@janhq/core/node' import { APIEvents, APIRoutes, AppConfiguration, getAppConfigurations, updateAppConfiguration } from '@janhq/core/node'
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
import { readdirSync } from 'fs'
const interfaces: { [key: string]: (...args: any[]) => any } = {} const interfaces: { [key: string]: (...args: any[]) => any } = {}
@ -12,7 +13,9 @@ const interfaces: { [key: string]: (...args: any[]) => any } = {}
APIRoutes.forEach((method) => { APIRoutes.forEach((method) => {
// For each method, create a function on the interfaces object // For each method, create a function on the interfaces object
// This function invokes the method on the ipcRenderer with any provided arguments // This function invokes the method on the ipcRenderer with any provided arguments
interfaces[method] = (...args: any[]) => ipcRenderer.invoke(method, ...args) interfaces[method] = (...args: any[]) => ipcRenderer.invoke(method, ...args)
}) })
// Loop over each method in APIEvents // Loop over each method in APIEvents
@ -22,6 +25,33 @@ APIEvents.forEach((method) => {
// The handler for the event is provided as an argument to the function // The handler for the event is provided as an argument to the function
interfaces[method] = (handler: any) => ipcRenderer.on(method, handler) interfaces[method] = (handler: any) => ipcRenderer.on(method, handler)
}) })
interfaces['changeDataFolder'] = async path => {
const appConfiguration: AppConfiguration = await ipcRenderer.invoke('getAppConfigurations')
const currentJanDataFolder = appConfiguration.data_folder
appConfiguration.data_folder = path
const reflect = require('@alumna/reflect')
const { err } = await reflect({
src: currentJanDataFolder,
dest: path,
recursive: true,
delete: false,
overwrite: true,
errorOnExist: false,
})
if (err) {
console.error(err)
throw err
}
await ipcRenderer.invoke('updateAppConfiguration', appConfiguration)
}
interfaces['isDirectoryEmpty'] = async path => {
const dirChildren = await readdirSync(path)
return dirChildren.filter((x) => x !== '.DS_Store').length === 0
}
// Expose the 'interfaces' object in the main world under the name 'electronAPI' // Expose the 'interfaces' object in the main world under the name 'electronAPI'
// This allows the renderer process to access these methods directly // This allows the renderer process to access these methods directly
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {

View File

@ -126,6 +126,7 @@ export default class JanAssistantExtension extends AssistantExtension {
{ {
type: 'retrieval', type: 'retrieval',
enabled: false, enabled: false,
useTimeWeightedRetriever: false,
settings: { settings: {
top_k: 2, top_k: 2,
chunk_size: 1024, chunk_size: 1024,

View File

@ -11,13 +11,14 @@ export function toolRetrievalUpdateTextSplitter(
export async function toolRetrievalIngestNewDocument( export async function toolRetrievalIngestNewDocument(
file: string, file: string,
model: string, model: string,
engine: string engine: string,
useTimeWeighted: boolean
) { ) {
const filePath = path.join(getJanDataFolderPath(), normalizeFilePath(file)) const filePath = path.join(getJanDataFolderPath(), normalizeFilePath(file))
const threadPath = path.dirname(filePath.replace('files', '')) const threadPath = path.dirname(filePath.replace('files', ''))
retrieval.updateEmbeddingEngine(model, engine) retrieval.updateEmbeddingEngine(model, engine)
return retrieval return retrieval
.ingestAgentKnowledge(filePath, `${threadPath}/memory`) .ingestAgentKnowledge(filePath, `${threadPath}/memory`, useTimeWeighted)
.catch((err) => { .catch((err) => {
console.error(err) console.error(err)
}) })
@ -33,8 +34,11 @@ export async function toolRetrievalLoadThreadMemory(threadId: string) {
}) })
} }
export async function toolRetrievalQueryResult(query: string) { export async function toolRetrievalQueryResult(
return retrieval.generateResult(query).catch((err) => { query: string,
useTimeWeighted: boolean = false
) {
return retrieval.generateResult(query, useTimeWeighted).catch((err) => {
console.error(err) console.error(err)
}) })
} }

View File

@ -2,11 +2,16 @@ import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter'
import { formatDocumentsAsString } from 'langchain/util/document' import { formatDocumentsAsString } from 'langchain/util/document'
import { PDFLoader } from 'langchain/document_loaders/fs/pdf' import { PDFLoader } from 'langchain/document_loaders/fs/pdf'
import { TimeWeightedVectorStoreRetriever } from 'langchain/retrievers/time_weighted'
import { MemoryVectorStore } from 'langchain/vectorstores/memory'
import { HNSWLib } from 'langchain/vectorstores/hnswlib' import { HNSWLib } from 'langchain/vectorstores/hnswlib'
import { OpenAIEmbeddings } from 'langchain/embeddings/openai' import { OpenAIEmbeddings } from 'langchain/embeddings/openai'
import { readEmbeddingEngine } from './engine' import { readEmbeddingEngine } from './engine'
import path from 'path'
export class Retrieval { export class Retrieval {
public chunkSize: number = 100 public chunkSize: number = 100
public chunkOverlap?: number = 0 public chunkOverlap?: number = 0
@ -15,8 +20,25 @@ export class Retrieval {
private embeddingModel?: OpenAIEmbeddings = undefined private embeddingModel?: OpenAIEmbeddings = undefined
private textSplitter?: RecursiveCharacterTextSplitter private textSplitter?: RecursiveCharacterTextSplitter
// to support time-weighted retrieval
private timeWeightedVectorStore: MemoryVectorStore
private timeWeightedretriever: any | TimeWeightedVectorStoreRetriever
constructor(chunkSize: number = 4000, chunkOverlap: number = 200) { constructor(chunkSize: number = 4000, chunkOverlap: number = 200) {
this.updateTextSplitter(chunkSize, chunkOverlap) this.updateTextSplitter(chunkSize, chunkOverlap)
// declare time-weighted retriever and storage
this.timeWeightedVectorStore = new MemoryVectorStore(
new OpenAIEmbeddings(
{ openAIApiKey: 'nitro-embedding' },
{ basePath: 'http://127.0.0.1:3928/v1' }
)
)
this.timeWeightedretriever = new TimeWeightedVectorStoreRetriever({
vectorStore: this.timeWeightedVectorStore,
memoryStream: [],
searchKwargs: 2,
})
} }
public updateTextSplitter(chunkSize: number, chunkOverlap: number): void { public updateTextSplitter(chunkSize: number, chunkOverlap: number): void {
@ -44,11 +66,15 @@ export class Retrieval {
openAIApiKey: settings.api_key, openAIApiKey: settings.api_key,
}) })
} }
// update time-weighted embedding model
this.timeWeightedVectorStore.embeddings = this.embeddingModel
} }
public ingestAgentKnowledge = async ( public ingestAgentKnowledge = async (
filePath: string, filePath: string,
memoryPath: string memoryPath: string,
useTimeWeighted: boolean
): Promise<any> => { ): Promise<any> => {
const loader = new PDFLoader(filePath, { const loader = new PDFLoader(filePath, {
splitPages: true, splitPages: true,
@ -57,6 +83,13 @@ export class Retrieval {
const doc = await loader.load() const doc = await loader.load()
const docs = await this.textSplitter!.splitDocuments(doc) const docs = await this.textSplitter!.splitDocuments(doc)
const vectorStore = await HNSWLib.fromDocuments(docs, this.embeddingModel) const vectorStore = await HNSWLib.fromDocuments(docs, this.embeddingModel)
// add documents with metadata by using the time-weighted retriever in order to support time-weighted retrieval
if (useTimeWeighted && this.timeWeightedretriever) {
await (
this.timeWeightedretriever as TimeWeightedVectorStoreRetriever
).addDocuments(docs)
}
return vectorStore.save(memoryPath) return vectorStore.save(memoryPath)
} }
@ -67,10 +100,25 @@ export class Retrieval {
return Promise.resolve() return Promise.resolve()
} }
public generateResult = async (query: string): Promise<string> => { public generateResult = async (
query: string,
useTimeWeighted: boolean
): Promise<string> => {
if (useTimeWeighted) {
if (!this.timeWeightedretriever) {
return Promise.resolve(' ')
}
// use invoke because getRelevantDocuments is deprecated
const relevantDocs = await this.timeWeightedretriever.invoke(query)
const serializedDoc = formatDocumentsAsString(relevantDocs)
return Promise.resolve(serializedDoc)
}
if (!this.retriever) { if (!this.retriever) {
return Promise.resolve(' ') return Promise.resolve(' ')
} }
// should use invoke(query) because getRelevantDocuments is deprecated
const relevantDocs = await this.retriever.getRelevantDocuments(query) const relevantDocs = await this.retriever.getRelevantDocuments(query)
const serializedDoc = formatDocumentsAsString(relevantDocs) const serializedDoc = formatDocumentsAsString(relevantDocs)
return Promise.resolve(serializedDoc) return Promise.resolve(serializedDoc)

View File

@ -37,7 +37,8 @@ export class RetrievalTool extends InferenceTool {
'toolRetrievalIngestNewDocument', 'toolRetrievalIngestNewDocument',
docFile, docFile,
data.model?.id, data.model?.id,
data.model?.engine data.model?.engine,
tool?.useTimeWeightedRetriever ?? false
) )
} else { } else {
return Promise.resolve(data) return Promise.resolve(data)
@ -78,7 +79,8 @@ export class RetrievalTool extends InferenceTool {
const retrievalResult = await executeOnMain( const retrievalResult = await executeOnMain(
NODE, NODE,
'toolRetrievalQueryResult', 'toolRetrievalQueryResult',
prompt prompt,
tool?.useTimeWeightedRetriever ?? false
) )
console.debug('toolRetrievalQueryResult', retrievalResult) console.debug('toolRetrievalQueryResult', retrievalResult)

View File

@ -1,7 +1,7 @@
{ {
"name": "@janhq/inference-anthropic-extension", "name": "@janhq/inference-anthropic-extension",
"productName": "Anthropic Inference Engine", "productName": "Anthropic Inference Engine",
"version": "1.0.1", "version": "1.0.2",
"description": "This extension enables Anthropic chat completion API calls", "description": "This extension enables Anthropic chat completion API calls",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/module.js", "module": "dist/module.js",

View File

@ -19,10 +19,7 @@
}, },
"metadata": { "metadata": {
"author": "Anthropic", "author": "Anthropic",
"tags": [ "tags": ["General", "Big Context Length"]
"General",
"Big Context Length"
]
}, },
"engine": "anthropic" "engine": "anthropic"
}, },
@ -46,10 +43,7 @@
}, },
"metadata": { "metadata": {
"author": "Anthropic", "author": "Anthropic",
"tags": [ "tags": ["General", "Big Context Length"]
"General",
"Big Context Length"
]
}, },
"engine": "anthropic" "engine": "anthropic"
}, },
@ -73,11 +67,32 @@
}, },
"metadata": { "metadata": {
"author": "Anthropic", "author": "Anthropic",
"tags": [ "tags": ["General", "Big Context Length"]
"General", },
"Big Context Length" "engine": "anthropic"
] },
{
"sources": [
{
"url": "https://www.anthropic.com/"
}
],
"id": "claude-3-5-sonnet-20240620",
"object": "model",
"name": "Claude 3.5 Sonnet",
"version": "1.0",
"description": "Claude 3.5 Sonnet raises the industry bar for intelligence, outperforming competitor models and Claude 3 Opus on a wide range of evaluations, with the speed and cost of our mid-tier model, Claude 3 Sonnet.",
"format": "api",
"settings": {},
"parameters": {
"max_tokens": 4096,
"temperature": 0.7,
"stream": true
},
"metadata": {
"author": "Anthropic",
"tags": ["General", "Big Context Length"]
}, },
"engine": "anthropic" "engine": "anthropic"
} }
] ]

View File

@ -1 +1 @@
0.4.13 0.4.20

View File

@ -1,7 +1,7 @@
{ {
"name": "@janhq/inference-cortex-extension", "name": "@janhq/inference-cortex-extension",
"productName": "Cortex Inference Engine", "productName": "Cortex Inference Engine",
"version": "1.0.13", "version": "1.0.14",
"description": "This extension embeds cortex.cpp, a lightweight inference engine written in C++. See https://nitro.jan.ai.\nAdditional dependencies could be installed to run without Cuda Toolkit installation.", "description": "This extension embeds cortex.cpp, a lightweight inference engine written in C++. See https://nitro.jan.ai.\nAdditional dependencies could be installed to run without Cuda Toolkit installation.",
"main": "dist/index.js", "main": "dist/index.js",
"node": "dist/node/index.cjs.js", "node": "dist/node/index.cjs.js",
@ -10,7 +10,7 @@
"scripts": { "scripts": {
"test": "jest", "test": "jest",
"build": "tsc --module commonjs && rollup -c rollup.config.ts", "build": "tsc --module commonjs && rollup -c rollup.config.ts",
"downloadnitro:linux": "CORTEX_VERSION=$(cat ./bin/version.txt) && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64-avx2.tar.gz -e --strip 1 -o ./bin/linux-cpu && chmod +x ./bin/linux-cpu/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64-cuda-12-0.tar.gz -e --strip 1 -o ./bin/linux-cuda-12-0 && chmod +x ./bin/linux-cuda-12-0/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64-cuda-11-7.tar.gz -e --strip 1 -o ./bin/linux-cuda-11-7 && chmod +x ./bin/linux-cuda-11-7/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64-vulkan.tar.gz -e --strip 1 -o ./bin/linux-vulkan && chmod +x ./bin/linux-vulkan/cortex-cpp", "downloadnitro:linux": "CORTEX_VERSION=$(cat ./bin/version.txt) && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64-avx2.tar.gz -e --strip 1 -o ./bin/linux-cpu && chmod +x ./bin/linux-cpu/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64-avx2-cuda-12-0.tar.gz -e --strip 1 -o ./bin/linux-cuda-12-0 && chmod +x ./bin/linux-cuda-12-0/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64-avx2-cuda-11-7.tar.gz -e --strip 1 -o ./bin/linux-cuda-11-7 && chmod +x ./bin/linux-cuda-11-7/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-linux-amd64-vulkan.tar.gz -e --strip 1 -o ./bin/linux-vulkan && chmod +x ./bin/linux-vulkan/cortex-cpp",
"downloadnitro:darwin": "CORTEX_VERSION=$(cat ./bin/version.txt) && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-mac-arm64.tar.gz -o ./bin/ && mkdir -p ./bin/mac-arm64 && tar -zxvf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-arm64.tar.gz --strip-components=1 -C ./bin/mac-arm64 && rm -rf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-arm64.tar.gz && chmod +x ./bin/mac-arm64/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-mac-amd64.tar.gz -o ./bin/ && mkdir -p ./bin/mac-amd64 && tar -zxvf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-amd64.tar.gz --strip-components=1 -C ./bin/mac-amd64 && rm -rf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-amd64.tar.gz && chmod +x ./bin/mac-amd64/cortex-cpp", "downloadnitro:darwin": "CORTEX_VERSION=$(cat ./bin/version.txt) && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-mac-arm64.tar.gz -o ./bin/ && mkdir -p ./bin/mac-arm64 && tar -zxvf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-arm64.tar.gz --strip-components=1 -C ./bin/mac-arm64 && rm -rf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-arm64.tar.gz && chmod +x ./bin/mac-arm64/cortex-cpp && download https://github.com/janhq/cortex/releases/download/v${CORTEX_VERSION}/cortex-cpp-${CORTEX_VERSION}-mac-amd64.tar.gz -o ./bin/ && mkdir -p ./bin/mac-amd64 && tar -zxvf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-amd64.tar.gz --strip-components=1 -C ./bin/mac-amd64 && rm -rf ./bin/cortex-cpp-${CORTEX_VERSION}-mac-amd64.tar.gz && chmod +x ./bin/mac-amd64/cortex-cpp",
"downloadnitro:win32": "download.bat", "downloadnitro:win32": "download.bat",
"downloadnitro": "run-script-os", "downloadnitro": "run-script-os",
@ -53,7 +53,7 @@
"fetch-retry": "^5.0.6", "fetch-retry": "^5.0.6",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"tcp-port-used": "^1.0.2", "tcp-port-used": "^1.0.2",
"terminate": "^2.6.1", "terminate": "2.6.1",
"ulidx": "^2.3.0" "ulidx": "^2.3.0"
}, },
"engines": { "engines": {

View File

@ -8,7 +8,7 @@
"id": "gemma-2b", "id": "gemma-2b",
"object": "model", "object": "model",
"name": "Gemma 2B Q4", "name": "Gemma 2B Q4",
"version": "1.2", "version": "1.3",
"description": "Gemma is built from the same technology with Google's Gemini.", "description": "Gemma is built from the same technology with Google's Gemini.",
"format": "gguf", "format": "gguf",
"settings": { "settings": {
@ -22,7 +22,7 @@
"top_p": 0.95, "top_p": 0.95,
"stream": true, "stream": true,
"max_tokens": 8192, "max_tokens": 8192,
"stop": [], "stop": ["<end_of_turn>"],
"frequency_penalty": 0, "frequency_penalty": 0,
"presence_penalty": 0 "presence_penalty": 0
}, },

View File

@ -1,7 +1,7 @@
{ {
"name": "@janhq/model-extension", "name": "@janhq/model-extension",
"productName": "Model Management", "productName": "Model Management",
"version": "1.0.32", "version": "1.0.33",
"description": "Model Management Extension provides model exploration and seamless downloads", "description": "Model Management Extension provides model exploration and seamless downloads",
"main": "dist/index.js", "main": "dist/index.js",
"node": "dist/node/index.cjs.js", "node": "dist/node/index.cjs.js",

View File

@ -383,7 +383,12 @@ export default class JanModelExtension extends ModelExtension {
// model binaries (sources) are absolute path & exist // model binaries (sources) are absolute path & exist
const existFiles = await Promise.all( const existFiles = await Promise.all(
model.sources.map((source) => fs.existsSync(source.url)) model.sources.map(
(source) =>
// Supposed to be a local file url
!source.url.startsWith(`http://`) &&
!source.url.startsWith(`https://`)
)
) )
if (existFiles.every((exist) => exist)) return true if (existFiles.every((exist) => exist)) return true

View File

@ -41,12 +41,13 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => {
case ErrorCode.Unknown: case ErrorCode.Unknown:
return 'Apologies, somethings amiss!' return 'Apologies, somethings amiss!'
case ErrorCode.InvalidApiKey: case ErrorCode.InvalidApiKey:
case ErrorCode.AuthenticationError:
case ErrorCode.InvalidRequestError: case ErrorCode.InvalidRequestError:
return ( return (
<span data-testid="invalid-API-key-error"> <span data-testid="invalid-API-key-error">
Invalid API key. Please check your API key from{' '} Invalid API key. Please check your API key from{' '}
<button <button
className="font-medium text-[hsla(var(--app-link))]" className="font-medium text-[hsla(var(--app-link))] underline"
onClick={() => { onClick={() => {
setMainState(MainViewState.Settings) setMainState(MainViewState.Settings)
@ -102,7 +103,7 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => {
<p> <p>
Jans in beta. Access&nbsp; Jans in beta. Access&nbsp;
<span <span
className="cursor-pointer text-[hsla(var(--app-link))]" className="cursor-pointer text-[hsla(var(--app-link))] underline"
onClick={() => setModalTroubleShooting(true)} onClick={() => setModalTroubleShooting(true)}
> >
troubleshooting assistance troubleshooting assistance

View File

@ -3,12 +3,20 @@ import { Fragment, useEffect, useState } from 'react'
import { Progress } from '@janhq/joi' import { Progress } from '@janhq/joi'
import { useClickOutside } from '@janhq/joi' import { useClickOutside } from '@janhq/joi'
import { useAtom, useAtomValue } from 'jotai' import { useAtom, useAtomValue } from 'jotai'
import { MonitorIcon, XIcon, ChevronDown, ChevronUp } from 'lucide-react' import {
MonitorIcon,
XIcon,
ChevronDown,
ChevronUp,
FolderOpenIcon,
} from 'lucide-react'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import useGetSystemResources from '@/hooks/useGetSystemResources' import useGetSystemResources from '@/hooks/useGetSystemResources'
import { usePath } from '@/hooks/usePath'
import { toGibibytes } from '@/utils/converter' import { toGibibytes } from '@/utils/converter'
import TableActiveModel from './TableActiveModel' import TableActiveModel from './TableActiveModel'
@ -28,6 +36,7 @@ const SystemMonitor = () => {
const usedRam = useAtomValue(usedRamAtom) const usedRam = useAtomValue(usedRamAtom)
const cpuUsage = useAtomValue(cpuUsageAtom) const cpuUsage = useAtomValue(cpuUsageAtom)
const gpus = useAtomValue(gpusAtom) const gpus = useAtomValue(gpusAtom)
const { onRevealInFinder } = usePath()
const [showFullScreen, setShowFullScreen] = useState(false) const [showFullScreen, setShowFullScreen] = useState(false)
const ramUtilitized = useAtomValue(ramUtilitizedAtom) const ramUtilitized = useAtomValue(ramUtilitizedAtom)
const [showSystemMonitorPanel, setShowSystemMonitorPanel] = useAtom( const [showSystemMonitorPanel, setShowSystemMonitorPanel] = useAtom(
@ -64,7 +73,7 @@ const SystemMonitor = () => {
<div <div
ref={setControl} ref={setControl}
className={twMerge( className={twMerge(
'flex cursor-pointer items-center gap-x-1 rounded-l px-1 py-0.5 hover:bg-[hsla(var(--secondary-bg))]', 'flex cursor-pointer items-center gap-x-1 rounded px-1 py-0.5 hover:bg-[hsla(var(--secondary-bg))]',
showSystemMonitorPanel && 'bg-[hsla(var(--secondary-bg))]' showSystemMonitorPanel && 'bg-[hsla(var(--secondary-bg))]'
)} )}
onClick={() => { onClick={() => {
@ -89,6 +98,12 @@ const SystemMonitor = () => {
Running Models Running Models
</h6> </h6>
<div className="unset-drag flex cursor-pointer items-center gap-x-2"> <div className="unset-drag flex cursor-pointer items-center gap-x-2">
<div
className="flex cursor-pointer items-center gap-x-1 rounded px-1 py-0.5 hover:bg-[hsla(var(--secondary-bg))]"
onClick={() => onRevealInFinder('Logs')}
>
<FolderOpenIcon size={12} /> App Log
</div>
{showFullScreen ? ( {showFullScreen ? (
<ChevronDown <ChevronDown
size={20} size={20}

View File

@ -1,7 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { Modal } from '@janhq/joi' import { Button, Modal } from '@janhq/joi'
import { atom, useAtom } from 'jotai' import { atom, useAtom } from 'jotai'
import { Maximize2 } from 'lucide-react'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import ServerLogs from '@/containers/ServerLogs' import ServerLogs from '@/containers/ServerLogs'
@ -17,6 +18,7 @@ const ModalTroubleShooting = () => {
modalTroubleShootingAtom modalTroubleShootingAtom
) )
const [isTabActive, setIsTabActivbe] = useState(0) const [isTabActive, setIsTabActivbe] = useState(0)
const [showLogFullSize, setshowLogFullSize] = useState(false)
return ( return (
<Modal <Modal
@ -26,59 +28,76 @@ const ModalTroubleShooting = () => {
title="Troubleshooting Assistance" title="Troubleshooting Assistance"
content={ content={
<div className="flex h-full w-full flex-col overflow-hidden text-sm"> <div className="flex h-full w-full flex-col overflow-hidden text-sm">
<div className="flex-shrink-0"> <div className="mb-3 flex-shrink-0">
<p className="text-[hsla(var(--text-secondary)] mt-2 pr-3 leading-relaxed"> <p className="text-[hsla(var(--text-secondary)] mt-2 pr-3 leading-relaxed">
{`We're here to help! Your report is crucial for debugging and shaping {`We're here to help! Your report is crucial for debugging and shaping
the next version. Heres how you can report & get further support:`} the next version. Heres how you can report & get further support:`}
</p> </p>
</div> </div>
<div className="my-3 rounded-lg border border-[hsla(var(--app-border))] p-4 shadow"> {!showLogFullSize && (
<h2 className="font-semibold">Step 1</h2> <div className="mb-3 rounded-lg border border-[hsla(var(--app-border))] p-4 shadow">
<p className="text-[hsla(var(--text-secondary)] mt-1"> <h2 className="font-semibold">Step 1</h2>
Follow our&nbsp;
<a
href="https://jan.ai/guides/troubleshooting"
target="_blank"
className="text-[hsla(var(--app-link))] hover:underline"
>
troubleshooting guide
</a>
&nbsp;for step-by-step solutions.
</p>
</div>
<div className="rounded-lg border border-[hsla(var(--app-border))] pb-2 pt-4 shadow">
<div className="px-4">
<h2 className="font-semibold">Step 2</h2>
<p className="text-[hsla(var(--text-secondary)] mt-1"> <p className="text-[hsla(var(--text-secondary)] mt-1">
{`If you can't find what you need in our troubleshooting guide, feel Follow our&nbsp;
free reach out to us for extra help:`} <a
href="https://jan.ai/guides/troubleshooting"
target="_blank"
className="text-[hsla(var(--app-link))] hover:underline"
>
troubleshooting guide
</a>
&nbsp;for step-by-step solutions.
</p> </p>
<ul className="mt-2 list-disc space-y-2 pl-6">
<li>
<p className="font-medium">
Copy your 2-hour logs & device specifications provided
below.{' '}
</p>
</li>
<li>
<p className="font-medium">
Go to our&nbsp;
<a
href="https://discord.gg/AsJ8krTT3N"
target="_blank"
className="text-[hsla(var(--app-link))] hover:underline"
>
Discord
</a>
&nbsp;& send it to #🆘|get-help channel for further support.
</p>
</li>
</ul>
</div> </div>
<div className="relative flex h-full w-full flex-col pt-4"> )}
<div className="border-y border-[hsla(var(--app-border))] px-4 py-2 ">
<div
className={twMerge(
'rounded-lg border border-[hsla(var(--app-border))] pb-2 shadow',
!showLogFullSize && 'pt-4'
)}
>
{!showLogFullSize && (
<div className="px-4">
<h2 className="font-semibold">Step 2</h2>
<p className="text-[hsla(var(--text-secondary)] mt-1">
{`If you can't find what you need in our troubleshooting guide, feel
free reach out to us for extra help:`}
</p>
<ul className="mt-2 list-disc space-y-2 pl-6">
<li>
<p className="font-medium">
Copy your 2-hour logs & device specifications provided
below.{' '}
</p>
</li>
<li>
<p className="font-medium">
Go to our&nbsp;
<a
href="https://discord.gg/AsJ8krTT3N"
target="_blank"
className="text-[hsla(var(--app-link))] hover:underline"
>
Discord
</a>
&nbsp;& send it to #🆘|get-help channel for further
support.
</p>
</li>
</ul>
</div>
)}
<div
className={twMerge('relative flex h-full w-full flex-col pt-4')}
>
<div
className={twMerge(
'border-y border-[hsla(var(--app-border))] px-4 py-2'
)}
>
<ul className="inline-flex space-x-2 rounded-lg px-1"> <ul className="inline-flex space-x-2 rounded-lg px-1">
{logOption.map((name, i) => { {logOption.map((name, i) => {
return ( return (
@ -103,7 +122,20 @@ const ModalTroubleShooting = () => {
})} })}
</ul> </ul>
</div> </div>
<div className="max-h-[160px] overflow-y-auto"> <div
className={twMerge(
'max-h-[180px] overflow-y-auto',
showLogFullSize && 'max-h-[400px]'
)}
>
<Button
theme="icon"
className="absolute right-4 top-20"
autoFocus={false}
onClick={() => setshowLogFullSize(!showLogFullSize)}
>
<Maximize2 size={14} />
</Button>
<div <div
className={twMerge('hidden', isTabActive === 0 && 'block')} className={twMerge('hidden', isTabActive === 0 && 'block')}
> >

View File

@ -1,4 +1,4 @@
import { useState, useMemo, useEffect, useCallback } from 'react' import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { InferenceEngine } from '@janhq/core' import { InferenceEngine } from '@janhq/core'
import { Badge, Input, ScrollArea, Select, useClickOutside } from '@janhq/joi' import { Badge, Input, ScrollArea, Select, useClickOutside } from '@janhq/joi'
@ -70,7 +70,7 @@ const ModelDropdown = ({
const downloadStates = useAtomValue(modelDownloadStateAtom) const downloadStates = useAtomValue(modelDownloadStateAtom)
const setThreadModelParams = useSetAtom(setThreadModelParamsAtom) const setThreadModelParams = useSetAtom(setThreadModelParamsAtom)
const { updateModelParameter } = useUpdateModelParameters() const { updateModelParameter } = useUpdateModelParameters()
const searchInputRef = useRef<HTMLInputElement>(null)
const configuredModels = useAtomValue(configuredModelsAtom) const configuredModels = useAtomValue(configuredModelsAtom)
const featuredModel = configuredModels.filter((x) => const featuredModel = configuredModels.filter((x) =>
x.metadata.tags.includes('Featured') x.metadata.tags.includes('Featured')
@ -83,7 +83,7 @@ const ModelDropdown = ({
const filteredDownloadedModels = useMemo( const filteredDownloadedModels = useMemo(
() => () =>
downloadedModels configuredModels
.filter((e) => .filter((e) =>
e.name.toLowerCase().includes(searchText.toLowerCase().trim()) e.name.toLowerCase().includes(searchText.toLowerCase().trim())
) )
@ -104,10 +104,31 @@ const ModelDropdown = ({
) )
} }
}) })
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name))
[downloadedModels, searchText, searchFilter] .sort((a, b) => {
const aInDownloadedModels = downloadedModels.some(
(item) => item.id === a.id
)
const bInDownloadedModels = downloadedModels.some(
(item) => item.id === b.id
)
if (aInDownloadedModels && !bInDownloadedModels) {
return -1
} else if (!aInDownloadedModels && bInDownloadedModels) {
return 1
} else {
return 0
}
}),
[configuredModels, searchText, searchFilter, downloadedModels]
) )
useEffect(() => {
if (open && searchInputRef.current) {
searchInputRef.current.focus()
}
}, [open])
useEffect(() => { useEffect(() => {
if (!activeThread) return if (!activeThread) return
let model = downloadedModels.find( let model = downloadedModels.find(
@ -258,6 +279,7 @@ const ModelDropdown = ({
<Input <Input
placeholder="Search" placeholder="Search"
value={searchText} value={searchText}
ref={searchInputRef}
className="rounded-none border-x-0 border-t-0 focus-within:ring-0 hover:border-b-[hsla(var(--app-border))]" className="rounded-none border-x-0 border-t-0 focus-within:ring-0 hover:border-b-[hsla(var(--app-border))]"
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
suffixIcon={ suffixIcon={
@ -290,58 +312,58 @@ const ModelDropdown = ({
</div> </div>
</div> </div>
<ScrollArea className="h-[calc(100%-36px)] w-full"> <ScrollArea className="h-[calc(100%-36px)] w-full">
{filteredDownloadedModels.filter( {searchFilter !== 'remote' && (
(x) => x.engine === InferenceEngine.nitro
).length !== 0 ? (
<div className="relative w-full"> <div className="relative w-full">
<div className="mt-2"> <div className="mt-2">
<h6 className="mb-1 mt-3 px-3 font-medium text-[hsla(var(--text-secondary))]"> <h6 className="mb-1 mt-3 px-3 font-medium text-[hsla(var(--text-secondary))]">
Cortex Cortex
</h6> </h6>
</div>
{filteredDownloadedModels
.filter((x) => {
if (searchText.length === 0) {
return downloadedModels.find((c) => c.id === x.id)
} else {
return x
}
})
.filter((x) => x.engine === InferenceEngine.nitro).length !==
0 ? (
<ul className="pb-2"> <ul className="pb-2">
{filteredDownloadedModels {filteredDownloadedModels
? filteredDownloadedModels ? filteredDownloadedModels
.filter((x) => x.engine === InferenceEngine.nitro) .filter((x) => x.engine === InferenceEngine.nitro)
.map((model) => { .filter((x) => {
return ( if (searchText.length === 0) {
<li return downloadedModels.find((c) => c.id === x.id)
key={model.id} } else {
className="flex cursor-pointer items-center gap-2 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]" return x
onClick={() => onClickModelItem(model.id)} }
>
<p className="line-clamp-1" title={model.name}>
{model.name}
</p>
<ModelLabel metadata={model.metadata} compact />
</li>
)
}) })
: null} .map((model) => {
</ul>
</div>
</div>
) : (
<>
{searchFilter !== 'remote' && (
<div className="relative w-full">
<div className="mt-2">
<h6 className="mb-1 mt-3 px-3 font-medium text-[hsla(var(--text-secondary))]">
Cortex
</h6>
{searchText.length === 0 ? (
<ul className="pb-2">
{featuredModel.map((model) => {
const isDownloading = downloadingModels.some( const isDownloading = downloadingModels.some(
(md) => md.id === model.id (md) => md.id === model.id
) )
const isdDownloaded = downloadedModels.some(
(c) => c.id === model.id
)
return ( return (
<li <li
key={model.id} key={model.id}
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]" className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
onClick={() => {
if (isdDownloaded) {
onClickModelItem(model.id)
}
}}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p <p
className="line-clamp-1 text-[hsla(var(--text-secondary))]" className={twMerge(
'line-clamp-1',
!isdDownloaded &&
'text-[hsla(var(--text-secondary))]'
)}
title={model.name} title={model.name}
> >
{model.name} {model.name}
@ -352,10 +374,12 @@ const ModelDropdown = ({
/> />
</div> </div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]"> <div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
<span className="font-medium"> {!isdDownloaded && (
{toGibibytes(model.metadata.size)} <span className="font-medium">
</span> {toGibibytes(model.metadata.size)}
{!isDownloading ? ( </span>
)}
{!isDownloading && !isdDownloaded ? (
<DownloadCloudIcon <DownloadCloudIcon
size={18} size={18}
className="cursor-pointer text-[hsla(var(--app-link))]" className="cursor-pointer text-[hsla(var(--app-link))]"
@ -382,76 +406,61 @@ const ModelDropdown = ({
</div> </div>
</li> </li>
) )
})} })
</ul> : null}
) : ( </ul>
<ul className="pb-2"> ) : (
{configuredModels <ul className="pb-2">
.filter((x) => x.engine === InferenceEngine.nitro) {featuredModel.map((model) => {
.filter((e) => const isDownloading = downloadingModels.some(
e.name (md) => md.id === model.id
.toLowerCase() )
.includes(searchText.toLowerCase().trim()) return (
) <li
.map((model) => { key={model.id}
const isDownloading = downloadingModels.some( className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
(md) => md.id === model.id >
) <div className="flex items-center gap-2">
return ( <p
<li className="line-clamp-1 text-[hsla(var(--text-secondary))]"
key={model.id} title={model.name}
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]" >
> {model.name}
<div className="flex items-center gap-2"> </p>
<p <ModelLabel metadata={model.metadata} compact />
className="line-clamp-1 text-[hsla(var(--text-secondary))]" </div>
title={model.name} <div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
> <span className="font-medium">
{model.name} {toGibibytes(model.metadata.size)}
</p> </span>
<ModelLabel {!isDownloading ? (
metadata={model.metadata} <DownloadCloudIcon
compact size={18}
/> className="cursor-pointer text-[hsla(var(--app-link))]"
</div> onClick={() => downloadModel(model)}
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]"> />
<span className="font-medium"> ) : (
{toGibibytes(model.metadata.size)} Object.values(downloadStates)
</span> .filter((x) => x.modelId === model.id)
{!isDownloading ? ( .map((item) => (
<DownloadCloudIcon <ProgressCircle
size={18} key={item.modelId}
className="cursor-pointer text-[hsla(var(--app-link))]" percentage={
onClick={() => downloadModel(model)} formatDownloadPercentage(item?.percent, {
/> hidePercentage: true,
) : ( }) as number
Object.values(downloadStates) }
.filter((x) => x.modelId === model.id) size={100}
.map((item) => ( />
<ProgressCircle ))
key={item.modelId} )}
percentage={ </div>
formatDownloadPercentage( </li>
item?.percent, )
{ })}
hidePercentage: true, </ul>
}
) as number
}
size={100}
/>
))
)}
</div>
</li>
)
})}
</ul>
)}
</div>
</div>
)} )}
</> </div>
)} )}
{groupByEngine.map((engine, i) => { {groupByEngine.map((engine, i) => {

View File

@ -10,6 +10,7 @@ export const janSettingScreenAtom = atom<SettingScreen[]>([])
export const THEME = 'themeAppearance' export const THEME = 'themeAppearance'
export const REDUCE_TRANSPARENT = 'reduceTransparent' export const REDUCE_TRANSPARENT = 'reduceTransparent'
export const SPELL_CHECKING = 'spellChecking'
export const themesOptionsAtom = atom<{ name: string; value: string }[]>([]) export const themesOptionsAtom = atom<{ name: string; value: string }[]>([])
export const janThemesPathAtom = atom<string | undefined>(undefined) export const janThemesPathAtom = atom<string | undefined>(undefined)
export const selectedThemeIdAtom = atomWithStorage<string>(THEME, '') export const selectedThemeIdAtom = atomWithStorage<string>(THEME, '')
@ -18,3 +19,4 @@ export const reduceTransparentAtom = atomWithStorage<boolean>(
REDUCE_TRANSPARENT, REDUCE_TRANSPARENT,
false false
) )
export const spellCheckAtom = atomWithStorage<boolean>(SPELL_CHECKING, true)

View File

@ -28,7 +28,7 @@
"lucide-react": "^0.291.0", "lucide-react": "^0.291.0",
"marked": "^9.1.2", "marked": "^9.1.2",
"marked-highlight": "^2.0.6", "marked-highlight": "^2.0.6",
"marked-katex-extension": "^5.0.1", "marked-katex-extension": "^5.0.2",
"next": "14.2.3", "next": "14.2.3",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"postcss": "8.4.31", "postcss": "8.4.31",

View File

@ -1,6 +1,6 @@
import { Fragment, useCallback, useState } from 'react' import { Fragment, useCallback, useState } from 'react'
import { fs, AppConfiguration, isSubdirectory } from '@janhq/core' import { AppConfiguration, isSubdirectory } from '@janhq/core'
import { Button, Input } from '@janhq/joi' import { Button, Input } from '@janhq/joi'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { PencilIcon, FolderOpenIcon } from 'lucide-react' import { PencilIcon, FolderOpenIcon } from 'lucide-react'
@ -51,11 +51,10 @@ const DataFolder = () => {
return return
} }
const newDestChildren: string[] = await fs.readdirSync(destFolder) const isEmpty: boolean =
const isNotEmpty = await window.core?.api?.isDirectoryEmpty(destFolder)
newDestChildren.filter((x) => x !== '.DS_Store').length > 0
if (isNotEmpty) { if (!isEmpty) {
setDestinationPath(destFolder) setDestinationPath(destFolder)
showDestNotEmptyConfirm(true) showDestNotEmptyConfirm(true)
return return
@ -74,16 +73,7 @@ const DataFolder = () => {
if (!destinationPath) return if (!destinationPath) return
try { try {
setShowLoader(true) setShowLoader(true)
const appConfiguration: AppConfiguration = await window.core?.api?.changeDataFolder(destinationPath)
await window.core?.api?.getAppConfigurations()
const currentJanDataFolder = appConfiguration.data_folder
appConfiguration.data_folder = destinationPath
const { err } = await fs.syncFile(currentJanDataFolder, destinationPath)
if (err) throw err
await window.core?.api?.updateAppConfiguration(appConfiguration)
console.debug(
`File sync finished from ${currentJanDataFolder} to ${destinationPath}`
)
localStorage.setItem(SUCCESS_SET_NEW_DESTINATION, 'true') localStorage.setItem(SUCCESS_SET_NEW_DESTINATION, 'true')
setTimeout(() => { setTimeout(() => {
setShowLoader(false) setShowLoader(false)

View File

@ -3,13 +3,14 @@ import { useCallback } from 'react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { fs, joinPath } from '@janhq/core' import { fs, joinPath } from '@janhq/core'
import { Button, Select } from '@janhq/joi' import { Button, Select, Switch } from '@janhq/joi'
import { useAtom, useAtomValue } from 'jotai' import { useAtom, useAtomValue } from 'jotai'
import { import {
janThemesPathAtom, janThemesPathAtom,
reduceTransparentAtom, reduceTransparentAtom,
selectedThemeIdAtom, selectedThemeIdAtom,
spellCheckAtom,
themeDataAtom, themeDataAtom,
themesOptionsAtom, themesOptionsAtom,
} from '@/helpers/atoms/Setting.atom' } from '@/helpers/atoms/Setting.atom'
@ -23,6 +24,7 @@ export default function AppearanceOptions() {
const [reduceTransparent, setReduceTransparent] = useAtom( const [reduceTransparent, setReduceTransparent] = useAtom(
reduceTransparentAtom reduceTransparentAtom
) )
const [spellCheck, setSpellCheck] = useAtom(spellCheckAtom)
const handleClickTheme = useCallback( const handleClickTheme = useCallback(
async (e: string) => { async (e: string) => {
@ -55,7 +57,7 @@ export default function AppearanceOptions() {
<h6 className="font-semibold capitalize">Appearance</h6> <h6 className="font-semibold capitalize">Appearance</h6>
</div> </div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]"> <p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Select of customize your interface color scheme Select a color theme
</p> </p>
</div> </div>
<Select <Select
@ -70,9 +72,6 @@ export default function AppearanceOptions() {
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<h6 className="font-semibold capitalize">Interface theme</h6> <h6 className="font-semibold capitalize">Interface theme</h6>
</div> </div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Choose the type of the interface
</p>
</div> </div>
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<Button <Button
@ -87,15 +86,29 @@ export default function AppearanceOptions() {
variant={reduceTransparent ? 'outline' : 'solid'} variant={reduceTransparent ? 'outline' : 'solid'}
onClick={() => setReduceTransparent(false)} onClick={() => setReduceTransparent(false)}
> >
Transparent Transparency
</Button> </Button>
</div> </div>
{/* <Switch
checked={reduceTransparent}
onChange={(e) => setReduceTransparent(e.target.checked)}
/> */}
</div> </div>
)} )}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="w-full space-y-1 lg:w-3/4">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">Spell checking</h6>
</div>
<p className=" font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Disable if you prefer to type without spell checking interruptions
or if you are using non-standard language/terminology that the spell
checker may not recognize.
</p>
</div>
<div className="flex-shrink-0">
<Switch
checked={spellCheck}
onChange={(e) => setSpellCheck(e.target.checked)}
/>
</div>
</div>
</div> </div>
) )
} }

View File

@ -32,8 +32,8 @@ const ExtensionItem: React.FC<Props> = ({ item }) => {
) )
const progress = isInstalling const progress = isInstalling
? installingExtensions.find((e) => e.extensionId === item.name) ? (installingExtensions.find((e) => e.extensionId === item.name)
?.percentage ?? -1 ?.percentage ?? -1)
: -1 : -1
useEffect(() => { useEffect(() => {

View File

@ -158,7 +158,7 @@ const ExtensionCatalog = () => {
{engineActiveExtensions.length !== 0 && ( {engineActiveExtensions.length !== 0 && (
<div className="mb-3 mt-4 border-b border-[hsla(var(--app-border))] pb-4"> <div className="mb-3 mt-4 border-b border-[hsla(var(--app-border))] pb-4">
<h6 className="text-base font-semibold text-[hsla(var(--text-primary))]"> <h6 className="text-base font-semibold text-[hsla(var(--text-primary))]">
Model Provider Model Providers
</h6> </h6>
</div> </div>
)} )}

View File

@ -19,7 +19,7 @@ const SettingDetail = () => {
case 'Appearance': case 'Appearance':
return <AppearanceOptions /> return <AppearanceOptions />
case 'Hotkey & Shortcut': case 'Keyboard Shortcuts':
return <Hotkeys /> return <Hotkeys />
case 'Advanced Settings': case 'Advanced Settings':

View File

@ -89,7 +89,7 @@ const SettingLeftPanel = () => {
).length > 0 && ( ).length > 0 && (
<div className="mb-1 mt-4 px-2"> <div className="mb-1 mt-4 px-2">
<label className="text-xs font-medium text-[hsla(var(--text-secondary))]"> <label className="text-xs font-medium text-[hsla(var(--text-secondary))]">
Model Provider Model Providers
</label> </label>
</div> </div>
)} )}

View File

@ -14,7 +14,7 @@ import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
export const SettingScreenList = [ export const SettingScreenList = [
'My Models', 'My Models',
'Appearance', 'Appearance',
'Hotkey & Shortcut', 'Keyboard Shortcuts',
'Advanced Settings', 'Advanced Settings',
'Extensions', 'Extensions',
] as const ] as const
@ -24,6 +24,7 @@ export type SettingScreen = SettingScreenTuple[number]
const SettingsScreen = () => { const SettingsScreen = () => {
const setSelectedSettingScreen = useSetAtom(selectedSettingAtom) const setSelectedSettingScreen = useSetAtom(selectedSettingAtom)
useEffect(() => { useEffect(() => {
if (localStorage.getItem(SUCCESS_SET_NEW_DESTINATION) === 'true') { if (localStorage.getItem(SUCCESS_SET_NEW_DESTINATION) === 'true') {
setSelectedSettingScreen('Advanced Settings') setSelectedSettingScreen('Advanced Settings')

View File

@ -38,6 +38,7 @@ import ImageUploadPreview from '../ImageUploadPreview'
import { showRightPanelAtom } from '@/helpers/atoms/App.atom' import { showRightPanelAtom } from '@/helpers/atoms/App.atom'
import { experimentalFeatureEnabledAtom } from '@/helpers/atoms/AppConfig.atom' import { experimentalFeatureEnabledAtom } from '@/helpers/atoms/AppConfig.atom'
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
import { spellCheckAtom } from '@/helpers/atoms/Setting.atom'
import { import {
activeThreadAtom, activeThreadAtom,
getActiveThreadIdAtom, getActiveThreadIdAtom,
@ -52,6 +53,7 @@ const ChatInput = () => {
const { stateModel } = useActiveModel() const { stateModel } = useActiveModel()
const messages = useAtomValue(getCurrentChatMessagesAtom) const messages = useAtomValue(getCurrentChatMessagesAtom)
const [activeSetting, setActiveSetting] = useState(false) const [activeSetting, setActiveSetting] = useState(false)
const spellCheck = useAtomValue(spellCheckAtom)
const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom) const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom)
const { sendChatMessage } = useSendChatMessage() const { sendChatMessage } = useSendChatMessage()
@ -162,6 +164,7 @@ const ChatInput = () => {
experimentalFeature && 'pl-10', experimentalFeature && 'pl-10',
activeSetting && 'pb-14 pr-16' activeSetting && 'pb-14 pr-16'
)} )}
spellCheck={spellCheck}
data-testid="txt-input-chat" data-testid="txt-input-chat"
style={{ height: activeSetting ? '100px' : '40px' }} style={{ height: activeSetting ? '100px' : '40px' }}
ref={textareaRef} ref={textareaRef}

View File

@ -26,6 +26,7 @@ import {
getCurrentChatMessagesAtom, getCurrentChatMessagesAtom,
setConvoMessagesAtom, setConvoMessagesAtom,
} from '@/helpers/atoms/ChatMessage.atom' } from '@/helpers/atoms/ChatMessage.atom'
import { spellCheckAtom } from '@/helpers/atoms/Setting.atom'
import { import {
activeThreadAtom, activeThreadAtom,
getActiveThreadIdAtom, getActiveThreadIdAtom,
@ -45,7 +46,7 @@ const EditChatInput: React.FC<Props> = ({ message }) => {
const { sendChatMessage } = useSendChatMessage() const { sendChatMessage } = useSendChatMessage()
const setMessages = useSetAtom(setConvoMessagesAtom) const setMessages = useSetAtom(setConvoMessagesAtom)
const activeThreadId = useAtomValue(getActiveThreadIdAtom) const activeThreadId = useAtomValue(getActiveThreadIdAtom)
const spellCheck = useAtomValue(spellCheckAtom)
const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage) const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const setEditMessage = useSetAtom(editMessageAtom) const setEditMessage = useSetAtom(editMessageAtom)
@ -127,6 +128,7 @@ const EditChatInput: React.FC<Props> = ({ message }) => {
className={twMerge('max-h-[400px] resize-none pr-20')} className={twMerge('max-h-[400px] resize-none pr-20')}
style={{ height: '40px' }} style={{ height: '40px' }}
ref={textareaRef} ref={textareaRef}
spellCheck={spellCheck}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Enter your message..." placeholder="Enter your message..."
disabled={stateModel.loading || !activeThread} disabled={stateModel.loading || !activeThread}

View File

@ -9,6 +9,7 @@ import {
ContentType, ContentType,
Thread, Thread,
} from '@janhq/core' } from '@janhq/core'
import { Tooltip } from '@janhq/joi'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { import {
RefreshCcw, RefreshCcw,
@ -103,9 +104,14 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
className="cursor-pointer rounded-lg border border-[hsla(var(--app-border))] p-2" className="cursor-pointer rounded-lg border border-[hsla(var(--app-border))] p-2"
onClick={onEditClick} onClick={onEditClick}
> >
<PencilIcon <Tooltip
size={14} trigger={
className="text-[hsla(var(--text-secondary))]" <PencilIcon
size={14}
className="text-[hsla(var(--text-secondary))]"
/>
}
content="Edit"
/> />
</div> </div>
)} )}
@ -118,9 +124,14 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
className="cursor-pointer rounded-lg border border-[hsla(var(--app-border))] p-2" className="cursor-pointer rounded-lg border border-[hsla(var(--app-border))] p-2"
onClick={onRegenerateClick} onClick={onRegenerateClick}
> >
<RefreshCcw <Tooltip
size={14} trigger={
className="text-[hsla(var(--text-secondary))]" <RefreshCcw
size={14}
className="text-[hsla(var(--text-secondary))]"
/>
}
content="Regenerate"
/> />
</div> </div>
)} )}
@ -134,9 +145,14 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
{clipboard.copied ? ( {clipboard.copied ? (
<CheckIcon size={14} className="text-[hsla(var(--success-bg))]" /> <CheckIcon size={14} className="text-[hsla(var(--success-bg))]" />
) : ( ) : (
<CopyIcon <Tooltip
size={14} trigger={
className="text-[hsla(var(--text-secondary))]" <CopyIcon
size={14}
className="text-[hsla(var(--text-secondary))]"
/>
}
content="Copy"
/> />
)} )}
</div> </div>
@ -144,9 +160,14 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
className="cursor-pointer rounded-lg border border-[hsla(var(--app-border))] p-2" className="cursor-pointer rounded-lg border border-[hsla(var(--app-border))] p-2"
onClick={onDeleteClick} onClick={onDeleteClick}
> >
<Trash2Icon <Tooltip
size={14} trigger={
className="text-[hsla(var(--text-secondary))]" <Trash2Icon
size={14}
className="text-[hsla(var(--text-secondary))]"
/>
}
content="Delete"
/> />
</div> </div>
</div> </div>

View File

@ -178,7 +178,7 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
> >
{isUser {isUser
? props.role ? props.role
: activeThread?.assistants[0].assistant_name ?? props.role} : (activeThread?.assistants[0].assistant_name ?? props.role)}
</div> </div>
<p className="text-xs font-medium text-gray-400"> <p className="text-xs font-medium text-gray-400">
{displayDate(props.created)} {displayDate(props.created)}
@ -201,7 +201,12 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
)} )}
</div> </div>
<div className={twMerge('w-full')}> <div
className={twMerge(
'w-full',
!isUser && !text.includes(' ') && 'break-all'
)}
>
<> <>
{props.content[0]?.type === ContentType.Image && ( {props.content[0]?.type === ContentType.Image && (
<div className="group/image relative mb-2 inline-flex cursor-pointer overflow-hidden rounded-xl"> <div className="group/image relative mb-2 inline-flex cursor-pointer overflow-hidden rounded-xl">

View File

@ -7,9 +7,10 @@ import useDeleteThread from '@/hooks/useDeleteThread'
type Props = { type Props = {
threadId: string threadId: string
closeContextMenu?: () => void
} }
const ModalCleanThread = ({ threadId }: Props) => { const ModalCleanThread = ({ threadId, closeContextMenu }: Props) => {
const { cleanThread } = useDeleteThread() const { cleanThread } = useDeleteThread()
const onCleanThreadClick = useCallback( const onCleanThreadClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@ -22,6 +23,11 @@ const ModalCleanThread = ({ threadId }: Props) => {
return ( return (
<Modal <Modal
title="Clean Thread" title="Clean Thread"
onOpenChange={(open) => {
if (open && closeContextMenu) {
closeContextMenu()
}
}}
trigger={ trigger={
<div <div
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]" className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"

View File

@ -7,10 +7,12 @@ import useDeleteThread from '@/hooks/useDeleteThread'
type Props = { type Props = {
threadId: string threadId: string
closeContextMenu?: () => void
} }
const ModalDeleteThread = ({ threadId }: Props) => { const ModalDeleteThread = ({ threadId, closeContextMenu }: Props) => {
const { deleteThread } = useDeleteThread() const { deleteThread } = useDeleteThread()
const onDeleteThreadClick = useCallback( const onDeleteThreadClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation() e.stopPropagation()
@ -22,6 +24,11 @@ const ModalDeleteThread = ({ threadId }: Props) => {
return ( return (
<Modal <Modal
title="Delete Thread" title="Delete Thread"
onOpenChange={(open) => {
if (open && closeContextMenu) {
closeContextMenu()
}
}}
trigger={ trigger={
<div <div
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]" className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"

View File

@ -1,4 +1,4 @@
import { useCallback, memo, useState } from 'react' import { useCallback, useLayoutEffect, memo, useState } from 'react'
import { Thread } from '@janhq/core' import { Thread } from '@janhq/core'
import { Modal, ModalClose, Button, Input } from '@janhq/joi' import { Modal, ModalClose, Button, Input } from '@janhq/joi'
@ -8,13 +8,19 @@ import { useCreateNewThread } from '@/hooks/useCreateNewThread'
type Props = { type Props = {
thread: Thread thread: Thread
closeContextMenu?: () => void
} }
const ModalEditTitleThread = ({ thread }: Props) => { const ModalEditTitleThread = ({ thread, closeContextMenu }: Props) => {
const [title, setTitle] = useState(thread.title) const [title, setTitle] = useState(thread.title)
const { updateThreadMetadata } = useCreateNewThread() const { updateThreadMetadata } = useCreateNewThread()
useLayoutEffect(() => {
if (thread.title) {
setTitle(thread.title)
}
}, [thread.title])
const onUpdateTitle = useCallback( const onUpdateTitle = useCallback(
(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation() e.stopPropagation()
@ -30,6 +36,11 @@ const ModalEditTitleThread = ({ thread }: Props) => {
return ( return (
<Modal <Modal
title="Edit title thread" title="Edit title thread"
onOpenChange={(open) => {
if (open && closeContextMenu) {
closeContextMenu()
}
}}
trigger={ trigger={
<div <div
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]" className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Thread } from '@janhq/core' import { Thread } from '@janhq/core'
@ -43,6 +43,14 @@ const ThreadLeftPanel = () => {
const setEditMessage = useSetAtom(editMessageAtom) const setEditMessage = useSetAtom(editMessageAtom)
const { recommendedModel, downloadedModels } = useRecommendedModel() const { recommendedModel, downloadedModels } = useRecommendedModel()
const [contextMenu, setContextMenu] = useState<{
visible: boolean
thread?: Thread
}>({
visible: false,
thread: undefined,
})
const onThreadClick = useCallback( const onThreadClick = useCallback(
(thread: Thread) => { (thread: Thread) => {
setActiveThread(thread) setActiveThread(thread)
@ -91,6 +99,21 @@ const ThreadLeftPanel = () => {
} }
} }
const onContextMenu = (event: React.MouseEvent, thread: Thread) => {
event.preventDefault()
setContextMenu({
visible: true,
thread,
})
}
const closeContextMenu = () => {
setContextMenu({
visible: false,
thread: undefined,
})
}
return ( return (
<LeftPanelContainer> <LeftPanelContainer>
{threads.length === 0 ? ( {threads.length === 0 ? (
@ -124,8 +147,10 @@ const ThreadLeftPanel = () => {
onClick={() => { onClick={() => {
onThreadClick(thread) onThreadClick(thread)
}} }}
onContextMenu={(e) => onContextMenu(e, thread)}
onMouseLeave={closeContextMenu}
> >
<div className="relative z-10 p-2"> <div className="relative z-10 break-all p-2">
<h1 <h1
className={twMerge( className={twMerge(
'line-clamp-1 pr-2 font-medium group-hover/message:pr-6', 'line-clamp-1 pr-2 font-medium group-hover/message:pr-6',
@ -143,10 +168,26 @@ const ThreadLeftPanel = () => {
<Button theme="icon" className="mt-2"> <Button theme="icon" className="mt-2">
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</Button> </Button>
<div className="invisible absolute -right-1 z-50 w-40 overflow-hidden rounded-lg border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] shadow-lg group-hover/icon:visible"> <div
<ModalEditTitleThread thread={thread} /> className={twMerge(
<ModalCleanThread threadId={thread.id} /> 'invisible absolute -right-1 z-50 w-40 overflow-hidden rounded-lg border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] shadow-lg group-hover/icon:visible',
<ModalDeleteThread threadId={thread.id} /> contextMenu.visible &&
contextMenu.thread?.id === thread.id &&
'visible'
)}
>
<ModalEditTitleThread
thread={thread}
closeContextMenu={closeContextMenu}
/>
<ModalCleanThread
threadId={thread.id}
closeContextMenu={closeContextMenu}
/>
<ModalDeleteThread
threadId={thread.id}
closeContextMenu={closeContextMenu}
/>
</div> </div>
</div> </div>
{activeThreadId === thread.id && ( {activeThreadId === thread.id && (

View File

@ -66,6 +66,32 @@ const Tools = () => {
[activeThread, updateThreadMetadata] [activeThread, updateThreadMetadata]
) )
const onTimeWeightedRetrieverSwitchUpdate = useCallback(
(enabled: boolean) => {
if (!activeThread) return
updateThreadMetadata({
...activeThread,
assistants: [
{
...activeThread.assistants[0],
tools: [
{
type: 'retrieval',
enabled: true,
useTimeWeightedRetriever: enabled,
settings:
(activeThread.assistants[0].tools &&
activeThread.assistants[0].tools[0]?.settings) ??
{},
},
],
},
],
})
},
[activeThread, updateThreadMetadata]
)
if (!experimentalFeature) return null if (!experimentalFeature) return null
return ( return (
@ -140,9 +166,49 @@ const Tools = () => {
<div className="mb-2 flex items-center"> <div className="mb-2 flex items-center">
<label <label
id="vector-database" id="vector-database"
className="inline-block font-medium" className="inline-flex items-center font-medium"
> >
Vector Database Vector Database
<Tooltip
trigger={
<InfoIcon
size={16}
className="ml-2 flex-shrink-0 text-[hsl(var(--text-secondary))]"
/>
}
content="Vector Database is crucial for efficient storage
and retrieval of embeddings. Consider your
specific task, available resources, and language
requirements. Experiment to find the best fit for
your specific use case."
/>
</label>
<div className="ml-auto flex items-center justify-between">
<Switch
name="use-time-weighted-retriever"
className="mr-2"
checked={
activeThread?.assistants[0].tools[0]
.useTimeWeightedRetriever || false
}
onChange={(e) =>
onTimeWeightedRetrieverSwitchUpdate(e.target.checked)
}
/>
</div>
</div>
<div className="w-full">
<Input value="HNSWLib" disabled readOnly />
</div>
</div>
<div className="mb-4">
<div className="mb-2 flex items-center">
<label
id="use-time-weighted-retriever"
className="inline-block font-medium"
>
Time-Weighted Retrieval?
</label> </label>
<Tooltip <Tooltip
trigger={ trigger={
@ -151,17 +217,13 @@ const Tools = () => {
className="ml-2 flex-shrink-0 text-[hsl(var(--text-secondary))]" className="ml-2 flex-shrink-0 text-[hsl(var(--text-secondary))]"
/> />
} }
content="Vector Database is crucial for efficient storage content="Time-Weighted Retriever looks at how similar
and retrieval of embeddings. Consider your they are and how new they are. It compares
specific task, available resources, and language documents based on their meaning like usual, but
requirements. Experiment to find the best fit for also considers when they were added to give
your specific use case." newer ones more importance."
/> />
</div> </div>
<div className="w-full">
<Input value="HNSWLib" disabled readOnly />
</div>
</div> </div>
<AssistantSetting <AssistantSetting
componentData={componentDataAssistantSetting} componentData={componentDataAssistantSetting}