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)
/**
* 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) =>
globalThis.core.api?.copyFile(src, dest)
@ -95,9 +81,7 @@ export const fs = {
rm,
unlinkSync,
appendFileSync,
copyFileSync,
copyFile,
syncFile,
fileStat,
writeBlob,
}

View File

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

View File

@ -1,5 +1,5 @@
import { join } from 'path'
import { normalizeFilePath } from '../../helper/path'
import { join, resolve } from 'path'
import { normalizeFilePath, validatePath } from '../../helper/path'
import { getJanDataFolderPath } from '../../helper'
import { Processor } from './Processor'
import fs from 'fs'
@ -15,17 +15,29 @@ export class FileSystem implements Processor {
process(route: string, ...args: any): any {
const instance = this as any
const func = instance[route]
if (func) {
return func(...args)
} else {
return import(FileSystem.moduleName).then((mdl) =>
mdl[route](
...args.map((arg: any) => {
return typeof arg === 'string' &&
...args.map((arg: any, index: number) => {
if(index !== 0) {
return 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))
}
const absolutePath = resolve(path)
validatePath(absolutePath)
return new Promise((resolve, reject) => {
fs.rm(path, { recursive: true, force: true }, (err) => {
fs.rm(absolutePath, { recursive: true, force: true }, (err) => {
if (err) {
reject(err)
} else {
@ -63,8 +78,11 @@ export class FileSystem implements Processor {
path = join(getJanDataFolderPath(), normalizeFilePath(path))
}
const absolutePath = resolve(path)
validatePath(absolutePath)
return new Promise((resolve, reject) => {
fs.mkdir(path, { recursive: true }, (err) => {
fs.mkdir(absolutePath, { recursive: true }, (err) => {
if (err) {
reject(err)
} else {
@ -73,4 +91,5 @@ export class FileSystem implements Processor {
})
})
}
}

View File

@ -1,6 +1,6 @@
import { join } from 'path'
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 { Processor } from './Processor'
import { FileStat } from '../../../types'
@ -18,19 +18,6 @@ export class FSExt implements Processor {
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.
getJanDataFolderPath() {
return Promise.resolve(getPath())
@ -70,14 +57,18 @@ export class FSExt implements Processor {
writeBlob(path: string, data: any) {
try {
const normalizedPath = normalizeFilePath(path)
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) {
console.error(`writeFile ${path} result: ${err}`)
}
}
copyFile(src: string, dest: string): Promise<void> {
validatePath(dest)
return new Promise((resolve, reject) => {
fs.copyFile(src, dest, (err) => {
if (err) {

View File

@ -7,7 +7,8 @@ import childProcess from 'child_process'
const configurationFileName = 'settings.json'
// 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 = {
data_folder: defaultJanDataFolder,
quick_ask: false,

View File

@ -11,7 +11,7 @@ export class DownloadManager {
// store the download information with key is model id
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> = {}
constructor() {

View File

@ -1,4 +1,5 @@
import { join } from 'path'
import { join, resolve } from 'path'
import { getJanDataFolderPath } from './config'
/**
* Normalize file path
@ -33,3 +34,11 @@ export async function appResourcePath(): Promise<string> {
// server
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 {
appendFileSync = 'appendFileSync',
copyFileSync = 'copyFileSync',
unlinkSync = 'unlinkSync',
existsSync = 'existsSync',
readdirSync = 'readdirSync',
@ -100,7 +99,6 @@ export enum FileSystemRoute {
writeFileSync = 'writeFileSync',
}
export enum FileManagerRoute {
syncFile = 'syncFile',
copyFile = 'copyFile',
getJanDataFolderPath = 'getJanDataFolderPath',
getResourcePath = 'getResourcePath',

View File

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

View File

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

View File

@ -3,8 +3,9 @@
* @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 { readdirSync } from 'fs'
const interfaces: { [key: string]: (...args: any[]) => any } = {}
@ -12,7 +13,9 @@ const interfaces: { [key: string]: (...args: any[]) => any } = {}
APIRoutes.forEach((method) => {
// For each method, create a function on the interfaces object
// This function invokes the method on the ipcRenderer with any provided arguments
interfaces[method] = (...args: any[]) => ipcRenderer.invoke(method, ...args)
})
// 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
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'
// This allows the renderer process to access these methods directly
contextBridge.exposeInMainWorld('electronAPI', {

View File

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

View File

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

View File

@ -2,11 +2,16 @@ import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter'
import { formatDocumentsAsString } from 'langchain/util/document'
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 { OpenAIEmbeddings } from 'langchain/embeddings/openai'
import { readEmbeddingEngine } from './engine'
import path from 'path'
export class Retrieval {
public chunkSize: number = 100
public chunkOverlap?: number = 0
@ -15,8 +20,25 @@ export class Retrieval {
private embeddingModel?: OpenAIEmbeddings = undefined
private textSplitter?: RecursiveCharacterTextSplitter
// to support time-weighted retrieval
private timeWeightedVectorStore: MemoryVectorStore
private timeWeightedretriever: any | TimeWeightedVectorStoreRetriever
constructor(chunkSize: number = 4000, chunkOverlap: number = 200) {
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 {
@ -44,11 +66,15 @@ export class Retrieval {
openAIApiKey: settings.api_key,
})
}
// update time-weighted embedding model
this.timeWeightedVectorStore.embeddings = this.embeddingModel
}
public ingestAgentKnowledge = async (
filePath: string,
memoryPath: string
memoryPath: string,
useTimeWeighted: boolean
): Promise<any> => {
const loader = new PDFLoader(filePath, {
splitPages: true,
@ -57,6 +83,13 @@ export class Retrieval {
const doc = await loader.load()
const docs = await this.textSplitter!.splitDocuments(doc)
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)
}
@ -67,10 +100,25 @@ export class Retrieval {
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) {
return Promise.resolve(' ')
}
// should use invoke(query) because getRelevantDocuments is deprecated
const relevantDocs = await this.retriever.getRelevantDocuments(query)
const serializedDoc = formatDocumentsAsString(relevantDocs)
return Promise.resolve(serializedDoc)

View File

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

View File

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

View File

@ -19,10 +19,7 @@
},
"metadata": {
"author": "Anthropic",
"tags": [
"General",
"Big Context Length"
]
"tags": ["General", "Big Context Length"]
},
"engine": "anthropic"
},
@ -46,10 +43,7 @@
},
"metadata": {
"author": "Anthropic",
"tags": [
"General",
"Big Context Length"
]
"tags": ["General", "Big Context Length"]
},
"engine": "anthropic"
},
@ -73,10 +67,31 @@
},
"metadata": {
"author": "Anthropic",
"tags": [
"General",
"Big Context Length"
]
"tags": ["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"
}

View File

@ -1 +1 @@
0.4.13
0.4.20

View File

@ -1,7 +1,7 @@
{
"name": "@janhq/inference-cortex-extension",
"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.",
"main": "dist/index.js",
"node": "dist/node/index.cjs.js",
@ -10,7 +10,7 @@
"scripts": {
"test": "jest",
"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:win32": "download.bat",
"downloadnitro": "run-script-os",
@ -53,7 +53,7 @@
"fetch-retry": "^5.0.6",
"rxjs": "^7.8.1",
"tcp-port-used": "^1.0.2",
"terminate": "^2.6.1",
"terminate": "2.6.1",
"ulidx": "^2.3.0"
},
"engines": {

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "@janhq/model-extension",
"productName": "Model Management",
"version": "1.0.32",
"version": "1.0.33",
"description": "Model Management Extension provides model exploration and seamless downloads",
"main": "dist/index.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
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

View File

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

View File

@ -3,12 +3,20 @@ import { Fragment, useEffect, useState } from 'react'
import { Progress } from '@janhq/joi'
import { useClickOutside } from '@janhq/joi'
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 useGetSystemResources from '@/hooks/useGetSystemResources'
import { usePath } from '@/hooks/usePath'
import { toGibibytes } from '@/utils/converter'
import TableActiveModel from './TableActiveModel'
@ -28,6 +36,7 @@ const SystemMonitor = () => {
const usedRam = useAtomValue(usedRamAtom)
const cpuUsage = useAtomValue(cpuUsageAtom)
const gpus = useAtomValue(gpusAtom)
const { onRevealInFinder } = usePath()
const [showFullScreen, setShowFullScreen] = useState(false)
const ramUtilitized = useAtomValue(ramUtilitizedAtom)
const [showSystemMonitorPanel, setShowSystemMonitorPanel] = useAtom(
@ -64,7 +73,7 @@ const SystemMonitor = () => {
<div
ref={setControl}
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))]'
)}
onClick={() => {
@ -89,6 +98,12 @@ const SystemMonitor = () => {
Running Models
</h6>
<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 ? (
<ChevronDown
size={20}

View File

@ -1,7 +1,8 @@
import { useState } from 'react'
import { Modal } from '@janhq/joi'
import { Button, Modal } from '@janhq/joi'
import { atom, useAtom } from 'jotai'
import { Maximize2 } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import ServerLogs from '@/containers/ServerLogs'
@ -17,6 +18,7 @@ const ModalTroubleShooting = () => {
modalTroubleShootingAtom
)
const [isTabActive, setIsTabActivbe] = useState(0)
const [showLogFullSize, setshowLogFullSize] = useState(false)
return (
<Modal
@ -26,14 +28,15 @@ const ModalTroubleShooting = () => {
title="Troubleshooting Assistance"
content={
<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">
{`We're here to help! Your report is crucial for debugging and shaping
the next version. Heres how you can report & get further support:`}
</p>
</div>
<div className="my-3 rounded-lg border border-[hsla(var(--app-border))] p-4 shadow">
{!showLogFullSize && (
<div className="mb-3 rounded-lg border border-[hsla(var(--app-border))] p-4 shadow">
<h2 className="font-semibold">Step 1</h2>
<p className="text-[hsla(var(--text-secondary)] mt-1">
Follow our&nbsp;
@ -47,8 +50,15 @@ const ModalTroubleShooting = () => {
&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={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">
@ -72,13 +82,22 @@ const ModalTroubleShooting = () => {
>
Discord
</a>
&nbsp;& send it to #🆘|get-help channel for further support.
&nbsp;& send it to #🆘|get-help channel for further
support.
</p>
</li>
</ul>
</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('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">
{logOption.map((name, i) => {
return (
@ -103,7 +122,20 @@ const ModalTroubleShooting = () => {
})}
</ul>
</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
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 { Badge, Input, ScrollArea, Select, useClickOutside } from '@janhq/joi'
@ -70,7 +70,7 @@ const ModelDropdown = ({
const downloadStates = useAtomValue(modelDownloadStateAtom)
const setThreadModelParams = useSetAtom(setThreadModelParamsAtom)
const { updateModelParameter } = useUpdateModelParameters()
const searchInputRef = useRef<HTMLInputElement>(null)
const configuredModels = useAtomValue(configuredModelsAtom)
const featuredModel = configuredModels.filter((x) =>
x.metadata.tags.includes('Featured')
@ -83,7 +83,7 @@ const ModelDropdown = ({
const filteredDownloadedModels = useMemo(
() =>
downloadedModels
configuredModels
.filter((e) =>
e.name.toLowerCase().includes(searchText.toLowerCase().trim())
)
@ -104,9 +104,30 @@ const ModelDropdown = ({
)
}
})
.sort((a, b) => a.name.localeCompare(b.name)),
[downloadedModels, searchText, searchFilter]
.sort((a, b) => a.name.localeCompare(b.name))
.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(() => {
if (!activeThread) return
@ -258,6 +279,7 @@ const ModelDropdown = ({
<Input
placeholder="Search"
value={searchText}
ref={searchInputRef}
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)}
suffixIcon={
@ -290,45 +312,104 @@ const ModelDropdown = ({
</div>
</div>
<ScrollArea className="h-[calc(100%-36px)] w-full">
{filteredDownloadedModels.filter(
(x) => x.engine === InferenceEngine.nitro
).length !== 0 ? (
<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>
<ul className="pb-2">
{filteredDownloadedModels
? filteredDownloadedModels
.filter((x) => x.engine === InferenceEngine.nitro)
.map((model) => {
return (
<li
key={model.id}
className="flex cursor-pointer items-center gap-2 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
onClick={() => onClickModelItem(model.id)}
>
<p className="line-clamp-1" title={model.name}>
{model.name}
</p>
<ModelLabel metadata={model.metadata} compact />
</li>
)
})
: null}
</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 ? (
</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">
{filteredDownloadedModels
? filteredDownloadedModels
.filter((x) => x.engine === InferenceEngine.nitro)
.filter((x) => {
if (searchText.length === 0) {
return downloadedModels.find((c) => c.id === x.id)
} else {
return x
}
})
.map((model) => {
const isDownloading = downloadingModels.some(
(md) => md.id === model.id
)
const isdDownloaded = downloadedModels.some(
(c) => c.id === model.id
)
return (
<li
key={model.id}
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">
<p
className={twMerge(
'line-clamp-1',
!isdDownloaded &&
'text-[hsla(var(--text-secondary))]'
)}
title={model.name}
>
{model.name}
</p>
<ModelLabel
metadata={model.metadata}
compact
/>
</div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
{!isdDownloaded && (
<span className="font-medium">
{toGibibytes(model.metadata.size)}
</span>
)}
{!isDownloading && !isdDownloaded ? (
<DownloadCloudIcon
size={18}
className="cursor-pointer text-[hsla(var(--app-link))]"
onClick={() => downloadModel(model)}
/>
) : (
Object.values(downloadStates)
.filter((x) => x.modelId === model.id)
.map((item) => (
<ProgressCircle
key={item.modelId}
percentage={
formatDownloadPercentage(
item?.percent,
{
hidePercentage: true,
}
) as number
}
size={100}
/>
))
)}
</div>
</li>
)
})
: null}
</ul>
) : (
<ul className="pb-2">
{featuredModel.map((model) => {
const isDownloading = downloadingModels.some(
@ -346,10 +427,7 @@ const ModelDropdown = ({
>
{model.name}
</p>
<ModelLabel
metadata={model.metadata}
compact
/>
<ModelLabel metadata={model.metadata} compact />
</div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
<span className="font-medium">
@ -368,75 +446,9 @@ const ModelDropdown = ({
<ProgressCircle
key={item.modelId}
percentage={
formatDownloadPercentage(
item?.percent,
{
formatDownloadPercentage(item?.percent, {
hidePercentage: true,
}
) as number
}
size={100}
/>
))
)}
</div>
</li>
)
})}
</ul>
) : (
<ul className="pb-2">
{configuredModels
.filter((x) => x.engine === InferenceEngine.nitro)
.filter((e) =>
e.name
.toLowerCase()
.includes(searchText.toLowerCase().trim())
)
.map((model) => {
const isDownloading = downloadingModels.some(
(md) => md.id === model.id
)
return (
<li
key={model.id}
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
>
<div className="flex items-center gap-2">
<p
className="line-clamp-1 text-[hsla(var(--text-secondary))]"
title={model.name}
>
{model.name}
</p>
<ModelLabel
metadata={model.metadata}
compact
/>
</div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
<span className="font-medium">
{toGibibytes(model.metadata.size)}
</span>
{!isDownloading ? (
<DownloadCloudIcon
size={18}
className="cursor-pointer text-[hsla(var(--app-link))]"
onClick={() => downloadModel(model)}
/>
) : (
Object.values(downloadStates)
.filter((x) => x.modelId === model.id)
.map((item) => (
<ProgressCircle
key={item.modelId}
percentage={
formatDownloadPercentage(
item?.percent,
{
hidePercentage: true,
}
) as number
}) as number
}
size={100}
/>
@ -449,9 +461,6 @@ const ModelDropdown = ({
</ul>
)}
</div>
</div>
)}
</>
)}
{groupByEngine.map((engine, i) => {

View File

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

View File

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

View File

@ -1,6 +1,6 @@
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 { useAtomValue, useSetAtom } from 'jotai'
import { PencilIcon, FolderOpenIcon } from 'lucide-react'
@ -51,11 +51,10 @@ const DataFolder = () => {
return
}
const newDestChildren: string[] = await fs.readdirSync(destFolder)
const isNotEmpty =
newDestChildren.filter((x) => x !== '.DS_Store').length > 0
const isEmpty: boolean =
await window.core?.api?.isDirectoryEmpty(destFolder)
if (isNotEmpty) {
if (!isEmpty) {
setDestinationPath(destFolder)
showDestNotEmptyConfirm(true)
return
@ -74,16 +73,7 @@ const DataFolder = () => {
if (!destinationPath) return
try {
setShowLoader(true)
const appConfiguration: AppConfiguration =
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}`
)
await window.core?.api?.changeDataFolder(destinationPath)
localStorage.setItem(SUCCESS_SET_NEW_DESTINATION, 'true')
setTimeout(() => {
setShowLoader(false)

View File

@ -3,13 +3,14 @@ import { useCallback } from 'react'
import { useTheme } from 'next-themes'
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 {
janThemesPathAtom,
reduceTransparentAtom,
selectedThemeIdAtom,
spellCheckAtom,
themeDataAtom,
themesOptionsAtom,
} from '@/helpers/atoms/Setting.atom'
@ -23,6 +24,7 @@ export default function AppearanceOptions() {
const [reduceTransparent, setReduceTransparent] = useAtom(
reduceTransparentAtom
)
const [spellCheck, setSpellCheck] = useAtom(spellCheckAtom)
const handleClickTheme = useCallback(
async (e: string) => {
@ -55,7 +57,7 @@ export default function AppearanceOptions() {
<h6 className="font-semibold capitalize">Appearance</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Select of customize your interface color scheme
Select a color theme
</p>
</div>
<Select
@ -70,9 +72,6 @@ export default function AppearanceOptions() {
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">Interface theme</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Choose the type of the interface
</p>
</div>
<div className="flex items-center gap-x-2">
<Button
@ -87,15 +86,29 @@ export default function AppearanceOptions() {
variant={reduceTransparent ? 'outline' : 'solid'}
onClick={() => setReduceTransparent(false)}
>
Transparent
Transparency
</Button>
</div>
{/* <Switch
checked={reduceTransparent}
onChange={(e) => setReduceTransparent(e.target.checked)}
/> */}
</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>
)
}

