From f36d740b1eb6e1b5caadd1fc115a0da3044b0e5b Mon Sep 17 00:00:00 2001 From: NamH Date: Fri, 8 Mar 2024 10:01:37 +0700 Subject: [PATCH] feat: add quick ask (#2197) * feat: add quick ask Signed-off-by: James --------- Signed-off-by: James Co-authored-by: James Co-authored-by: Louis --- .gitignore | 1 + core/package.json | 11 +- core/src/api/index.ts | 11 ++ .../node/api/restful/helper/startStopModel.ts | 6 +- core/src/types/miscellaneous/appUpdate.ts | 7 + core/src/types/miscellaneous/index.ts | 1 + core/src/types/model/modelEntity.ts | 4 + electron/handlers/common.ts | 15 +- electron/handlers/native.ts | 41 +++++- electron/handlers/update.ts | 26 ++-- electron/icons/icon-tray.png | Bin 0 -> 1352 bytes electron/icons/icon-tray@2x.png | Bin 0 -> 2269 bytes electron/main.ts | 103 +++++++++----- electron/managers/mainWindowConfig.ts | 16 +++ electron/managers/quickAskWindowConfig.ts | 13 ++ electron/managers/window.ts | 132 +++++++++++++++--- electron/package.json | 18 ++- electron/utils/clean.ts | 4 +- electron/utils/selectedText.ts | 39 ++++++ extensions/assistant-extension/package.json | 2 +- .../inference-nitro-extension/package.json | 2 +- server/package.json | 4 +- uikit/package.json | 2 +- web/app/search/SelectedText.tsx | 47 +++++++ web/app/search/UserInput.tsx | 86 ++++++++++++ web/app/search/page.tsx | 13 ++ .../Providers/AppUpdateListener.tsx | 12 +- .../Providers/ClipboardListener.tsx | 17 +++ web/containers/Providers/DataLoader.tsx | 2 +- web/containers/Providers/EventHandler.tsx | 13 +- web/containers/Providers/EventListener.tsx | 12 +- web/containers/Providers/Jotai.tsx | 2 + web/containers/Providers/KeyListener.tsx | 4 + web/containers/Providers/QuickAskListener.tsx | 39 ++++++ web/helpers/atoms/Assistant.atom.ts | 2 +- web/hooks/useActiveModel.ts | 10 +- web/hooks/useSendChatMessage.ts | 55 ++++---- web/package.json | 5 +- .../ImportingModelItem.tsx | 2 +- 39 files changed, 631 insertions(+), 148 deletions(-) create mode 100644 core/src/types/miscellaneous/appUpdate.ts create mode 100644 electron/icons/icon-tray.png create mode 100644 electron/icons/icon-tray@2x.png create mode 100644 electron/managers/mainWindowConfig.ts create mode 100644 electron/managers/quickAskWindowConfig.ts create mode 100644 electron/utils/selectedText.ts create mode 100644 web/app/search/SelectedText.tsx create mode 100644 web/app/search/UserInput.tsx create mode 100644 web/app/search/page.tsx create mode 100644 web/containers/Providers/ClipboardListener.tsx create mode 100644 web/containers/Providers/QuickAskListener.tsx diff --git a/.gitignore b/.gitignore index 75518bf5a..ae0691605 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ electron/renderer electron/models electron/docs electron/engines +electron/playwright-report server/pre-install package-lock.json diff --git a/core/package.json b/core/package.json index c3abe2d56..2bf3e1735 100644 --- a/core/package.json +++ b/core/package.json @@ -45,19 +45,20 @@ "start": "rollup -c rollup.config.ts -w" }, "devDependencies": { - "jest": "^25.4.0", - "@types/jest": "^29.5.11", + "jest": "^29.7.0", + "@types/jest": "^29.5.12", "@types/node": "^12.0.2", - "eslint-plugin-jest": "^23.8.2", + "eslint-plugin-jest": "^27.9.0", + "eslint": "8.57.0", "rollup": "^2.38.5", "rollup-plugin-commonjs": "^9.1.8", "rollup-plugin-json": "^3.1.0", "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-sourcemaps": "^0.6.3", "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^26.1.1", + "ts-jest": "^29.1.2", "tslib": "^2.6.2", - "typescript": "^5.2.2", + "typescript": "^5.3.3", "rimraf": "^3.0.2" } } diff --git a/core/src/api/index.ts b/core/src/api/index.ts index 7fb8eeb38..e62b49087 100644 --- a/core/src/api/index.ts +++ b/core/src/api/index.ts @@ -9,6 +9,14 @@ export enum NativeRoute { selectDirectory = 'selectDirectory', selectModelFiles = 'selectModelFiles', relaunch = 'relaunch', + + hideQuickAskWindow = 'hideQuickAskWindow', + sendQuickAskInput = 'sendQuickAskInput', + + hideMainWindow = 'hideMainWindow', + showMainWindow = 'showMainWindow', + + quickAskSizeUpdated = 'quickAskSizeUpdated', } /** @@ -31,6 +39,9 @@ export enum AppEvent { onAppUpdateDownloadUpdate = 'onAppUpdateDownloadUpdate', onAppUpdateDownloadError = 'onAppUpdateDownloadError', onAppUpdateDownloadSuccess = 'onAppUpdateDownloadSuccess', + + onUserSubmitQuickAsk = 'onUserSubmitQuickAsk', + onSelectedText = 'onSelectedText', } export enum DownloadRoute { diff --git a/core/src/node/api/restful/helper/startStopModel.ts b/core/src/node/api/restful/helper/startStopModel.ts index 0e6972b0b..4627b4120 100644 --- a/core/src/node/api/restful/helper/startStopModel.ts +++ b/core/src/node/api/restful/helper/startStopModel.ts @@ -41,7 +41,7 @@ const runModel = async (modelId: string, settingParams?: ModelSettingParams): Pr const modelFolderFullPath = join(janDataFolderPath, 'models', modelId) if (!fs.existsSync(modelFolderFullPath)) { - throw `Model not found: ${modelId}` + throw new Error(`Model not found: ${modelId}`) } const files: string[] = fs.readdirSync(modelFolderFullPath) @@ -53,7 +53,7 @@ const runModel = async (modelId: string, settingParams?: ModelSettingParams): Pr const modelMetadata: Model = JSON.parse(fs.readFileSync(modelMetadataPath, 'utf-8')) if (!ggufBinFile) { - throw 'No GGUF model file found' + throw new Error('No GGUF model file found') } const modelBinaryPath = join(modelFolderFullPath, ggufBinFile) @@ -76,7 +76,7 @@ const runModel = async (modelId: string, settingParams?: ModelSettingParams): Pr const promptTemplate = modelMetadata.settings.prompt_template const prompt = promptTemplateConverter(promptTemplate) if (prompt?.error) { - return Promise.reject(prompt.error) + throw new Error(prompt.error) } nitroModelSettings.system_prompt = prompt.system_prompt nitroModelSettings.user_prompt = prompt.user_prompt diff --git a/core/src/types/miscellaneous/appUpdate.ts b/core/src/types/miscellaneous/appUpdate.ts new file mode 100644 index 000000000..ed135e3bd --- /dev/null +++ b/core/src/types/miscellaneous/appUpdate.ts @@ -0,0 +1,7 @@ +export type AppUpdateInfo = { + total: number + delta: number + transferred: number + percent: number + bytesPerSecond: number +} diff --git a/core/src/types/miscellaneous/index.ts b/core/src/types/miscellaneous/index.ts index 02c973323..e9c205a73 100644 --- a/core/src/types/miscellaneous/index.ts +++ b/core/src/types/miscellaneous/index.ts @@ -1,2 +1,3 @@ export * from './systemResourceInfo' export * from './promptTemplate' +export * from './appUpdate' diff --git a/core/src/types/model/modelEntity.ts b/core/src/types/model/modelEntity.ts index 3cbe799e2..11d3e0526 100644 --- a/core/src/types/model/modelEntity.ts +++ b/core/src/types/model/modelEntity.ts @@ -138,3 +138,7 @@ export type ModelRuntimeParams = { presence_penalty?: number engine?: string } + +export type ModelInitFailed = Model & { + error: Error +} diff --git a/electron/handlers/common.ts b/electron/handlers/common.ts index 5a54a92bd..a2a1bd2f7 100644 --- a/electron/handlers/common.ts +++ b/electron/handlers/common.ts @@ -1,25 +1,20 @@ import { Handler, RequestHandler } from '@janhq/core/node' import { ipcMain } from 'electron' -import { WindowManager } from '../managers/window' +import { windowManager } from '../managers/window' export function injectHandler() { const ipcWrapper: Handler = ( route: string, listener: (...args: any[]) => any - ) => { - return ipcMain.handle(route, async (event, ...args: any[]) => { + ) => + ipcMain.handle(route, async (_event, ...args: any[]) => { return listener(...args) }) - } const handler = new RequestHandler( ipcWrapper, - (channel: string, args: any) => { - return WindowManager.instance.currentWindow?.webContents.send( - channel, - args - ) - } + (channel: string, args: any) => + windowManager.mainWindow?.webContents.send(channel, args) ) handler.handle() } diff --git a/electron/handlers/native.ts b/electron/handlers/native.ts index 19a473e73..04e9b71af 100644 --- a/electron/handlers/native.ts +++ b/electron/handlers/native.ts @@ -1,13 +1,13 @@ import { app, ipcMain, dialog, shell } from 'electron' import { join } from 'path' -import { WindowManager } from '../managers/window' +import { windowManager } from '../managers/window' import { ModuleManager, getJanDataFolderPath, getJanExtensionsPath, init, } from '@janhq/core/node' -import { NativeRoute } from '@janhq/core' +import { AppEvent, NativeRoute } from '@janhq/core' export function handleAppIPCs() { /** @@ -62,12 +62,12 @@ export function handleAppIPCs() { // Path to install extension to extensionsPath: getJanExtensionsPath(), }) - WindowManager.instance.currentWindow?.reload() + windowManager.mainWindow?.reload() } }) ipcMain.handle(NativeRoute.selectDirectory, async () => { - const mainWindow = WindowManager.instance.currentWindow + const mainWindow = windowManager.mainWindow if (!mainWindow) { console.error('No main window found') return @@ -85,7 +85,7 @@ export function handleAppIPCs() { }) ipcMain.handle(NativeRoute.selectModelFiles, async () => { - const mainWindow = WindowManager.instance.currentWindow + const mainWindow = windowManager.mainWindow if (!mainWindow) { console.error('No main window found') return @@ -101,4 +101,35 @@ export function handleAppIPCs() { return filePaths }) + + ipcMain.handle( + NativeRoute.hideQuickAskWindow, + async (): Promise => windowManager.hideQuickAskWindow() + ) + + ipcMain.handle( + NativeRoute.sendQuickAskInput, + async (_event, input: string): Promise => { + windowManager.mainWindow?.webContents.send( + AppEvent.onUserSubmitQuickAsk, + input + ) + } + ) + + ipcMain.handle( + NativeRoute.hideMainWindow, + async (): Promise => windowManager.hideMainWindow() + ) + + ipcMain.handle( + NativeRoute.showMainWindow, + async (): Promise => windowManager.showMainWindow() + ) + + ipcMain.handle( + NativeRoute.quickAskSizeUpdated, + async (_event, heightOffset: number): Promise => + windowManager.expandQuickAskWindow(heightOffset) + ) } diff --git a/electron/handlers/update.ts b/electron/handlers/update.ts index c8e28e580..3f52c401e 100644 --- a/electron/handlers/update.ts +++ b/electron/handlers/update.ts @@ -1,6 +1,11 @@ import { app, dialog } from 'electron' -import { WindowManager } from './../managers/window' -import { autoUpdater } from 'electron-updater' +import { windowManager } from './../managers/window' +import { + ProgressInfo, + UpdateDownloadedEvent, + UpdateInfo, + autoUpdater, +} from 'electron-updater' import { AppEvent } from '@janhq/core' export let waitingToInstallVersion: string | undefined = undefined @@ -11,7 +16,7 @@ export function handleAppUpdates() { return } /* New Update Available */ - autoUpdater.on('update-available', async (_info: any) => { + autoUpdater.on('update-available', async (_info: UpdateInfo) => { const action = await dialog.showMessageBox({ title: 'Update Available', message: 'Would you like to download and install it now?', @@ -21,8 +26,8 @@ export function handleAppUpdates() { }) /* App Update Completion Message */ - autoUpdater.on('update-downloaded', async (_info: any) => { - WindowManager.instance.currentWindow?.webContents.send( + autoUpdater.on('update-downloaded', async (_info: UpdateDownloadedEvent) => { + windowManager.mainWindow?.webContents.send( AppEvent.onAppUpdateDownloadSuccess, {} ) @@ -37,23 +42,24 @@ export function handleAppUpdates() { }) /* App Update Error */ - autoUpdater.on('error', (info: any) => { - WindowManager.instance.currentWindow?.webContents.send( + autoUpdater.on('error', (info: Error) => { + windowManager.mainWindow?.webContents.send( AppEvent.onAppUpdateDownloadError, { failedToInstallVersion: waitingToInstallVersion, info } ) }) /* App Update Progress */ - autoUpdater.on('download-progress', (progress: any) => { + autoUpdater.on('download-progress', (progress: ProgressInfo) => { console.debug('app update progress: ', progress.percent) - WindowManager.instance.currentWindow?.webContents.send( + windowManager.mainWindow?.webContents.send( AppEvent.onAppUpdateDownloadUpdate, { - percent: progress.percent, + ...progress, } ) }) + autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true if (process.env.CI !== 'e2e') { diff --git a/electron/icons/icon-tray.png b/electron/icons/icon-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..ab356a9dc4318aabb43fb6e8d3b04486a0c95fc7 GIT binary patch literal 1352 zcmZ`%3ou+~6#myHgrd^LdZk`jgpg|!E4DHvc7vp=-ibl$u~%ynkJwewNf2~0`ie)J zv?C@;5*Z<0QSV)k6)YnjiK=R)GAR{Qi)C4RdoF!Ur|q5p{*QCM@0|0U|IhLD@zl~V z(m)7ld3$mEVbvl3Jye)&>|YLm#Vp#(pNG)p()~H)8>aG%X6NB~&iA7Jx^@TDDFTw5OZl#)d#cN|+5nI>=`9wcO7Rc`fk6z|ZHP^lDpVnYqRVWd zhF&Qe_|s*p?6rb)vIZJtUvHsH>MvJ`djIe~vX_gDf2-1fs>oStvr?^A)keky3^9a= z!GqvZ_A=I*^w(Pq$Ox^5tMz-qC~q~GtaQ06{#M?11PWI6Fu=I-KxeCGKRG|#zg(*& zYtR7-Sk{{Lz-_L}GTv zeUbFnUJe*n8)(y4|F)xI0*t_gZd8jTThfEDhXJ|fZ*5=S_7N<~K5K=<92&cLS7isI z=rUif(faXI1Q)v1Z2=vCB0>AM*BbPKC{PM*-0ER$bz71*LaGA^2cQ*-ZVL!Vwvix? z`I)N2b14=2`;brVKfjk8RW)o?bdsIfQ*ZN9PnbTJvc7r5yLiz^m23HNR@x;W0j5UP zf70^$!qB$SoApcpWZR_S!_e%Rg5; z{`g!@(+9Uduh*i|ODl`ovPui;KDt-fa;1z;b$;#Yn!o83o~G=ls$^w)7+i?+=<2kz zC@qPOdNxgarNCf4Kfr%<2 zrjMhL%OOs>D$HxpD~6DJM#zs8;_T=o92UgHm`Cif!}b^}5OZN;Og6@{!!S06xt(0~ z{Rbf~fgc-_`u_w+I&U5bHh*Oh#>U|SVPst5zd4RBJ4wQxns9()NOA%aVuUG?NjOSL hNpXmcKP!liOvD`$lHMs?bTtAH^5*(*?jMiH{2h=^c>w?b literal 0 HcmV?d00001 diff --git a/electron/icons/icon-tray@2x.png b/electron/icons/icon-tray@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a82c285f53bd7bce072322ef67f6e9ffd73aba28 GIT binary patch literal 2269 zcmZ{lc{CJm7ssEOVeFc+W=*9cG?pxbNMjkYG)=N=8f%8k5@Y#gCMIS7$&xgw5E7~E zk|ngFcRP zd&$a;Nf|pM(`m83wXk^fF?+t3Scj`-x-{+0GO)xHCrR(xE3Yl1prVmfmfC^HN<42T ztOl26UZ5o;CS8-YBZbmR?Mhuj84?DL5YM1*wU$kz-XHTmvGQ2|m@llQ=MC3VH|`qc zoh18CQ(C6SD1FZ1DLvoU=vf^#3h&1u_bi8IlD$dIp0L!DLOPH;un6cQ_omDC!ckwIiK3imp)U;xZ(|GJ z?1l+LFtORJUfy3lU!t?S2Zq@r0J)=zp~KPTy#3+i+`^lE0_1sC%M>Ra+Q1R%A&<&v z;Ao4h00LIRof0c1lTjAK%?}AVZNC)4Eta&FcAQg3N?8W~p#|u=`b>n_7%?N_XDNFt*=mm4KQ6{iJ>uT5>MAC;86vnMoJ&aKJgo>6ac{FgqJW+!j3h2tStj2WXS;C7HIKzbNUJXpJoM> zb4SYFp4eC3p`J<(mamr+W>zh&uxcku6%&n!HyMa@!wq$x%9k}+jrfSK;|u7bJ0TiN zKW=W#!w!xd>77mUeIY~>HO}|rW*m1D{2|)izNq!(8*a9;i^GY%5_@FH8F{F$vj=XT z)02uRd?87b67O0k=?&}e{cAns!UrA1`h4ninvR(4uRSUPmj$%?G0zil0h$2ja>2Xc zAx`r0y*YtNlNJ?^ZI7L5i|{TG_BUf?v)q1jvt(aK!H8-7ag}b8t5SR>2aD&yg#+JFsdx1&5Grtf&Q5?-7 zIaE?_skkK3NhZmYv5J6puT~Lb?o_iTm{}S?O&jsCA3aGk%{IiAB?E@8!7H1@`yLaQ z@u#-@#EY2Kjl9^BY4|^LC9L-J^7tlSpb{LWxh!VQc8aP?|D%c`{84z1HdB#G!x+*W*0#$x@1jYxK2^pt zg9n+|x&!!+Z}7J=b_I`i1(_3eIfPI%aSZt1)V5nSb5TtsLHl=wSJoC}5z}I7qFt+@ z%86o!VX#pwle=0`#B~^bg$aZPyGJD$@3-_SxU1{>s(bQ@k{nPzXz>}&=^ous z%t*d)i-kj;AbD3BOUMK^J@#l%gwRK$5yE@)J^}P%k)B>`v!J1X6pS zUwyNc7jS(|kdco~uZ9efd@nKtp!QGV%ZYxek~gW;ac z!V3=`mo%Hs{A_A9w+uJX>Brlx6*d=_VjCoBN*dtA_)6iX4(Q-jXuA_y&Fr6jP&FH9 ztamL&nboU5bj?v5G?Zj^TNFcuG#KFRuy*ckG3v?Q{#*OSFX77PMGq62N#?OGZs%iS z&P-2kguO0-8zZ0~Jz$rq)J^{sl44;onGj2K@G4)gXt<>;x4m;d%hqeh*XQ%CC13cn z`t2O$fm2V{RpWrzz72HEtY2NR){4W8_y^mnrA0HbdTgz${0Xg2wRWTW;i>*80@(Jwe4>i+|&^?~_(;Lv|MP=iDL zDO7xD`2S#x{@vtScJKtB+5MY2eKCL { + if (!app.isPackaged) { + windowManager.mainWindow?.webContents.openDevTools() + } + }) + .then(() => { + const iconPath = join(app.getAppPath(), 'icons', 'icon-tray.png') + const tray = new Tray(iconPath) + tray.setToolTip(app.getName()) + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Open Jan', + type: 'normal', + click: () => windowManager.showMainWindow(), + }, + { + label: 'Open Quick Ask', + type: 'normal', + click: () => windowManager.showQuickAskWindow(), + }, + { label: 'Quit', type: 'normal', click: () => app.quit() }, + ]) + tray.setContextMenu(contextMenu) + }) + .then(() => { + log(`Version: ${app.getVersion()}`) + }) .then(() => { app.on('activate', () => { if (!BrowserWindow.getAllWindows().length) { @@ -45,45 +87,42 @@ app }) .then(() => cleanLogs()) -app.once('window-all-closed', () => { - cleanUpAndQuit() +app.on('ready', () => { + registerGlobalShortcuts() }) app.once('quit', () => { cleanUpAndQuit() }) +function createQuickAskWindow() { + const startUrl = app.isPackaged ? `file://${quickAskPath}` : quickAskUrl + windowManager.createQuickAskWindow(preloadPath, startUrl) +} + function createMainWindow() { - /* Create main window */ - const mainWindow = WindowManager.instance.createWindow({ - webPreferences: { - nodeIntegration: true, - preload: join(__dirname, 'preload.js'), - webSecurity: false, - }, + const startUrl = app.isPackaged ? `file://${mainPath}` : mainUrl + windowManager.createMainWindow(preloadPath, startUrl) +} + +function registerGlobalShortcuts() { + // TODO: Toggle below line when build production + // const ret = globalShortcut.register(quickAskHotKey, () => { + // const selectedText = '' + const ret = registerShortcut(quickAskHotKey, (selectedText: string) => { + if (!windowManager.isQuickAskWindowVisible()) { + windowManager.showQuickAskWindow() + windowManager.sendQuickAskSelectedText(selectedText) + } else { + windowManager.hideQuickAskWindow() + } }) - const startURL = app.isPackaged - ? `file://${join(__dirname, '..', 'renderer', 'index.html')}` - : 'http://localhost:3000' - - /* Load frontend app to the window */ - mainWindow.loadURL(startURL) - - mainWindow.once('ready-to-show', () => mainWindow?.show()) - mainWindow.on('closed', () => { - if (process.platform !== 'darwin') app.quit() - }) - - /* Open external links in the default browser */ - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url) - return { action: 'deny' } - }) - - /* Enable dev tools for development */ - if (!app.isPackaged) mainWindow.webContents.openDevTools() - log(`Version: ${app.getVersion()}`) + if (!ret) { + console.error('Global shortcut registration failed') + } else { + console.log('Global shortcut registered successfully') + } } /** diff --git a/electron/managers/mainWindowConfig.ts b/electron/managers/mainWindowConfig.ts new file mode 100644 index 000000000..184fb1c86 --- /dev/null +++ b/electron/managers/mainWindowConfig.ts @@ -0,0 +1,16 @@ +const DEFAULT_WIDTH = 1200 +const DEFAULT_HEIGHT = 800 + +export const mainWindowConfig: Electron.BrowserWindowConstructorOptions = { + width: DEFAULT_WIDTH, + minWidth: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + skipTaskbar: true, + show: true, + trafficLightPosition: { + x: 10, + y: 15, + }, + titleBarStyle: 'hiddenInset', + vibrancy: 'sidebar', +} diff --git a/electron/managers/quickAskWindowConfig.ts b/electron/managers/quickAskWindowConfig.ts new file mode 100644 index 000000000..4a5ce1e5d --- /dev/null +++ b/electron/managers/quickAskWindowConfig.ts @@ -0,0 +1,13 @@ +const DEFAULT_WIDTH = 556 + +const DEFAULT_HEIGHT = 60 + +export const quickAskWindowConfig: Electron.BrowserWindowConstructorOptions = { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + skipTaskbar: true, + resizable: false, + transparent: true, + frame: false, + type: 'panel', +} diff --git a/electron/managers/window.ts b/electron/managers/window.ts index 4edf505b2..5a5254bc8 100644 --- a/electron/managers/window.ts +++ b/electron/managers/window.ts @@ -1,37 +1,123 @@ -import { BrowserWindow } from 'electron' +import { BrowserWindow, app, shell } from 'electron' +import { quickAskWindowConfig } from './quickAskWindowConfig' +import { AppEvent } from '@janhq/core' +import { mainWindowConfig } from './mainWindowConfig' /** * Manages the current window instance. */ -export class WindowManager { - public static instance: WindowManager = new WindowManager() - public currentWindow?: BrowserWindow - - constructor() { - if (WindowManager.instance) { - return WindowManager.instance - } - } +// TODO: refactor this +let isAppQuitting = false +class WindowManager { + public mainWindow?: BrowserWindow + private _quickAskWindow: BrowserWindow | undefined = undefined + private _quickAskWindowVisible = false + private _mainWindowVisible = false /** * Creates a new window instance. * @param {Electron.BrowserWindowConstructorOptions} options - The options to create the window with. * @returns The created window instance. */ - createWindow(options?: Electron.BrowserWindowConstructorOptions | undefined) { - this.currentWindow = new BrowserWindow({ - width: 1200, - minWidth: 1200, - height: 800, - show: true, - trafficLightPosition: { - x: 10, - y: 15, + createMainWindow(preloadPath: string, startUrl: string) { + this.mainWindow = new BrowserWindow({ + ...mainWindowConfig, + webPreferences: { + nodeIntegration: true, + preload: preloadPath, + webSecurity: false, }, - titleBarStyle: 'hiddenInset', - vibrancy: 'sidebar', - ...options, }) - return this.currentWindow + + /* Load frontend app to the window */ + this.mainWindow.loadURL(startUrl) + + /* Open external links in the default browser */ + this.mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url) + return { action: 'deny' } + }) + + app.on('before-quit', function () { + isAppQuitting = true + }) + + windowManager.mainWindow?.on('close', function (evt) { + if (!isAppQuitting) { + evt.preventDefault() + windowManager.hideMainWindow() + } + }) + } + + createQuickAskWindow(preloadPath: string, startUrl: string): void { + this._quickAskWindow = new BrowserWindow({ + ...quickAskWindowConfig, + webPreferences: { + nodeIntegration: true, + preload: preloadPath, + webSecurity: false, + }, + }) + + this._quickAskWindow.loadURL(startUrl) + this._quickAskWindow.on('blur', () => { + this.hideQuickAskWindow() + }) + this.hideQuickAskWindow() + } + + isMainWindowVisible(): boolean { + return this._mainWindowVisible + } + + hideMainWindow(): void { + this.mainWindow?.hide() + this._mainWindowVisible = false + // Only macos + if (process.platform === 'darwin') app.dock.hide() + } + + showMainWindow(): void { + this.mainWindow?.show() + this._mainWindowVisible = true + // Only macos + if (process.platform === 'darwin') app.dock.show() + } + + hideQuickAskWindow(): void { + this._quickAskWindow?.hide() + this._quickAskWindowVisible = false + } + + showQuickAskWindow(): void { + this._quickAskWindow?.show() + this._quickAskWindowVisible = true + } + + isQuickAskWindowVisible(): boolean { + return this._quickAskWindowVisible + } + + expandQuickAskWindow(heightOffset: number): void { + const width = quickAskWindowConfig.width! + const height = quickAskWindowConfig.height! + heightOffset + this._quickAskWindow?.setSize(width, height, true) + } + + sendQuickAskSelectedText(selectedText: string): void { + this._quickAskWindow?.webContents.send( + AppEvent.onSelectedText, + selectedText + ) + } + + cleanUp(): void { + this.mainWindow?.destroy() + this._quickAskWindow?.destroy() + this._quickAskWindowVisible = false + this._mainWindowVisible = false } } + +export const windowManager = new WindowManager() diff --git a/electron/package.json b/electron/package.json index cd6b83137..93c30682c 100644 --- a/electron/package.json +++ b/electron/package.json @@ -16,13 +16,15 @@ "pre-install", "models/**/*", "docs/**/*", - "scripts/**/*" + "scripts/**/*", + "icons/**/*" ], "asarUnpack": [ "pre-install", "models", "docs", - "scripts" + "scripts", + "icons" ], "publish": [ { @@ -39,6 +41,7 @@ "notarize": { "teamId": "F8AH6NHVY5" }, + "icon": "icons/icon.png" }, "linux": { @@ -81,7 +84,6 @@ "@janhq/core": "link:./core", "@janhq/server": "link:./server", "@npmcli/arborist": "^7.1.0", - "@uiball/loaders": "^1.3.0", "electron-store": "^8.1.0", "electron-updater": "^6.1.7", "fs-extra": "^11.2.0", @@ -90,7 +92,7 @@ "request": "^2.88.2", "request-progress": "^3.0.0", "ulid": "^2.3.0", - "use-debounce": "^9.0.4" + "@hurdlegroup/robotjs": "^0.11.4" }, "devDependencies": { "@electron/notarize": "^2.1.0", @@ -101,13 +103,15 @@ "@typescript-eslint/eslint-plugin": "^6.7.3", "@typescript-eslint/parser": "^6.7.3", "electron": "28.0.0", - "electron-builder": "^24.9.1", + "electron-builder": "^24.13.3", + "electron-builder-squirrel-windows": "^24.13.3", "electron-devtools-installer": "^3.2.0", "electron-playwright-helpers": "^1.6.0", - "eslint-plugin-react": "^7.33.2", + "eslint": "8.57.0", + "eslint-plugin-react": "^7.34.0", "rimraf": "^5.0.5", "run-script-os": "^1.1.6", - "typescript": "^5.2.2" + "typescript": "^5.3.3" }, "installConfig": { "hoistingLimits": "workspaces" diff --git a/electron/utils/clean.ts b/electron/utils/clean.ts index 2334b589a..12a68d39e 100644 --- a/electron/utils/clean.ts +++ b/electron/utils/clean.ts @@ -1,12 +1,12 @@ import { ModuleManager } from '@janhq/core/node' -import { WindowManager } from './../managers/window' +import { windowManager } from './../managers/window' import { dispose } from './disposable' import { app } from 'electron' export function cleanUpAndQuit() { if (!ModuleManager.instance.cleaningResource) { ModuleManager.instance.cleaningResource = true - WindowManager.instance.currentWindow?.destroy() + windowManager.cleanUp() dispose(ModuleManager.instance.requiredModules) ModuleManager.instance.clearImportedModules() app.quit() diff --git a/electron/utils/selectedText.ts b/electron/utils/selectedText.ts new file mode 100644 index 000000000..6b2349725 --- /dev/null +++ b/electron/utils/selectedText.ts @@ -0,0 +1,39 @@ +import { clipboard, globalShortcut } from "electron"; +import { keyTap, keys } from "@hurdlegroup/robotjs"; + +/** + * Gets selected text by synthesizing the keyboard shortcut + * "CommandOrControl+c" then reading text from the clipboard + */ +export const getSelectedText = async () => { + const currentClipboardContent = clipboard.readText(); // preserve clipboard content + clipboard.clear(); + keyTap("c" as keys, process.platform === "darwin" ? "command" : "control"); + await new Promise((resolve) => setTimeout(resolve, 200)); // add a delay before checking clipboard + const selectedText = clipboard.readText(); + clipboard.writeText(currentClipboardContent); + return selectedText; +}; + +/** + * Registers a global shortcut of `accelerator`. The `callback` is called + * with the selected text when the registered shorcut is pressed by the user + * + * Returns `true` if the shortcut was registered successfully + */ +export const registerShortcut = ( + accelerator: Electron.Accelerator, + callback: (selectedText: string) => void +) => { + return globalShortcut.register(accelerator, async () => { + callback(await getSelectedText()); + }); +}; + +/** + * Unregisters a global shortcut of `accelerator` and + * is equivalent to electron.globalShortcut.unregister + */ +export const unregisterShortcut = (accelerator: Electron.Accelerator) => { + globalShortcut.unregister(accelerator); +}; \ No newline at end of file diff --git a/extensions/assistant-extension/package.json b/extensions/assistant-extension/package.json index baa858655..e3860a1c1 100644 --- a/extensions/assistant-extension/package.json +++ b/extensions/assistant-extension/package.json @@ -26,7 +26,7 @@ "rollup-plugin-define": "^1.0.1", "rollup-plugin-sourcemaps": "^0.6.3", "rollup-plugin-typescript2": "^0.36.0", - "typescript": "^5.2.2", + "typescript": "^5.3.3", "run-script-os": "^1.1.6" }, "dependencies": { diff --git a/extensions/inference-nitro-extension/package.json b/extensions/inference-nitro-extension/package.json index ba6b473eb..e6365ad92 100644 --- a/extensions/inference-nitro-extension/package.json +++ b/extensions/inference-nitro-extension/package.json @@ -35,7 +35,7 @@ "rollup-plugin-sourcemaps": "^0.6.3", "rollup-plugin-typescript2": "^0.36.0", "run-script-os": "^1.1.6", - "typescript": "^5.2.2", + "typescript": "^5.3.3", "@types/os-utils": "^0.0.4", "@rollup/plugin-replace": "^5.0.5" }, diff --git a/server/package.json b/server/package.json index d9a2bbc9a..b2c237c61 100644 --- a/server/package.json +++ b/server/package.json @@ -39,8 +39,8 @@ "@types/tcp-port-used": "^1.0.4", "@typescript-eslint/eslint-plugin": "^6.7.3", "@typescript-eslint/parser": "^6.7.3", - "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react": "^7.34.0", "run-script-os": "^1.1.6", - "typescript": "^5.2.2" + "typescript": "^5.3.3" } } diff --git a/uikit/package.json b/uikit/package.json index 66f05840b..b011ed497 100644 --- a/uikit/package.json +++ b/uikit/package.json @@ -52,6 +52,6 @@ "tailwind-merge": "^2.0.0", "terser": "^5.24.0", "tsup": "^7.2.0", - "typescript": "^5.2.2" + "typescript": "^5.3.3" } } diff --git a/web/app/search/SelectedText.tsx b/web/app/search/SelectedText.tsx new file mode 100644 index 000000000..742eba956 --- /dev/null +++ b/web/app/search/SelectedText.tsx @@ -0,0 +1,47 @@ +import React, { useCallback, useEffect, useRef } from 'react' + +import { useAtom } from 'jotai' +import { X } from 'lucide-react' + +import { selectedTextAtom } from '@/containers/Providers/Jotai' + +const SelectedText = ({ onCleared }: { onCleared?: () => void }) => { + const [text, setText] = useAtom(selectedTextAtom) + const containerRef = useRef(null) + + useEffect(() => { + if (text.trim().length === 0) { + window.core?.api?.quickAskSizeUpdated(0) + } else { + window.core?.api?.quickAskSizeUpdated( + (containerRef.current?.offsetHeight ?? 0) + 14 + ) + } + }) + + const onClearClicked = useCallback(() => { + setText('') + onCleared?.() + }, [setText, onCleared]) + + const shouldShowSelectedText = text.trim().length > 0 + + return shouldShowSelectedText ? ( +
+
+ +
+

{text}

+
+ ) : ( +
+ ) +} + +export default SelectedText diff --git a/web/app/search/UserInput.tsx b/web/app/search/UserInput.tsx new file mode 100644 index 000000000..a5fbfc682 --- /dev/null +++ b/web/app/search/UserInput.tsx @@ -0,0 +1,86 @@ +import React, { useState, useRef, useEffect } from 'react' + +import { Button } from '@janhq/uikit' +import { useAtomValue } from 'jotai' + +import { Send } from 'lucide-react' + +import LogoMark from '@/containers/Brand/Logo/Mark' + +import { selectedTextAtom } from '@/containers/Providers/Jotai' + +import SelectedText from './SelectedText' + +const UserInput: React.FC = () => { + const [inputValue, setInputValue] = useState('') + const inputRef = useRef(null) + const formRef = useRef(null) + const selectedText = useAtomValue(selectedTextAtom) + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus() + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + window.core?.api?.hideQuickAskWindow() + } + } + + document.addEventListener('keydown', onKeyDown) + + return () => { + document.removeEventListener('keydown', onKeyDown) + } + }, []) + + const handleChange = ( + event: + | React.ChangeEvent + | React.ChangeEvent + ) => { + const { value } = event.target + setInputValue(value) + } + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (inputValue.trim() !== '') { + const fullText = `${inputValue} ${selectedText}`.trim() + window.core?.api?.sendQuickAskInput(fullText) + setInputValue('') + window.core?.api?.hideQuickAskWindow() + window.core?.api?.showMainWindow() + } + } + + return ( +
+
+
+ + + +
+
+ + inputRef?.current?.focus()} /> +
+ ) +} + +export default UserInput diff --git a/web/app/search/page.tsx b/web/app/search/page.tsx new file mode 100644 index 000000000..0822c2676 --- /dev/null +++ b/web/app/search/page.tsx @@ -0,0 +1,13 @@ +'use client' + +import UserInput from './UserInput' + +const Search: React.FC = () => { + return ( +
+ +
+ ) +} + +export default Search diff --git a/web/containers/Providers/AppUpdateListener.tsx b/web/containers/Providers/AppUpdateListener.tsx index 542886ee5..d339b240a 100644 --- a/web/containers/Providers/AppUpdateListener.tsx +++ b/web/containers/Providers/AppUpdateListener.tsx @@ -1,6 +1,6 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { Fragment, PropsWithChildren, useEffect } from 'react' +import { AppUpdateInfo } from '@janhq/core' import { useSetAtom } from 'jotai' import { appDownloadProgress, updateVersionError } from './Jotai' @@ -12,13 +12,14 @@ const AppUpdateListener = ({ children }: PropsWithChildren) => { useEffect(() => { if (window && window.electronAPI) { window.electronAPI.onAppUpdateDownloadUpdate( - (_event: string, progress: any) => { - setProgress(progress.percent) - console.debug('app update progress:', progress.percent) + (_event: string, appUpdateInfo: AppUpdateInfo) => { + setProgress(appUpdateInfo.percent) + console.debug('app update progress:', appUpdateInfo.percent) } ) window.electronAPI.onAppUpdateDownloadError( + // eslint-disable-next-line @typescript-eslint/no-explicit-any (_event: string, error: any) => { console.error('Download error: ', error) setProgress(-1) @@ -33,8 +34,7 @@ const AppUpdateListener = ({ children }: PropsWithChildren) => { setProgress(-1) }) } - return () => {} - }, [setProgress]) + }, [setProgress, setUpdateVersionError]) return {children} } diff --git a/web/containers/Providers/ClipboardListener.tsx b/web/containers/Providers/ClipboardListener.tsx new file mode 100644 index 000000000..780515461 --- /dev/null +++ b/web/containers/Providers/ClipboardListener.tsx @@ -0,0 +1,17 @@ +import { Fragment, PropsWithChildren } from 'react' + +import { useSetAtom } from 'jotai' + +import { selectedTextAtom } from './Jotai' + +const ClipboardListener = ({ children }: PropsWithChildren) => { + const setSelectedText = useSetAtom(selectedTextAtom) + + window?.electronAPI?.onSelectedText((_event: string, text: string) => { + setSelectedText(text) + }) + + return {children} +} + +export default ClipboardListener diff --git a/web/containers/Providers/DataLoader.tsx b/web/containers/Providers/DataLoader.tsx index fb439c92f..bc1461d5b 100644 --- a/web/containers/Providers/DataLoader.tsx +++ b/web/containers/Providers/DataLoader.tsx @@ -2,7 +2,7 @@ import { Fragment, ReactNode, useEffect } from 'react' -import { AppConfiguration } from '@janhq/core/.' +import { AppConfiguration } from '@janhq/core' import { useSetAtom } from 'jotai' import useAssistants from '@/hooks/useAssistants' diff --git a/web/containers/Providers/EventHandler.tsx b/web/containers/Providers/EventHandler.tsx index 1dd0bd042..f0020d311 100644 --- a/web/containers/Providers/EventHandler.tsx +++ b/web/containers/Providers/EventHandler.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { Fragment, ReactNode, useCallback, useEffect, useRef } from 'react' import { @@ -15,6 +14,7 @@ import { MessageRequestType, ModelEvent, Thread, + ModelInitFailed, } from '@janhq/core' import { useAtomValue, useSetAtom } from 'jotai' import { ulid } from 'ulid' @@ -113,15 +113,14 @@ export default function EventHandler({ children }: { children: ReactNode }) { }, [setActiveModel, setStateModel]) const onModelInitFailed = useCallback( - (res: any) => { - const errorMessage = res?.error ?? res - console.error('Failed to load model: ', errorMessage) + (res: ModelInitFailed) => { + console.error('Failed to load model: ', res.error.message) setStateModel(() => ({ state: 'start', loading: false, - model: res.modelId, + model: res.id, })) - setLoadModelError(errorMessage) + setLoadModelError(res.error.message) setQueuedMessage(false) }, [setStateModel, setQueuedMessage, setLoadModelError] @@ -245,7 +244,7 @@ export default function EventHandler({ children }: { children: ReactNode }) { if (!threadMessages || threadMessages.length === 0) return - const summarizeFirstPrompt = `Summarize this text "${threadMessages[0].content[0].text.value}" for a conversation title in less than 10 words` + const summarizeFirstPrompt = `Summarize in a 5-word Title. Give the title only. "${threadMessages[0].content[0].text.value}"` // Prompt: Given this query from user {query}, return to me the summary in 5 words as the title const msgId = ulid() const messages: ChatCompletionMessage[] = [ diff --git a/web/containers/Providers/EventListener.tsx b/web/containers/Providers/EventListener.tsx index 9febbade5..bfc87917b 100644 --- a/web/containers/Providers/EventListener.tsx +++ b/web/containers/Providers/EventListener.tsx @@ -8,9 +8,11 @@ import { useSetAtom } from 'jotai' import { setDownloadStateAtom } from '@/hooks/useDownloadState' import AppUpdateListener from './AppUpdateListener' +import ClipboardListener from './ClipboardListener' import EventHandler from './EventHandler' import ModelImportListener from './ModelImportListener' +import QuickAskListener from './QuickAskListener' const EventListenerWrapper = ({ children }: PropsWithChildren) => { const setDownloadState = useSetAtom(setDownloadStateAtom) @@ -55,9 +57,13 @@ const EventListenerWrapper = ({ children }: PropsWithChildren) => { return ( - - {children} - + + + + {children} + + + ) } diff --git a/web/containers/Providers/Jotai.tsx b/web/containers/Providers/Jotai.tsx index c43786c89..7829041e9 100644 --- a/web/containers/Providers/Jotai.tsx +++ b/web/containers/Providers/Jotai.tsx @@ -15,6 +15,8 @@ export const appDownloadProgress = atom(-1) export const updateVersionError = atom(undefined) export const searchAtom = atom('') +export const selectedTextAtom = atom('') + export default function JotaiWrapper({ children }: Props) { return {children} } diff --git a/web/containers/Providers/KeyListener.tsx b/web/containers/Providers/KeyListener.tsx index a4702783c..d832059c2 100644 --- a/web/containers/Providers/KeyListener.tsx +++ b/web/containers/Providers/KeyListener.tsx @@ -24,6 +24,10 @@ export default function KeyListener({ children }: Props) { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + window.core?.api?.hideMainWindow() + } + const prefixKey = isMac ? e.metaKey : e.ctrlKey if (e.key === 'b' && prefixKey) { diff --git a/web/containers/Providers/QuickAskListener.tsx b/web/containers/Providers/QuickAskListener.tsx new file mode 100644 index 000000000..3073c9036 --- /dev/null +++ b/web/containers/Providers/QuickAskListener.tsx @@ -0,0 +1,39 @@ +import { Fragment, ReactNode, useRef } from 'react' + +import { useSetAtom } from 'jotai' + +import { MainViewState } from '@/constants/screens' + +import useSendChatMessage from '@/hooks/useSendChatMessage' + +import { showRightSideBarAtom } from '@/screens/Chat/Sidebar' + +import { showLeftSideBarAtom } from './KeyListener' + +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' + +type Props = { + children: ReactNode +} + +const QuickAskListener: React.FC = ({ children }) => { + const { sendChatMessage } = useSendChatMessage() + const setShowRightSideBar = useSetAtom(showRightSideBarAtom) + const setShowLeftSideBar = useSetAtom(showLeftSideBarAtom) + const setMainState = useSetAtom(mainViewStateAtom) + + const previousMessage = useRef('') + + window.electronAPI.onUserSubmitQuickAsk((_event: string, input: string) => { + if (previousMessage.current === input) return + setMainState(MainViewState.Thread) + setShowRightSideBar(false) + setShowLeftSideBar(false) + sendChatMessage(input) + previousMessage.current = input + }) + + return {children} +} + +export default QuickAskListener diff --git a/web/helpers/atoms/Assistant.atom.ts b/web/helpers/atoms/Assistant.atom.ts index e90923d3d..d44703cf4 100644 --- a/web/helpers/atoms/Assistant.atom.ts +++ b/web/helpers/atoms/Assistant.atom.ts @@ -1,4 +1,4 @@ -import { Assistant } from '@janhq/core/.' +import { Assistant } from '@janhq/core' import { atom } from 'jotai' export const assistantsAtom = atom([]) diff --git a/web/hooks/useActiveModel.ts b/web/hooks/useActiveModel.ts index 1b61a0dd1..600e10783 100644 --- a/web/hooks/useActiveModel.ts +++ b/web/hooks/useActiveModel.ts @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react' + import { events, Model, ModelEvent } from '@janhq/core' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' @@ -24,6 +26,12 @@ export function useActiveModel() { const downloadedModels = useAtomValue(downloadedModelsAtom) const setLoadModelError = useSetAtom(loadModelErrorAtom) + const downloadedModelsRef = useRef([]) + + useEffect(() => { + downloadedModelsRef.current = downloadedModels + }, [downloadedModels]) + const startModel = async (modelId: string) => { if ( (activeModel && activeModel.id === modelId) || @@ -39,7 +47,7 @@ export function useActiveModel() { setStateModel({ state: 'start', loading: true, model: modelId }) - let model = downloadedModels.find((e) => e.id === modelId) + let model = downloadedModelsRef?.current.find((e) => e.id === modelId) if (!model) { toaster({ diff --git a/web/hooks/useSendChatMessage.ts b/web/hooks/useSendChatMessage.ts index 09c64a0f1..9e88e763a 100644 --- a/web/hooks/useSendChatMessage.ts +++ b/web/hooks/useSendChatMessage.ts @@ -63,9 +63,8 @@ export default function useSendChatMessage() { const setEditPrompt = useSetAtom(editPromptAtom) const currentMessages = useAtomValue(getCurrentChatMessagesAtom) - const { activeModel } = useActiveModel() const selectedModel = useAtomValue(selectedModelAtom) - const { startModel } = useActiveModel() + const { activeModel, startModel } = useActiveModel() const setQueuedMessage = useSetAtom(queuedMessageAtom) const loadModelFailed = useAtomValue(loadModelErrorAtom) @@ -78,6 +77,7 @@ export default function useSendChatMessage() { const setReloadModel = useSetAtom(reloadModelAtom) const [fileUpload, setFileUpload] = useAtom(fileUploadAtom) const setIsGeneratingResponse = useSetAtom(isGeneratingResponseAtom) + const activeThreadRef = useRef() useEffect(() => { modelRef.current = activeModel @@ -87,15 +87,19 @@ export default function useSendChatMessage() { loadModelFailedRef.current = loadModelFailed }, [loadModelFailed]) + useEffect(() => { + activeThreadRef.current = activeThread + }, [activeThread]) + const resendChatMessage = async (currentMessage: ThreadMessage) => { - if (!activeThread) { + if (!activeThreadRef.current) { console.error('No active thread') return } setIsGeneratingResponse(true) - updateThreadWaiting(activeThread.id, true) + updateThreadWaiting(activeThreadRef.current.id, true) const messages: ChatCompletionMessage[] = [ - activeThread.assistants[0]?.instructions, + activeThreadRef.current.assistants[0]?.instructions, ] .filter((e) => e && e.trim() !== '') .map((instructions) => { @@ -123,13 +127,14 @@ export default function useSendChatMessage() { id: ulid(), type: MessageRequestType.Thread, messages: messages, - threadId: activeThread.id, - model: activeThread.assistants[0].model ?? selectedModel, + threadId: activeThreadRef.current.id, + model: activeThreadRef.current.assistants[0].model ?? selectedModel, } - const modelId = selectedModel?.id ?? activeThread.assistants[0].model.id + const modelId = + selectedModel?.id ?? activeThreadRef.current.assistants[0].model.id - if (activeModel?.id !== modelId) { + if (modelRef.current?.id !== modelId) { setQueuedMessage(true) startModel(modelId) await waitForModelStarting(modelId) @@ -139,11 +144,11 @@ export default function useSendChatMessage() { if (currentMessage.role !== ChatCompletionRole.User) { // Delete last response before regenerating deleteMessage(currentMessage.id ?? '') - if (activeThread) { + if (activeThreadRef.current) { await extensionManager .get(ExtensionTypeEnum.Conversational) ?.writeMessages( - activeThread.id, + activeThreadRef.current.id, currentMessages.filter((msg) => msg.id !== currentMessage.id) ) } @@ -154,7 +159,7 @@ export default function useSendChatMessage() { const sendChatMessage = async (message: string) => { if (!message || message.trim().length === 0) return - if (!activeThread) { + if (!activeThreadRef.current) { console.error('No active thread') return } @@ -165,7 +170,7 @@ export default function useSendChatMessage() { const runtimeParams = toRuntimeParams(activeModelParams) const settingParams = toSettingParams(activeModelParams) - updateThreadWaiting(activeThread.id, true) + updateThreadWaiting(activeThreadRef.current.id, true) const prompt = message.trim() setCurrentPrompt('') setEditPrompt('') @@ -187,7 +192,7 @@ export default function useSendChatMessage() { } const messages: ChatCompletionMessage[] = [ - activeThread.assistants[0]?.instructions, + activeThreadRef.current.assistants[0]?.instructions, ] .filter((e) => e && e.trim() !== '') .map((instructions) => { @@ -218,7 +223,7 @@ export default function useSendChatMessage() { ? { type: ChatCompletionMessageContentType.Doc, doc_url: { - url: `threads/${activeThread.id}/files/${msgId}.pdf`, + url: `threads/${activeThreadRef.current.id}/files/${msgId}.pdf`, }, } : null, @@ -236,13 +241,14 @@ export default function useSendChatMessage() { ]) ) - let modelRequest = selectedModel ?? activeThread.assistants[0].model + let modelRequest = + selectedModel ?? activeThreadRef.current.assistants[0].model if (runtimeParams.stream == null) { runtimeParams.stream = true } // Add middleware to the model request with tool retrieval enabled if ( - activeThread.assistants[0].tools?.some( + activeThreadRef.current.assistants[0].tools?.some( (tool: AssistantTool) => tool.type === 'retrieval' && tool.enabled ) ) { @@ -260,14 +266,14 @@ export default function useSendChatMessage() { const messageRequest: MessageRequest = { id: msgId, type: MessageRequestType.Thread, - threadId: activeThread.id, + threadId: activeThreadRef.current.id, messages, model: { ...modelRequest, settings: settingParams, parameters: runtimeParams, }, - thread: activeThread, + thread: activeThreadRef.current, } const timestamp = Date.now() @@ -307,7 +313,7 @@ export default function useSendChatMessage() { const threadMessage: ThreadMessage = { id: msgId, - thread_id: activeThread.id, + thread_id: activeThreadRef.current.id, role: ChatCompletionRole.User, status: MessageStatus.Ready, created: timestamp, @@ -322,10 +328,10 @@ export default function useSendChatMessage() { } const updatedThread: Thread = { - ...activeThread, + ...activeThreadRef.current, updated: timestamp, metadata: { - ...(activeThread.metadata ?? {}), + ...(activeThreadRef.current.metadata ?? {}), lastMessage: prompt, }, } @@ -337,9 +343,10 @@ export default function useSendChatMessage() { .get(ExtensionTypeEnum.Conversational) ?.addNewMessage(threadMessage) - const modelId = selectedModel?.id ?? activeThread.assistants[0].model.id + const modelId = + selectedModel?.id ?? activeThreadRef.current.assistants[0].model.id - if (activeModel?.id !== modelId) { + if (modelRef.current?.id !== modelId) { setQueuedMessage(true) startModel(modelId) await waitForModelStarting(modelId) diff --git a/web/package.json b/web/package.json index 0a8af0f92..e3301f68b 100644 --- a/web/package.json +++ b/web/package.json @@ -37,6 +37,7 @@ "react-dropzone": "^14.2.3", "react-hook-form": "^7.47.0", "react-hot-toast": "^2.4.1", + "csstype": "^3.0.10", "react-icons": "^4.12.0", "react-scroll-to-bottom": "^4.2.0", "react-toastify": "^9.1.3", @@ -66,11 +67,11 @@ "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.28.1", "eslint-plugin-prettier": "^5.0.1", - "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react": "^7.34.0", "eslint-plugin-react-hooks": "^4.6.0", "prettier": "^3.0.3", "prettier-plugin-tailwindcss": "^0.5.6", "rimraf": "^5.0.5", - "typescript": "^5.2.2" + "typescript": "^5.3.3" } } diff --git a/web/screens/Settings/ImportingModelModal/ImportingModelItem.tsx b/web/screens/Settings/ImportingModelModal/ImportingModelItem.tsx index 0426c3dd0..d4d50722f 100644 --- a/web/screens/Settings/ImportingModelModal/ImportingModelItem.tsx +++ b/web/screens/Settings/ImportingModelModal/ImportingModelItem.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react' -import { ImportingModel } from '@janhq/core/.' +import { ImportingModel } from '@janhq/core' import { useSetAtom } from 'jotai' import { AlertCircle } from 'lucide-react'