View File

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

View File

@ -158,7 +158,7 @@ const ExtensionCatalog = () => {
{engineActiveExtensions.length !== 0 && (
<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))]">
Model Provider
Model Providers
</h6>
</div>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -178,7 +178,7 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
>
{isUser
? props.role
: activeThread?.assistants[0].assistant_name ?? props.role}
: (activeThread?.assistants[0].assistant_name ?? props.role)}
</div>
<p className="text-xs font-medium text-gray-400">
{displayDate(props.created)}
@ -201,7 +201,12 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
)}
</div>
<div className={twMerge('w-full')}>
<div
className={twMerge(
'w-full',
!isUser && !text.includes(' ') && 'break-all'
)}
>
<>
{props.content[0]?.type === ContentType.Image && (
<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 = {
threadId: string
closeContextMenu?: () => void
}
const ModalCleanThread = ({ threadId }: Props) => {
const ModalCleanThread = ({ threadId, closeContextMenu }: Props) => {
const { cleanThread } = useDeleteThread()
const onCleanThreadClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@ -22,6 +23,11 @@ const ModalCleanThread = ({ threadId }: Props) => {
return (
<Modal
title="Clean Thread"
onOpenChange={(open) => {
if (open && closeContextMenu) {
closeContextMenu()
}
}}
trigger={
<div
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 = {
threadId: string
closeContextMenu?: () => void
}
const ModalDeleteThread = ({ threadId }: Props) => {
const ModalDeleteThread = ({ threadId, closeContextMenu }: Props) => {
const { deleteThread } = useDeleteThread()
const onDeleteThreadClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation()
@ -22,6 +24,11 @@ const ModalDeleteThread = ({ threadId }: Props) => {
return (
<Modal
title="Delete Thread"
onOpenChange={(open) => {
if (open && closeContextMenu) {
closeContextMenu()
}
}}
trigger={
<div
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 { Modal, ModalClose, Button, Input } from '@janhq/joi'
@ -8,13 +8,19 @@ import { useCreateNewThread } from '@/hooks/useCreateNewThread'
type Props = {
thread: Thread
closeContextMenu?: () => void
}
const ModalEditTitleThread = ({ thread }: Props) => {
const ModalEditTitleThread = ({ thread, closeContextMenu }: Props) => {
const [title, setTitle] = useState(thread.title)
const { updateThreadMetadata } = useCreateNewThread()
useLayoutEffect(() => {
if (thread.title) {
setTitle(thread.title)
}
}, [thread.title])
const onUpdateTitle = useCallback(
(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation()
@ -30,6 +36,11 @@ const ModalEditTitleThread = ({ thread }: Props) => {
return (
<Modal
title="Edit title thread"
onOpenChange={(open) => {
if (open && closeContextMenu) {
closeContextMenu()
}
}}
trigger={
<div
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'
@ -43,6 +43,14 @@ const ThreadLeftPanel = () => {
const setEditMessage = useSetAtom(editMessageAtom)
const { recommendedModel, downloadedModels } = useRecommendedModel()
const [contextMenu, setContextMenu] = useState<{
visible: boolean
thread?: Thread
}>({
visible: false,
thread: undefined,
})
const onThreadClick = useCallback(
(thread: 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 (
<LeftPanelContainer>
{threads.length === 0 ? (
@ -124,8 +147,10 @@ const ThreadLeftPanel = () => {
onClick={() => {
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
className={twMerge(
'line-clamp-1 pr-2 font-medium group-hover/message:pr-6',
@ -143,10 +168,26 @@ const ThreadLeftPanel = () => {
<Button theme="icon" className="mt-2">
<MoreHorizontalIcon />
</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">
<ModalEditTitleThread thread={thread} />
<ModalCleanThread threadId={thread.id} />
<ModalDeleteThread threadId={thread.id} />
<div
className={twMerge(
'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',
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>
{activeThreadId === thread.id && (

View File

@ -66,6 +66,32 @@ const Tools = () => {
[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
return (
@ -140,10 +166,9 @@ const Tools = () => {
<div className="mb-2 flex items-center">
<label
id="vector-database"
className="inline-block font-medium"
className="inline-flex items-center font-medium"
>
Vector Database
</label>
<Tooltip
trigger={
<InfoIcon
@ -157,12 +182,49 @@ const Tools = () => {
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>
<Tooltip
trigger={
<InfoIcon
size={16}
className="ml-2 flex-shrink-0 text-[hsl(var(--text-secondary))]"
/>
}
content="Time-Weighted Retriever looks at how similar
they are and how new they are. It compares
documents based on their meaning like usual, but
also considers when they were added to give
newer ones more importance."
/>
</div>
</div>
<AssistantSetting
componentData={componentDataAssistantSetting}
/>