Merge branch 'main' into docs/add-guides

This commit is contained in:
Hieu 2023-12-28 15:28:32 +07:00 committed by GitHub
commit 4f9482263b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 878 additions and 471 deletions

View File

@ -7,22 +7,19 @@ assignees: ''
--- ---
**Motivation** ## Motivation
- -
**Specs & Designs** ## Specs
- -
**In Scope** ## Designs
[Figma](link)
## Tasklist
- [ ]
## Not in Scope
- -
**Not in Scope** ## Appendix
-
**Tasklist**
> Note: All issues need to share the same `milestone` as this epic
-
**Related Milestones**
- Past
- Future

View File

@ -70,7 +70,7 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute
<tr style="text-align: center"> <tr style="text-align: center">
<td style="text-align:center"><b>Experimental (Nighlty Build)</b></td> <td style="text-align:center"><b>Experimental (Nighlty Build)</b></td>
<td style="text-align:center" colspan="4"> <td style="text-align:center" colspan="4">
<a href='https://github.com/janhq/jan/actions/runs/7324039788'> <a href='https://github.com/janhq/jan/actions/runs/7341513351'>
<b>Github action artifactory</b> <b>Github action artifactory</b>
</a> </a>
</td> </td>

View File

@ -10,6 +10,7 @@ export enum AppRoute {
openAppDirectory = 'openAppDirectory', openAppDirectory = 'openAppDirectory',
openFileExplore = 'openFileExplorer', openFileExplore = 'openFileExplorer',
relaunch = 'relaunch', relaunch = 'relaunch',
joinPath = 'joinPath'
} }
export enum AppEvent { export enum AppEvent {

View File

@ -44,6 +44,13 @@ const getUserSpace = (): Promise<string> => global.core.api?.getUserSpace()
const openFileExplorer: (path: string) => Promise<any> = (path) => const openFileExplorer: (path: string) => Promise<any> = (path) =>
global.core.api?.openFileExplorer(path) global.core.api?.openFileExplorer(path)
/**
* Joins multiple paths together.
* @param paths - The paths to join.
* @returns {Promise<string>} A promise that resolves with the joined path.
*/
const joinPath: (paths: string[]) => Promise<string> = (paths) => global.core.api?.joinPath(paths)
const getResourcePath: () => Promise<string> = () => global.core.api?.getResourcePath() const getResourcePath: () => Promise<string> = () => global.core.api?.getResourcePath()
/** /**
@ -66,4 +73,5 @@ export {
getUserSpace, getUserSpace,
openFileExplorer, openFileExplorer,
getResourcePath, getResourcePath,
joinPath,
} }

View File

@ -67,13 +67,6 @@ export type Model = {
*/ */
description: string description: string
/**
* The model state.
* Default: "to_download"
* Enum: "to_download" "downloading" "ready" "running"
*/
state?: ModelState
/** /**
* The model settings. * The model settings.
*/ */
@ -101,15 +94,6 @@ export type ModelMetadata = {
cover?: string cover?: string
} }
/**
* The Model transition states.
*/
export enum ModelState {
Downloading = 'downloading',
Ready = 'ready',
Running = 'running',
}
/** /**
* The available model settings. * The available model settings.
*/ */

View File

@ -1,24 +1,42 @@
--- ---
title: Import Models Manually title: Import Models Manually
slug: /guides/using-models/import-manually
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords:
[
Jan AI,
Jan,
ChatGPT alternative,
local AI,
private AI,
conversational AI,
no-subscription fee,
large language model,
import-models-manually,
]
--- ---
:::caution
This is currently under development.
:::
{/* Imports */} {/* Imports */}
import Tabs from "@theme/Tabs"; import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem"; import TabItem from "@theme/TabItem";
Jan is compatible with all GGUF models. Jan is compatible with all GGUF models.
If you don't see the model you want in the Hub, or if you have a custom model, you can add it to Jan. If you can not find the model you want in the Hub or have a custom model you want to use, you can import it manually.
In this guide we will use our latest model, [Trinity](https://huggingface.co/janhq/trinity-v1-GGUF), as an example. In this guide, we will show you how to import a GGUF model from [HuggingFace](https://huggingface.co/), using our lastest model, [Trinity](https://huggingface.co/janhq/trinity-v1-GGUF), as an example.
> We are fast shipping a UI to make this easier, but it's a bit manual for now. Apologies. > We are fast shipping a UI to make this easier, but it's a bit manual for now. Apologies.
## 1. Create a model folder ## Steps to Manually Import a Model
Navigate to the `~/jan/models` folder on your computer. ### 1. Create a Model Folder
In `App Settings`, go to `Advanced`, then `Open App Directory`. Navigate to the `~/jan/models` folder. You can find this folder by going to `App Settings` > `Advanced` > `Open App Directory`.
<Tabs groupId="operating-systems"> <Tabs groupId="operating-systems">
<TabItem value="mac" label="macOS"> <TabItem value="mac" label="macOS">
@ -70,11 +88,11 @@ In the `models` folder, create a folder with the name of the model.
</TabItem> </TabItem>
</Tabs> </Tabs>
## 2. Create a model JSON ### 2. Create a Model JSON
Jan follows a folder-based, [standard model template](/specs/models) called a `model.json` to persist the model configurations on your local filesystem. Jan follows a folder-based, [standard model template](/docs/engineering/models) called a `model.json` to persist the model configurations on your local filesystem.
This means you can easily & transparently reconfigure your models and export and share your preferences. This means that you can easily reconfigure your models, export them, and share your preferences transparently.
<Tabs groupId="operating-systems"> <Tabs groupId="operating-systems">
<TabItem value="mac" label="macOS"> <TabItem value="mac" label="macOS">
@ -89,7 +107,7 @@ This means you can easily & transparently reconfigure your models and export and
```sh ```sh
cd trinity-v1-7b cd trinity-v1-7b
touch model.json echo {} > model.json
``` ```
</TabItem> </TabItem>
@ -103,45 +121,53 @@ This means you can easily & transparently reconfigure your models and export and
</TabItem> </TabItem>
</Tabs> </Tabs>
Copy the following configurations into the `model.json`. Edit `model.json` and include the following configurations:
1. Make sure the `id` property is the same as the folder name you created. - Ensure the filename must be `model.json`.
2. Make sure the `source_url` property is the direct binary download link ending in `.gguf`. In HuggingFace, you can find the directl links in `Files and versions` tab. - Ensure the `id` property matches the folder name you created.
3. Ensure you are using the correct `prompt_template`. This is usually provided in the HuggingFace model's description page. - Ensure the GGUF filename should match the `id` property exactly.
- Ensure the `source_url` property is the direct binary download link ending in `.gguf`. In HuggingFace, you can find the direct links in `Files and versions` tab.
- Ensure you are using the correct `prompt_template`. This is usually provided in the HuggingFace model's description page.
- Ensure the `state` property is set to `ready`.
```js ```js
{ {
// highlight-start
"source_url": "https://huggingface.co/janhq/trinity-v1-GGUF/resolve/main/trinity-v1.Q4_K_M.gguf", "source_url": "https://huggingface.co/janhq/trinity-v1-GGUF/resolve/main/trinity-v1.Q4_K_M.gguf",
"id": "trinity-v1-7b", "id": "trinity-v1-7b",
// highlight-end
"object": "model", "object": "model",
"name": "Trinity 7B Q4", "name": "Trinity-v1 7B Q4",
"version": "1.0", "version": "1.0",
"description": "Trinity is an experimental model merge of GreenNodeLM & LeoScorpius using the Slerp method. Recommended for daily assistance purposes.", "description": "Trinity is an experimental model merge of GreenNodeLM & LeoScorpius using the Slerp method. Recommended for daily assistance purposes.",
"format": "gguf", "format": "gguf",
"settings": { "settings": {
"ctx_len": 2048, "ctx_len": 4096,
"prompt_template": "<|im_start|>system\n{system_message}<|im_end|>\n<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant" // highlight-next-line
"prompt_template": "{system_message}\n### Instruction:\n{prompt}\n### Response:"
}, },
"parameters": { "parameters": {
"max_tokens": 2048 "max_tokens": 4096
}, },
"metadata": { "metadata": {
"author": "Jan", "author": "Jan",
"tags": ["7B", "Merged", "Featured"], "tags": ["7B", "Merged"],
"size": 4370000000 "size": 4370000000
}, },
// highlight-next-line
"state": "ready",
"engine": "nitro" "engine": "nitro"
} }
``` ```
## 3. Download your model ### 3. Download the Model
Restart the Jan application and look for your model in the Hub. Restart Jan and navigate to the Hub. Locate your model and click the `Download` button to download the model binary.
Click the green `download` button to download your actual model binary. This pulls from the `source_url` you provided above. ![image](assets/download-model.png)
![image](https://hackmd.io/_uploads/HJLAqvwI6.png) Your model is now ready to use in Jan.
There you go! You are ready to use your model. ## Assistance and Support
If you have any questions or want to request for more preconfigured GGUF models, please message us in [Discord](https://discord.gg/Dt7MxDyNNZ). If you have questions or are looking for more preconfigured GGUF models, please feel free to join our [Discord community](https://discord.gg/Dt7MxDyNNZ) for support, updates, and discussions.

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

View File

@ -48,6 +48,13 @@ export function handleAppIPCs() {
shell.openPath(url) shell.openPath(url)
}) })
/**
* Joins multiple paths together, respect to the current OS.
*/
ipcMain.handle(AppRoute.joinPath, async (_event, paths: string[]) =>
join(...paths)
)
/** /**
* Relaunches the app in production - reload window in development. * Relaunches the app in production - reload window in development.
* @param _event - The IPC event object. * @param _event - The IPC event object.

View File

@ -3,7 +3,7 @@ import { DownloadManager } from './../managers/download'
import { resolve, join } from 'path' import { resolve, join } from 'path'
import { WindowManager } from './../managers/window' import { WindowManager } from './../managers/window'
import request from 'request' import request from 'request'
import { createWriteStream } from 'fs' import { createWriteStream, renameSync } from 'fs'
import { DownloadEvent, DownloadRoute } from '@janhq/core' import { DownloadEvent, DownloadRoute } from '@janhq/core'
const progress = require('request-progress') const progress = require('request-progress')
@ -48,6 +48,8 @@ export function handleDownloaderIPCs() {
const userDataPath = join(app.getPath('home'), 'jan') const userDataPath = join(app.getPath('home'), 'jan')
const destination = resolve(userDataPath, fileName) const destination = resolve(userDataPath, fileName)
const rq = request(url) const rq = request(url)
// downloading file to a temp file first
const downloadingTempFile = `${destination}.download`
progress(rq, {}) progress(rq, {})
.on('progress', function (state: any) { .on('progress', function (state: any) {
@ -70,6 +72,9 @@ export function handleDownloaderIPCs() {
}) })
.on('end', function () { .on('end', function () {
if (DownloadManager.instance.networkRequests[fileName]) { if (DownloadManager.instance.networkRequests[fileName]) {
// Finished downloading, rename temp file to actual file
renameSync(downloadingTempFile, destination)
WindowManager?.instance.currentWindow?.webContents.send( WindowManager?.instance.currentWindow?.webContents.send(
DownloadEvent.onFileDownloadSuccess, DownloadEvent.onFileDownloadSuccess,
{ {
@ -87,7 +92,7 @@ export function handleDownloaderIPCs() {
) )
} }
}) })
.pipe(createWriteStream(destination)) .pipe(createWriteStream(downloadingTempFile))
DownloadManager.instance.setRequest(fileName, rq) DownloadManager.instance.setRequest(fileName, rq)
}) })

View File

@ -1,7 +1,6 @@
import { ExtensionType, fs } from '@janhq/core' import { ExtensionType, fs, joinPath } from '@janhq/core'
import { ConversationalExtension } from '@janhq/core' import { ConversationalExtension } from '@janhq/core'
import { Thread, ThreadMessage } from '@janhq/core' import { Thread, ThreadMessage } from '@janhq/core'
import { join } from 'path'
/** /**
* JSONConversationalExtension is a ConversationalExtension implementation that provides * JSONConversationalExtension is a ConversationalExtension implementation that provides
@ -69,14 +68,14 @@ export default class JSONConversationalExtension
*/ */
async saveThread(thread: Thread): Promise<void> { async saveThread(thread: Thread): Promise<void> {
try { try {
const threadDirPath = join( const threadDirPath = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._homeDir,
thread.id thread.id,
) ])
const threadJsonPath = join( const threadJsonPath = await joinPath([
threadDirPath, threadDirPath,
JSONConversationalExtension._threadInfoFileName JSONConversationalExtension._threadInfoFileName,
) ])
await fs.mkdir(threadDirPath) await fs.mkdir(threadDirPath)
await fs.writeFile(threadJsonPath, JSON.stringify(thread, null, 2)) await fs.writeFile(threadJsonPath, JSON.stringify(thread, null, 2))
Promise.resolve() Promise.resolve()
@ -89,20 +88,22 @@ export default class JSONConversationalExtension
* Delete a thread with the specified ID. * Delete a thread with the specified ID.
* @param threadId The ID of the thread to delete. * @param threadId The ID of the thread to delete.
*/ */
deleteThread(threadId: string): Promise<void> { async deleteThread(threadId: string): Promise<void> {
return fs.rmdir(join(JSONConversationalExtension._homeDir, `${threadId}`)) return fs.rmdir(
await joinPath([JSONConversationalExtension._homeDir, `${threadId}`])
)
} }
async addNewMessage(message: ThreadMessage): Promise<void> { async addNewMessage(message: ThreadMessage): Promise<void> {
try { try {
const threadDirPath = join( const threadDirPath = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._homeDir,
message.thread_id message.thread_id,
) ])
const threadMessagePath = join( const threadMessagePath = await joinPath([
threadDirPath, threadDirPath,
JSONConversationalExtension._threadMessagesFileName JSONConversationalExtension._threadMessagesFileName,
) ])
await fs.mkdir(threadDirPath) await fs.mkdir(threadDirPath)
await fs.appendFile(threadMessagePath, JSON.stringify(message) + '\n') await fs.appendFile(threadMessagePath, JSON.stringify(message) + '\n')
Promise.resolve() Promise.resolve()
@ -116,11 +117,14 @@ export default class JSONConversationalExtension
messages: ThreadMessage[] messages: ThreadMessage[]
): Promise<void> { ): Promise<void> {
try { try {
const threadDirPath = join(JSONConversationalExtension._homeDir, threadId) const threadDirPath = await joinPath([
const threadMessagePath = join( JSONConversationalExtension._homeDir,
threadId,
])
const threadMessagePath = await joinPath([
threadDirPath, threadDirPath,
JSONConversationalExtension._threadMessagesFileName JSONConversationalExtension._threadMessagesFileName,
) ])
await fs.mkdir(threadDirPath) await fs.mkdir(threadDirPath)
await fs.writeFile( await fs.writeFile(
threadMessagePath, threadMessagePath,
@ -140,11 +144,11 @@ export default class JSONConversationalExtension
*/ */
private async readThread(threadDirName: string): Promise<any> { private async readThread(threadDirName: string): Promise<any> {
return fs.readFile( return fs.readFile(
join( await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._homeDir,
threadDirName, threadDirName,
JSONConversationalExtension._threadInfoFileName JSONConversationalExtension._threadInfoFileName,
) ])
) )
} }
@ -159,10 +163,10 @@ export default class JSONConversationalExtension
const threadDirs: string[] = [] const threadDirs: string[] = []
for (let i = 0; i < fileInsideThread.length; i++) { for (let i = 0; i < fileInsideThread.length; i++) {
const path = join( const path = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._homeDir,
fileInsideThread[i] fileInsideThread[i],
) ])
const isDirectory = await fs.isDirectory(path) const isDirectory = await fs.isDirectory(path)
if (!isDirectory) { if (!isDirectory) {
console.debug(`Ignore ${path} because it is not a directory`) console.debug(`Ignore ${path} because it is not a directory`)
@ -184,7 +188,10 @@ export default class JSONConversationalExtension
async getAllMessages(threadId: string): Promise<ThreadMessage[]> { async getAllMessages(threadId: string): Promise<ThreadMessage[]> {
try { try {
const threadDirPath = join(JSONConversationalExtension._homeDir, threadId) const threadDirPath = await joinPath([
JSONConversationalExtension._homeDir,
threadId,
])
const isDir = await fs.isDirectory(threadDirPath) const isDir = await fs.isDirectory(threadDirPath)
if (!isDir) { if (!isDir) {
throw Error(`${threadDirPath} is not directory`) throw Error(`${threadDirPath} is not directory`)
@ -197,10 +204,10 @@ export default class JSONConversationalExtension
throw Error(`${threadDirPath} not contains message file`) throw Error(`${threadDirPath} not contains message file`)
} }
const messageFilePath = join( const messageFilePath = await joinPath([
threadDirPath, threadDirPath,
JSONConversationalExtension._threadMessagesFileName JSONConversationalExtension._threadMessagesFileName,
) ])
const result = await fs.readLineByLine(messageFilePath) const result = await fs.readLineByLine(messageFilePath)

View File

@ -28,11 +28,12 @@ export CUDA_VISIBLE_DEVICES=$selectedGpuId
# Attempt to run nitro_linux_amd64_cuda # Attempt to run nitro_linux_amd64_cuda
cd linux-cuda cd linux-cuda
if ./nitro "$@"; then ./nitro "$@" > output.log 2>&1 || (
echo "Check output log" &&
if grep -q "CUDA error" output.log; then
echo "CUDA error detected, attempting to run nitro_linux_amd64..."
cd ../linux-cpu && ./nitro "$@"
exit $?
fi
exit $? exit $?
else )
echo "nitro_linux_amd64_cuda encountered an error, attempting to run nitro_linux_amd64..."
cd ../linux-cpu
./nitro "$@"
exit $?
fi

View File

@ -31,9 +31,10 @@ set CUDA_VISIBLE_DEVICES=!gpuId!
rem Attempt to run nitro_windows_amd64_cuda.exe rem Attempt to run nitro_windows_amd64_cuda.exe
cd win-cuda cd win-cuda
nitro.exe %*
if %errorlevel% neq 0 goto RunCpuVersion nitro.exe %* > output.log
goto End type output.log | findstr /C:"CUDA error" >nul
if %errorlevel% equ 0 ( goto :RunCpuVersion ) else ( goto :End )
:RunCpuVersion :RunCpuVersion
rem Run nitro_windows_amd64.exe... rem Run nitro_windows_amd64.exe...

View File

@ -111,7 +111,7 @@ export default class JanInferenceNitroExtension implements InferenceExtension {
return; return;
} }
const userSpacePath = await getUserSpace(); const userSpacePath = await getUserSpace();
const modelFullPath = join(userSpacePath, "models", model.id, model.id); const modelFullPath = join(userSpacePath, "models", model.id);
const nitroInitResult = await executeOnMain(MODULE, "initModel", { const nitroInitResult = await executeOnMain(MODULE, "initModel", {
modelFullPath: modelFullPath, modelFullPath: modelFullPath,

View File

@ -13,10 +13,11 @@ const NITRO_HTTP_LOAD_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/
const NITRO_HTTP_UNLOAD_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/unloadModel`; const NITRO_HTTP_UNLOAD_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/unloadModel`;
const NITRO_HTTP_VALIDATE_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/modelstatus`; const NITRO_HTTP_VALIDATE_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/modelstatus`;
const NITRO_HTTP_KILL_URL = `${NITRO_HTTP_SERVER_URL}/processmanager/destroy`; const NITRO_HTTP_KILL_URL = `${NITRO_HTTP_SERVER_URL}/processmanager/destroy`;
const SUPPORTED_MODEL_FORMAT = ".gguf";
// The subprocess instance for Nitro // The subprocess instance for Nitro
let subprocess = undefined; let subprocess = undefined;
let currentModelFile = undefined; let currentModelFile: string = undefined;
let currentSettings = undefined; let currentSettings = undefined;
/** /**
@ -37,6 +38,17 @@ function stopModel(): Promise<void> {
*/ */
async function initModel(wrapper: any): Promise<ModelOperationResponse> { async function initModel(wrapper: any): Promise<ModelOperationResponse> {
currentModelFile = wrapper.modelFullPath; currentModelFile = wrapper.modelFullPath;
const files: string[] = fs.readdirSync(currentModelFile);
// Look for GGUF model file
const ggufBinFile = files.find(
(file) =>
file === path.basename(currentModelFile) ||
file.toLowerCase().includes(SUPPORTED_MODEL_FORMAT)
);
currentModelFile = path.join(currentModelFile, ggufBinFile);
if (wrapper.model.engine !== "nitro") { if (wrapper.model.engine !== "nitro") {
return Promise.resolve({ error: "Not a nitro model" }); return Promise.resolve({ error: "Not a nitro model" });
} else { } else {
@ -66,15 +78,31 @@ async function initModel(wrapper: any): Promise<ModelOperationResponse> {
async function loadModel(nitroResourceProbe: any | undefined) { async function loadModel(nitroResourceProbe: any | undefined) {
// Gather system information for CPU physical cores and memory // Gather system information for CPU physical cores and memory
if (!nitroResourceProbe) nitroResourceProbe = await getResourcesInfo(); if (!nitroResourceProbe) nitroResourceProbe = await getResourcesInfo();
return killSubprocess() return (
.then(() => spawnNitroProcess(nitroResourceProbe)) killSubprocess()
.then(() => loadLLMModel(currentSettings)) .then(() => tcpPortUsed.waitUntilFree(PORT, 300, 5000))
.then(validateModelStatus) // wait for 500ms to make sure the port is free for windows platform
.catch((err) => { .then(() => {
console.error("error: ", err); if (process.platform === "win32") {
// TODO: Broadcast error so app could display proper error message return sleep(500);
return { error: err, currentModelFile }; } else {
}); return sleep(0);
}
})
.then(() => spawnNitroProcess(nitroResourceProbe))
.then(() => loadLLMModel(currentSettings))
.then(validateModelStatus)
.catch((err) => {
console.error("error: ", err);
// TODO: Broadcast error so app could display proper error message
return { error: err, currentModelFile };
})
);
}
// Add function sleep
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
} }
function promptTemplateConverter(promptTemplate) { function promptTemplateConverter(promptTemplate) {

View File

@ -5,9 +5,11 @@ import {
abortDownload, abortDownload,
getResourcePath, getResourcePath,
getUserSpace, getUserSpace,
InferenceEngine,
joinPath,
} from '@janhq/core' } from '@janhq/core'
import { ModelExtension, Model, ModelState } from '@janhq/core' import { basename } from 'path'
import { join } from 'path' import { ModelExtension, Model } from '@janhq/core'
/** /**
* A extension for models * A extension for models
@ -15,6 +17,9 @@ import { join } from 'path'
export default class JanModelExtension implements ModelExtension { export default class JanModelExtension implements ModelExtension {
private static readonly _homeDir = 'models' private static readonly _homeDir = 'models'
private static readonly _modelMetadataFileName = 'model.json' private static readonly _modelMetadataFileName = 'model.json'
private static readonly _supportedModelFormat = '.gguf'
private static readonly _incompletedModelFileName = '.download'
private static readonly _offlineInferenceEngine = InferenceEngine.nitro
/** /**
* Implements type from JanExtension. * Implements type from JanExtension.
@ -54,10 +59,10 @@ export default class JanModelExtension implements ModelExtension {
// copy models folder from resources to home directory // copy models folder from resources to home directory
const resourePath = await getResourcePath() const resourePath = await getResourcePath()
const srcPath = join(resourePath, 'models') const srcPath = await joinPath([resourePath, 'models'])
const userSpace = await getUserSpace() const userSpace = await getUserSpace()
const destPath = join(userSpace, JanModelExtension._homeDir) const destPath = await joinPath([userSpace, JanModelExtension._homeDir])
await fs.syncFile(srcPath, destPath) await fs.syncFile(srcPath, destPath)
@ -88,11 +93,18 @@ export default class JanModelExtension implements ModelExtension {
*/ */
async downloadModel(model: Model): Promise<void> { async downloadModel(model: Model): Promise<void> {
// create corresponding directory // create corresponding directory
const directoryPath = join(JanModelExtension._homeDir, model.id) const modelDirPath = await joinPath([JanModelExtension._homeDir, model.id])
await fs.mkdir(directoryPath) await fs.mkdir(modelDirPath)
// path to model binary // try to retrieve the download file name from the source url
const path = join(directoryPath, model.id) // if it fails, use the model ID as the file name
const extractedFileName = basename(model.source_url)
const fileName = extractedFileName
.toLowerCase()
.endsWith(JanModelExtension._supportedModelFormat)
? extractedFileName
: model.id
const path = await joinPath([modelDirPath, fileName])
downloadFile(model.source_url, path) downloadFile(model.source_url, path)
} }
@ -103,10 +115,12 @@ export default class JanModelExtension implements ModelExtension {
*/ */
async cancelModelDownload(modelId: string): Promise<void> { async cancelModelDownload(modelId: string): Promise<void> {
return abortDownload( return abortDownload(
join(JanModelExtension._homeDir, modelId, modelId) await joinPath([JanModelExtension._homeDir, modelId, modelId])
).then(() => { ).then(async () =>
fs.deleteFile(join(JanModelExtension._homeDir, modelId, modelId)) fs.deleteFile(
}) await joinPath([JanModelExtension._homeDir, modelId, modelId])
)
)
} }
/** /**
@ -116,27 +130,16 @@ export default class JanModelExtension implements ModelExtension {
*/ */
async deleteModel(modelId: string): Promise<void> { async deleteModel(modelId: string): Promise<void> {
try { try {
const dirPath = join(JanModelExtension._homeDir, modelId) const dirPath = await joinPath([JanModelExtension._homeDir, modelId])
// remove all files under dirPath except model.json // remove all files under dirPath except model.json
const files = await fs.listFiles(dirPath) const files = await fs.listFiles(dirPath)
const deletePromises = files.map((fileName: string) => { const deletePromises = files.map(async (fileName: string) => {
if (fileName !== JanModelExtension._modelMetadataFileName) { if (fileName !== JanModelExtension._modelMetadataFileName) {
return fs.deleteFile(join(dirPath, fileName)) return fs.deleteFile(await joinPath([dirPath, fileName]))
} }
}) })
await Promise.allSettled(deletePromises) await Promise.allSettled(deletePromises)
// update the state as default
const jsonFilePath = join(
dirPath,
JanModelExtension._modelMetadataFileName
)
const json = await fs.readFile(jsonFilePath)
const model = JSON.parse(json) as Model
delete model.state
await fs.writeFile(jsonFilePath, JSON.stringify(model, null, 2))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
@ -148,24 +151,14 @@ export default class JanModelExtension implements ModelExtension {
* @returns A Promise that resolves when the model is saved. * @returns A Promise that resolves when the model is saved.
*/ */
async saveModel(model: Model): Promise<void> { async saveModel(model: Model): Promise<void> {
const jsonFilePath = join( const jsonFilePath = await joinPath([
JanModelExtension._homeDir, JanModelExtension._homeDir,
model.id, model.id,
JanModelExtension._modelMetadataFileName JanModelExtension._modelMetadataFileName,
) ])
try { try {
await fs.writeFile( await fs.writeFile(jsonFilePath, JSON.stringify(model, null, 2))
jsonFilePath,
JSON.stringify(
{
...model,
state: ModelState.Ready,
},
null,
2
)
)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
@ -176,11 +169,34 @@ export default class JanModelExtension implements ModelExtension {
* @returns A Promise that resolves with an array of all models. * @returns A Promise that resolves with an array of all models.
*/ */
async getDownloadedModels(): Promise<Model[]> { async getDownloadedModels(): Promise<Model[]> {
const models = await this.getModelsMetadata() return await this.getModelsMetadata(
return models.filter((model) => model.state === ModelState.Ready) async (modelDir: string, model: Model) => {
if (model.engine !== JanModelExtension._offlineInferenceEngine) {
return true
}
return await fs
.listFiles(await joinPath([JanModelExtension._homeDir, modelDir]))
.then((files: string[]) => {
// or model binary exists in the directory
// model binary name can match model ID or be a .gguf file and not be an incompleted model file
return (
files.includes(modelDir) ||
files.some(
(file) =>
file
.toLowerCase()
.includes(JanModelExtension._supportedModelFormat) &&
!file.endsWith(JanModelExtension._incompletedModelFileName)
)
)
})
}
)
} }
private async getModelsMetadata(): Promise<Model[]> { private async getModelsMetadata(
selector?: (path: string, model: Model) => Promise<boolean>
): Promise<Model[]> {
try { try {
const filesUnderJanRoot = await fs.listFiles('') const filesUnderJanRoot = await fs.listFiles('')
if (!filesUnderJanRoot.includes(JanModelExtension._homeDir)) { if (!filesUnderJanRoot.includes(JanModelExtension._homeDir)) {
@ -193,26 +209,35 @@ export default class JanModelExtension implements ModelExtension {
const allDirectories: string[] = [] const allDirectories: string[] = []
for (const file of files) { for (const file of files) {
const isDirectory = await fs.isDirectory( const isDirectory = await fs.isDirectory(
join(JanModelExtension._homeDir, file) await joinPath([JanModelExtension._homeDir, file])
) )
if (isDirectory) { if (isDirectory) {
allDirectories.push(file) allDirectories.push(file)
} }
} }
const readJsonPromises = allDirectories.map((dirName) => { const readJsonPromises = allDirectories.map(async (dirName) => {
const jsonPath = join( // filter out directories that don't match the selector
// read model.json
const jsonPath = await joinPath([
JanModelExtension._homeDir, JanModelExtension._homeDir,
dirName, dirName,
JanModelExtension._modelMetadataFileName JanModelExtension._modelMetadataFileName,
) ])
return this.readModelMetadata(jsonPath) let model = await this.readModelMetadata(jsonPath)
model = typeof model === 'object' ? model : JSON.parse(model)
if (selector && !(await selector?.(dirName, model))) {
return
}
return model
}) })
const results = await Promise.allSettled(readJsonPromises) const results = await Promise.allSettled(readJsonPromises)
const modelData = results.map((result) => { const modelData = results.map((result) => {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
try { try {
return JSON.parse(result.value) as Model return result.value as Model
} catch { } catch {
console.debug(`Unable to parse model metadata: ${result.value}`) console.debug(`Unable to parse model metadata: ${result.value}`)
return undefined return undefined
@ -230,7 +255,7 @@ export default class JanModelExtension implements ModelExtension {
} }
private readModelMetadata(path: string) { private readModelMetadata(path: string) {
return fs.readFile(join(path)) return fs.readFile(path)
} }
/** /**

View File

@ -29,6 +29,13 @@ export default function CardSidebar({
useClickOutside(() => setMore(false), null, [menu, toggle]) useClickOutside(() => setMore(false), null, [menu, toggle])
let openFolderTitle: string = 'Open Containing Folder'
if (isMac) {
openFolderTitle = 'Reveal in Finder'
} else if (isWindows) {
openFolderTitle = 'Reveal in File Explorer'
}
return ( return (
<div <div
className={twMerge( className={twMerge(
@ -74,7 +81,7 @@ export default function CardSidebar({
> >
<FolderOpenIcon size={16} className="text-muted-foreground" /> <FolderOpenIcon size={16} className="text-muted-foreground" />
<span className="text-bold text-black dark:text-muted-foreground"> <span className="text-bold text-black dark:text-muted-foreground">
Reveal in Finder {openFolderTitle}
</span> </span>
</div> </div>
<div <div

View File

@ -1,48 +1,33 @@
import { FieldValues, UseFormRegister } from 'react-hook-form' import React from 'react'
import { ModelRuntimeParams } from '@janhq/core'
import { Switch } from '@janhq/uikit' import { Switch } from '@janhq/uikit'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import useUpdateModelParameters from '@/hooks/useUpdateModelParameters' import useUpdateModelParameters from '@/hooks/useUpdateModelParameters'
import { import { getActiveThreadIdAtom } from '@/helpers/atoms/Thread.atom'
getActiveThreadIdAtom,
getActiveThreadModelRuntimeParamsAtom,
} from '@/helpers/atoms/Thread.atom'
type Props = { type Props = {
name: string name: string
title: string title: string
checked: boolean checked: boolean
register: UseFormRegister<FieldValues>
} }
const Checkbox: React.FC<Props> = ({ name, title, checked, register }) => { const Checkbox: React.FC<Props> = ({ name, title, checked }) => {
const { updateModelParameter } = useUpdateModelParameters() const { updateModelParameter } = useUpdateModelParameters()
const threadId = useAtomValue(getActiveThreadIdAtom) const threadId = useAtomValue(getActiveThreadIdAtom)
const activeModelParams = useAtomValue(getActiveThreadModelRuntimeParamsAtom)
const onCheckedChange = (checked: boolean) => { const onCheckedChange = (checked: boolean) => {
if (!threadId || !activeModelParams) return if (!threadId) return
const updatedModelParams: ModelRuntimeParams = { updateModelParameter(threadId, name, checked)
...activeModelParams,
[name]: checked,
}
updateModelParameter(threadId, updatedModelParams)
} }
return ( return (
<div className="flex justify-between"> <div className="flex justify-between">
<label>{title}</label> <p className="mb-2 text-sm font-semibold text-gray-600">{title}</p>
<Switch <Switch checked={checked} onCheckedChange={onCheckedChange} />
checked={checked}
{...register(name)}
onCheckedChange={onCheckedChange}
/>
</div> </div>
) )
} }

View File

@ -9,10 +9,6 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipArrow,
Input, Input,
} from '@janhq/uikit' } from '@janhq/uikit'
@ -32,14 +28,22 @@ import useRecommendedModel from '@/hooks/useRecommendedModel'
import { toGigabytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
import { activeThreadAtom, threadStatesAtom } from '@/helpers/atoms/Thread.atom' import {
activeThreadAtom,
getActiveThreadIdAtom,
setThreadModelParamsAtom,
threadStatesAtom,
} from '@/helpers/atoms/Thread.atom'
export const selectedModelAtom = atom<Model | undefined>(undefined) export const selectedModelAtom = atom<Model | undefined>(undefined)
export default function DropdownListSidebar() { export default function DropdownListSidebar() {
const setSelectedModel = useSetAtom(selectedModelAtom) const activeThreadId = useAtomValue(getActiveThreadIdAtom)
const threadStates = useAtomValue(threadStatesAtom)
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const threadStates = useAtomValue(threadStatesAtom)
const setSelectedModel = useSetAtom(selectedModelAtom)
const setThreadModelParams = useSetAtom(setThreadModelParamsAtom)
const [selected, setSelected] = useState<Model | undefined>() const [selected, setSelected] = useState<Model | undefined>()
const { setMainViewState } = useMainViewState() const { setMainViewState } = useMainViewState()
const [openAISettings, setOpenAISettings] = useState< const [openAISettings, setOpenAISettings] = useState<
@ -58,83 +62,93 @@ export default function DropdownListSidebar() {
useEffect(() => { useEffect(() => {
setSelected(recommendedModel) setSelected(recommendedModel)
setSelectedModel(recommendedModel) setSelectedModel(recommendedModel)
}, [recommendedModel, setSelectedModel])
if (activeThread) {
const finishInit = threadStates[activeThread.id].isFinishInit ?? true
if (finishInit) return
const modelParams = {
...recommendedModel?.parameters,
...recommendedModel?.settings,
}
setThreadModelParams(activeThread.id, modelParams)
}
}, [
recommendedModel,
activeThread,
setSelectedModel,
setThreadModelParams,
threadStates,
])
const onValueSelected = useCallback( const onValueSelected = useCallback(
(modelId: string) => { (modelId: string) => {
const model = downloadedModels.find((m) => m.id === modelId) const model = downloadedModels.find((m) => m.id === modelId)
setSelected(model) setSelected(model)
setSelectedModel(model) setSelectedModel(model)
if (activeThreadId) {
const modelParams = {
...model?.parameters,
...model?.settings,
}
setThreadModelParams(activeThreadId, modelParams)
}
}, },
[downloadedModels, setSelectedModel] [downloadedModels, activeThreadId, setSelectedModel, setThreadModelParams]
) )
if (!activeThread) { if (!activeThread) {
return null return null
} }
const finishInit = threadStates[activeThread.id].isFinishInit ?? true
return ( return (
<Tooltip> <>
<TooltipTrigger className="w-full"> <Select value={selected?.id} onValueChange={onValueSelected}>
<Select <SelectTrigger className="w-full">
disabled={finishInit} <SelectValue placeholder="Choose model to start">
value={selected?.id} {downloadedModels.filter((x) => x.id === selected?.id)[0]?.name}
onValueChange={finishInit ? undefined : onValueSelected} </SelectValue>
> </SelectTrigger>
<SelectTrigger className="w-full"> <SelectContent className="right-5 block w-full min-w-[300px] pr-0">
<SelectValue placeholder="Choose model to start"> <div className="flex w-full items-center space-x-2 px-4 py-2">
{downloadedModels.filter((x) => x.id === selected?.id)[0]?.name} <MonitorIcon size={20} className="text-muted-foreground" />
</SelectValue> <span>Local</span>
</SelectTrigger> </div>
<SelectContent className="right-5 block w-full min-w-[300px] pr-0"> <div className="border-b border-border" />
<div className="flex w-full items-center space-x-2 px-4 py-2"> {downloadedModels.length === 0 ? (
<MonitorIcon size={20} className="text-muted-foreground" /> <div className="px-4 py-2">
<span>Local</span> <p>{`Oops, you don't have a model yet.`}</p>
</div> </div>
<div className="border-b border-border" /> ) : (
{downloadedModels.length === 0 ? ( <SelectGroup>
<div className="px-4 py-2"> {downloadedModels.map((x, i) => (
<p>{`Oops, you don't have a model yet.`}</p> <SelectItem
</div> key={i}
) : ( value={x.id}
<SelectGroup> className={twMerge(x.id === selected?.id && 'bg-secondary')}
{downloadedModels.map((x, i) => ( >
<SelectItem <div className="flex w-full justify-between">
key={i} <span className="line-clamp-1 block">{x.name}</span>
value={x.id} <span className="font-bold text-muted-foreground">
className={twMerge(x.id === selected?.id && 'bg-secondary')} {toGigabytes(x.metadata.size)}
> </span>
<div className="flex w-full justify-between"> </div>
<span className="line-clamp-1 block">{x.name}</span> </SelectItem>
<span className="font-bold text-muted-foreground"> ))}
{toGigabytes(x.metadata.size)} </SelectGroup>
</span> )}
</div> <div className="border-b border-border" />
</SelectItem> <div className="w-full px-4 py-2">
))} <Button
</SelectGroup> block
)} className="bg-blue-100 font-bold text-blue-600 hover:bg-blue-100 hover:text-blue-600"
<div className="border-b border-border" /> onClick={() => setMainViewState(MainViewState.Hub)}
<div className="w-full px-4 py-2"> >
<Button Explore The Hub
block </Button>
className="bg-blue-100 font-bold text-blue-600 hover:bg-blue-100 hover:text-blue-600" </div>
onClick={() => setMainViewState(MainViewState.Hub)} </SelectContent>
> </Select>
Explore The Hub
</Button>
</div>
</SelectContent>
</Select>
</TooltipTrigger>
{finishInit && (
<TooltipContent sideOffset={10}>
<span>Start a new thread to change the model</span>
<TooltipArrow />
</TooltipContent>
)}
{selected?.engine === InferenceEngine.openai && ( {selected?.engine === InferenceEngine.openai && (
<div className="mt-4"> <div className="mt-4">
@ -154,6 +168,6 @@ export default function DropdownListSidebar() {
/> />
</div> </div>
)} )}
</Tooltip> </>
) )
} }

View File

@ -1,7 +1,5 @@
import { Fragment } from 'react' import { Fragment } from 'react'
import { ExtensionType } from '@janhq/core'
import { ModelExtension } from '@janhq/core'
import { import {
Progress, Progress,
Modal, Modal,
@ -12,14 +10,19 @@ import {
ModalTrigger, ModalTrigger,
} from '@janhq/uikit' } from '@janhq/uikit'
import { useAtomValue } from 'jotai'
import useDownloadModel from '@/hooks/useDownloadModel'
import { useDownloadState } from '@/hooks/useDownloadState' import { useDownloadState } from '@/hooks/useDownloadState'
import { formatDownloadPercentage } from '@/utils/converter' import { formatDownloadPercentage } from '@/utils/converter'
import { extensionManager } from '@/extension' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
export default function DownloadingState() { export default function DownloadingState() {
const { downloadStates } = useDownloadState() const { downloadStates } = useDownloadState()
const downloadingModels = useAtomValue(downloadingModelsAtom)
const { abortModelDownload } = useDownloadModel()
const totalCurrentProgress = downloadStates const totalCurrentProgress = downloadStates
.map((a) => a.size.transferred + a.size.transferred) .map((a) => a.size.transferred + a.size.transferred)
@ -73,9 +76,10 @@ export default function DownloadingState() {
size="sm" size="sm"
onClick={() => { onClick={() => {
if (item?.modelId) { if (item?.modelId) {
extensionManager const model = downloadingModels.find(
.get<ModelExtension>(ExtensionType.Model) (model) => model.id === item.modelId
?.cancelModelDownload(item.modelId) )
if (model) abortModelDownload(model)
} }
}} }}
> >

View File

@ -1,6 +1,5 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { ModelExtension, ExtensionType } from '@janhq/core'
import { Model } from '@janhq/core' import { Model } from '@janhq/core'
import { import {
@ -17,11 +16,12 @@ import {
import { atom, useAtomValue } from 'jotai' import { atom, useAtomValue } from 'jotai'
import useDownloadModel from '@/hooks/useDownloadModel'
import { useDownloadState } from '@/hooks/useDownloadState' import { useDownloadState } from '@/hooks/useDownloadState'
import { formatDownloadPercentage } from '@/utils/converter' import { formatDownloadPercentage } from '@/utils/converter'
import { extensionManager } from '@/extension' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
type Props = { type Props = {
model: Model model: Model
@ -30,6 +30,7 @@ type Props = {
export default function ModalCancelDownload({ model, isFromList }: Props) { export default function ModalCancelDownload({ model, isFromList }: Props) {
const { modelDownloadStateAtom } = useDownloadState() const { modelDownloadStateAtom } = useDownloadState()
const downloadingModels = useAtomValue(downloadingModelsAtom)
const downloadAtom = useMemo( const downloadAtom = useMemo(
() => atom((get) => get(modelDownloadStateAtom)[model.id]), () => atom((get) => get(modelDownloadStateAtom)[model.id]),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -37,6 +38,7 @@ export default function ModalCancelDownload({ model, isFromList }: Props) {
) )
const downloadState = useAtomValue(downloadAtom) const downloadState = useAtomValue(downloadAtom)
const cancelText = `Cancel ${formatDownloadPercentage(downloadState.percent)}` const cancelText = `Cancel ${formatDownloadPercentage(downloadState.percent)}`
const { abortModelDownload } = useDownloadModel()
return ( return (
<Modal> <Modal>
@ -80,9 +82,10 @@ export default function ModalCancelDownload({ model, isFromList }: Props) {
themes="danger" themes="danger"
onClick={() => { onClick={() => {
if (downloadState?.modelId) { if (downloadState?.modelId) {
extensionManager const model = downloadingModels.find(
.get<ModelExtension>(ExtensionType.Model) (model) => model.id === downloadState.modelId
?.cancelModelDownload(downloadState.modelId) )
if (model) abortModelDownload(model)
} }
}} }}
> >

View File

@ -0,0 +1,43 @@
import { Textarea } from '@janhq/uikit'
import { useAtomValue } from 'jotai'
import useUpdateModelParameters from '@/hooks/useUpdateModelParameters'
import { getActiveThreadIdAtom } from '@/helpers/atoms/Thread.atom'
type Props = {
title: string
name: string
placeholder: string
value: string
}
const ModelConfigInput: React.FC<Props> = ({
title,
name,
value,
placeholder,
}) => {
const { updateModelParameter } = useUpdateModelParameters()
const threadId = useAtomValue(getActiveThreadIdAtom)
const onValueChanged = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!threadId) return
updateModelParameter(threadId, name, e.target.value)
}
return (
<div className="flex flex-col">
<p className="mb-2 text-sm font-semibold text-gray-600">{title}</p>
<Textarea
placeholder={placeholder}
onChange={onValueChanged}
value={value}
/>
</div>
)
}
export default ModelConfigInput

View File

@ -1,34 +1,35 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { PropsWithChildren, useEffect, useRef } from 'react' import { basename } from 'path'
import { ExtensionType } from '@janhq/core' import { PropsWithChildren, useEffect, useRef } from 'react'
import { ModelExtension } from '@janhq/core'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { useDownloadState } from '@/hooks/useDownloadState' import { useDownloadState } from '@/hooks/useDownloadState'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import { modelBinFileName } from '@/utils/model'
import EventHandler from './EventHandler' import EventHandler from './EventHandler'
import { appDownloadProgress } from './Jotai' import { appDownloadProgress } from './Jotai'
import { extensionManager } from '@/extension/ExtensionManager'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
export default function EventListenerWrapper({ children }: PropsWithChildren) { export default function EventListenerWrapper({ children }: PropsWithChildren) {
const setProgress = useSetAtom(appDownloadProgress) const setProgress = useSetAtom(appDownloadProgress)
const models = useAtomValue(downloadingModelsAtom) const models = useAtomValue(downloadingModelsAtom)
const modelsRef = useRef(models) const modelsRef = useRef(models)
useEffect(() => {
modelsRef.current = models
}, [models])
const { setDownloadedModels, downloadedModels } = useGetDownloadedModels() const { setDownloadedModels, downloadedModels } = useGetDownloadedModels()
const { setDownloadState, setDownloadStateSuccess, setDownloadStateFailed } = const { setDownloadState, setDownloadStateSuccess, setDownloadStateFailed } =
useDownloadState() useDownloadState()
const downloadedModelRef = useRef(downloadedModels) const downloadedModelRef = useRef(downloadedModels)
useEffect(() => {
modelsRef.current = models
}, [models])
useEffect(() => { useEffect(() => {
downloadedModelRef.current = downloadedModels downloadedModelRef.current = downloadedModels
}, [downloadedModels]) }, [downloadedModels])
@ -38,40 +39,36 @@ export default function EventListenerWrapper({ children }: PropsWithChildren) {
window.electronAPI.onFileDownloadUpdate( window.electronAPI.onFileDownloadUpdate(
(_event: string, state: any | undefined) => { (_event: string, state: any | undefined) => {
if (!state) return if (!state) return
setDownloadState({ const model = modelsRef.current.find(
...state, (model) => modelBinFileName(model) === basename(state.fileName)
modelId: state.fileName.split('/').pop() ?? '', )
}) if (model)
setDownloadState({
...state,
modelId: model.id,
})
} }
) )
window.electronAPI.onFileDownloadError( window.electronAPI.onFileDownloadError((_event: string, state: any) => {
(_event: string, callback: any) => { console.error('Download error', state)
console.error('Download error', callback) const model = modelsRef.current.find(
const modelId = callback.fileName.split('/').pop() ?? '' (model) => modelBinFileName(model) === basename(state.fileName)
setDownloadStateFailed(modelId) )
} if (model) setDownloadStateFailed(model.id)
) })
window.electronAPI.onFileDownloadSuccess( window.electronAPI.onFileDownloadSuccess((_event: string, state: any) => {
(_event: string, callback: any) => { if (state && state.fileName) {
if (callback && callback.fileName) { const model = modelsRef.current.find(
const modelId = callback.fileName.split('/').pop() ?? '' (model) => modelBinFileName(model) === basename(state.fileName)
)
const model = modelsRef.current.find((e) => e.id === modelId) if (model) {
setDownloadStateSuccess(model.id)
setDownloadStateSuccess(modelId) setDownloadedModels([...downloadedModelRef.current, model])
if (model)
extensionManager
.get<ModelExtension>(ExtensionType.Model)
?.saveModel(model)
.then(() => {
setDownloadedModels([...downloadedModelRef.current, model])
})
} }
} }
) })
window.electronAPI.onAppUpdateDownloadUpdate( window.electronAPI.onAppUpdateDownloadUpdate(
(_event: string, progress: any) => { (_event: string, progress: any) => {

View File

@ -73,7 +73,7 @@ const Providers = (props: PropsWithChildren) => {
{setupCore && activated && ( {setupCore && activated && (
<FeatureToggleWrapper> <FeatureToggleWrapper>
<EventListenerWrapper> <EventListenerWrapper>
<TooltipProvider>{children}</TooltipProvider> <TooltipProvider delayDuration={0}>{children}</TooltipProvider>
</EventListenerWrapper> </EventListenerWrapper>
<Toaster position="top-right" /> <Toaster position="top-right" />
</FeatureToggleWrapper> </FeatureToggleWrapper>

View File

@ -1,15 +1,11 @@
import { FieldValues, UseFormRegister } from 'react-hook-form' import React from 'react'
import { ModelRuntimeParams } from '@janhq/core'
import { Slider, Input } from '@janhq/uikit' import { Slider, Input } from '@janhq/uikit'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import useUpdateModelParameters from '@/hooks/useUpdateModelParameters' import useUpdateModelParameters from '@/hooks/useUpdateModelParameters'
import { import { getActiveThreadIdAtom } from '@/helpers/atoms/Thread.atom'
getActiveThreadIdAtom,
getActiveThreadModelRuntimeParamsAtom,
} from '@/helpers/atoms/Thread.atom'
type Props = { type Props = {
name: string name: string
@ -18,7 +14,6 @@ type Props = {
max: number max: number
step: number step: number
value: number value: number
register: UseFormRegister<FieldValues>
} }
const SliderRightPanel: React.FC<Props> = ({ const SliderRightPanel: React.FC<Props> = ({
@ -28,21 +23,14 @@ const SliderRightPanel: React.FC<Props> = ({
max, max,
step, step,
value, value,
register,
}) => { }) => {
const { updateModelParameter } = useUpdateModelParameters() const { updateModelParameter } = useUpdateModelParameters()
const threadId = useAtomValue(getActiveThreadIdAtom) const threadId = useAtomValue(getActiveThreadIdAtom)
const activeModelParams = useAtomValue(getActiveThreadModelRuntimeParamsAtom)
const onValueChanged = (e: number[]) => { const onValueChanged = (e: number[]) => {
if (!threadId || !activeModelParams) return if (!threadId) return
const updatedModelParams: ModelRuntimeParams = { updateModelParameter(threadId, name, e[0])
...activeModelParams,
[name]: Number(e[0]),
}
updateModelParameter(threadId, updatedModelParams)
} }
return ( return (
@ -51,9 +39,6 @@ const SliderRightPanel: React.FC<Props> = ({
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<div className="relative w-full"> <div className="relative w-full">
<Slider <Slider
{...register(name, {
setValueAs: (v: string) => parseInt(v),
})}
value={[value]} value={[value]}
onValueChange={onValueChanged} onValueChange={onValueChanged}
min={min} min={min}

View File

@ -1,5 +1,6 @@
import { import {
ModelRuntimeParams, ModelRuntimeParams,
ModelSettingParams,
Thread, Thread,
ThreadContent, ThreadContent,
ThreadState, ThreadState,
@ -110,30 +111,26 @@ export const activeThreadAtom = atom<Thread | undefined>((get) =>
/** /**
* Store model params at thread level settings * Store model params at thread level settings
*/ */
export const threadModelRuntimeParamsAtom = atom< export const threadModelParamsAtom = atom<Record<string, ModelParams>>({})
Record<string, ModelRuntimeParams>
>({})
export const getActiveThreadModelRuntimeParamsAtom = atom< export type ModelParams = ModelRuntimeParams | ModelSettingParams
ModelRuntimeParams | undefined
>((get) => { export const getActiveThreadModelParamsAtom = atom<ModelParams | undefined>(
const threadId = get(activeThreadIdAtom) (get) => {
if (!threadId) { const threadId = get(activeThreadIdAtom)
console.debug('Active thread id is undefined') if (!threadId) {
return undefined console.debug('Active thread id is undefined')
return undefined
}
return get(threadModelParamsAtom)[threadId]
} }
return get(threadModelRuntimeParamsAtom)[threadId]
})
export const getThreadModelRuntimeParamsAtom = atom(
(get, threadId: string) => get(threadModelRuntimeParamsAtom)[threadId]
) )
export const setThreadModelRuntimeParamsAtom = atom( export const setThreadModelParamsAtom = atom(
null, null,
(get, set, threadId: string, params: ModelRuntimeParams) => { (get, set, threadId: string, params: ModelParams) => {
const currentState = { ...get(threadModelRuntimeParamsAtom) } const currentState = { ...get(threadModelParamsAtom) }
currentState[threadId] = params currentState[threadId] = params
console.debug( console.debug(
`Update model params for thread ${threadId}, ${JSON.stringify( `Update model params for thread ${threadId}, ${JSON.stringify(
@ -142,6 +139,6 @@ export const setThreadModelRuntimeParamsAtom = atom(
2 2
)}` )}`
) )
set(threadModelRuntimeParamsAtom, currentState) set(threadModelParamsAtom, currentState)
} }
) )

View File

@ -19,7 +19,6 @@ import {
setActiveThreadIdAtom, setActiveThreadIdAtom,
threadStatesAtom, threadStatesAtom,
updateThreadAtom, updateThreadAtom,
setThreadModelRuntimeParamsAtom,
} from '@/helpers/atoms/Thread.atom' } from '@/helpers/atoms/Thread.atom'
const createNewThreadAtom = atom(null, (get, set, newThread: Thread) => { const createNewThreadAtom = atom(null, (get, set, newThread: Thread) => {
@ -45,10 +44,6 @@ export const useCreateNewThread = () => {
const createNewThread = useSetAtom(createNewThreadAtom) const createNewThread = useSetAtom(createNewThreadAtom)
const setActiveThreadId = useSetAtom(setActiveThreadIdAtom) const setActiveThreadId = useSetAtom(setActiveThreadIdAtom)
const updateThread = useSetAtom(updateThreadAtom) const updateThread = useSetAtom(updateThreadAtom)
const setThreadModelRuntimeParams = useSetAtom(
setThreadModelRuntimeParamsAtom
)
const { deleteThread } = useDeleteThread() const { deleteThread } = useDeleteThread()
const requestCreateNewThread = async ( const requestCreateNewThread = async (
@ -77,10 +72,7 @@ export const useCreateNewThread = () => {
model: { model: {
id: modelId, id: modelId,
settings: {}, settings: {},
parameters: { parameters: {},
stream: true,
max_tokens: 1024,
},
engine: undefined, engine: undefined,
}, },
instructions: assistant.instructions, instructions: assistant.instructions,
@ -94,7 +86,6 @@ export const useCreateNewThread = () => {
created: createdAt, created: createdAt,
updated: createdAt, updated: createdAt,
} }
setThreadModelRuntimeParams(thread.id, assistantInfo.model.parameters)
// add the new thread on top of the thread list to the state // add the new thread on top of the thread list to the state
createNewThread(thread) createNewThread(thread)

View File

@ -22,6 +22,7 @@ import {
setActiveThreadIdAtom, setActiveThreadIdAtom,
deleteThreadStateAtom, deleteThreadStateAtom,
threadStatesAtom, threadStatesAtom,
updateThreadStateLastMessageAtom,
} from '@/helpers/atoms/Thread.atom' } from '@/helpers/atoms/Thread.atom'
export default function useDeleteThread() { export default function useDeleteThread() {
@ -33,21 +34,23 @@ export default function useDeleteThread() {
const deleteMessages = useSetAtom(deleteChatMessagesAtom) const deleteMessages = useSetAtom(deleteChatMessagesAtom)
const cleanMessages = useSetAtom(cleanChatMessagesAtom) const cleanMessages = useSetAtom(cleanChatMessagesAtom)
const deleteThreadState = useSetAtom(deleteThreadStateAtom) const deleteThreadState = useSetAtom(deleteThreadStateAtom)
const threadStates = useAtomValue(threadStatesAtom) const threadStates = useAtomValue(threadStatesAtom)
const updateThreadLastMessage = useSetAtom(updateThreadStateLastMessageAtom)
const cleanThread = async (threadId: string) => { const cleanThread = async (threadId: string) => {
if (threadId) { if (threadId) {
const thread = threads.filter((c) => c.id === threadId)[0] const thread = threads.filter((c) => c.id === threadId)[0]
cleanMessages(threadId) cleanMessages(threadId)
if (thread) if (thread) {
await extensionManager await extensionManager
.get<ConversationalExtension>(ExtensionType.Conversational) .get<ConversationalExtension>(ExtensionType.Conversational)
?.writeMessages( ?.writeMessages(
threadId, threadId,
messages.filter((msg) => msg.role === ChatCompletionRole.System) messages.filter((msg) => msg.role === ChatCompletionRole.System)
) )
updateThreadLastMessage(threadId, undefined)
}
} }
} }

View File

@ -1,7 +1,15 @@
import { Model, ExtensionType, ModelExtension } from '@janhq/core' import {
Model,
ExtensionType,
ModelExtension,
abortDownload,
joinPath,
} from '@janhq/core'
import { useSetAtom } from 'jotai' import { useSetAtom } from 'jotai'
import { modelBinFileName } from '@/utils/model'
import { useDownloadState } from './useDownloadState' import { useDownloadState } from './useDownloadState'
import { extensionManager } from '@/extension/ExtensionManager' import { extensionManager } from '@/extension/ExtensionManager'
@ -33,8 +41,14 @@ export default function useDownloadModel() {
.get<ModelExtension>(ExtensionType.Model) .get<ModelExtension>(ExtensionType.Model)
?.downloadModel(model) ?.downloadModel(model)
} }
const abortModelDownload = async (model: Model) => {
await abortDownload(
await joinPath(['models', model.id, modelBinFileName(model)])
)
}
return { return {
downloadModel, downloadModel,
abortModelDownload,
} }
} }

View File

@ -1,10 +1,10 @@
import { join } from 'path' import { fs, joinPath } from '@janhq/core'
import { fs } from '@janhq/core'
export const useEngineSettings = () => { export const useEngineSettings = () => {
const readOpenAISettings = async () => { const readOpenAISettings = async () => {
const settings = await fs.readFile(join('engines', 'openai.json')) const settings = await fs.readFile(
await joinPath(['engines', 'openai.json'])
)
if (settings) { if (settings) {
return JSON.parse(settings) return JSON.parse(settings)
} }
@ -17,7 +17,10 @@ export const useEngineSettings = () => {
}) => { }) => {
const settings = await readOpenAISettings() const settings = await readOpenAISettings()
settings.api_key = apiKey settings.api_key = apiKey
await fs.writeFile(join('engines', 'openai.json'), JSON.stringify(settings)) await fs.writeFile(
await joinPath(['engines', 'openai.json']),
JSON.stringify(settings)
)
} }
return { readOpenAISettings, saveOpenAISettings } return { readOpenAISettings, saveOpenAISettings }
} }

View File

@ -42,6 +42,7 @@ export default function useRecommendedModel() {
const getRecommendedModel = useCallback(async (): Promise< const getRecommendedModel = useCallback(async (): Promise<
Model | undefined Model | undefined
> => { > => {
const models = await getAndSortDownloadedModels()
if (!activeThread) { if (!activeThread) {
return return
} }
@ -49,7 +50,6 @@ export default function useRecommendedModel() {
const finishInit = threadStates[activeThread.id].isFinishInit ?? true const finishInit = threadStates[activeThread.id].isFinishInit ?? true
if (finishInit) { if (finishInit) {
const modelId = activeThread.assistants[0]?.model.id const modelId = activeThread.assistants[0]?.model.id
const models = await getAndSortDownloadedModels()
const model = models.find((model) => model.id === modelId) const model = models.find((model) => model.id === modelId)
if (model) { if (model) {
@ -60,7 +60,6 @@ export default function useRecommendedModel() {
} else { } else {
const modelId = activeThread.assistants[0]?.model.id const modelId = activeThread.assistants[0]?.model.id
if (modelId !== '*') { if (modelId !== '*') {
const models = await getAndSortDownloadedModels()
const model = models.find((model) => model.id === modelId) const model = models.find((model) => model.id === modelId)
if (model) { if (model) {
@ -78,7 +77,7 @@ export default function useRecommendedModel() {
} }
// sort the model, for display purpose // sort the model, for display purpose
const models = await getAndSortDownloadedModels()
if (models.length === 0) { if (models.length === 0) {
// if we have no downloaded models, then can't recommend anything // if we have no downloaded models, then can't recommend anything
console.debug("No downloaded models, can't recommend anything") console.debug("No downloaded models, can't recommend anything")

View File

@ -24,6 +24,8 @@ import { currentPromptAtom } from '@/containers/Providers/Jotai'
import { toaster } from '@/containers/Toast' import { toaster } from '@/containers/Toast'
import { toRuntimeParams, toSettingParams } from '@/utils/model_param'
import { useActiveModel } from './useActiveModel' import { useActiveModel } from './useActiveModel'
import { extensionManager } from '@/extension/ExtensionManager' import { extensionManager } from '@/extension/ExtensionManager'
@ -33,7 +35,7 @@ import {
} from '@/helpers/atoms/ChatMessage.atom' } from '@/helpers/atoms/ChatMessage.atom'
import { import {
activeThreadAtom, activeThreadAtom,
getActiveThreadModelRuntimeParamsAtom, getActiveThreadModelParamsAtom,
threadStatesAtom, threadStatesAtom,
updateThreadAtom, updateThreadAtom,
updateThreadInitSuccessAtom, updateThreadInitSuccessAtom,
@ -56,7 +58,7 @@ export default function useSendChatMessage() {
const modelRef = useRef<Model | undefined>() const modelRef = useRef<Model | undefined>()
const threadStates = useAtomValue(threadStatesAtom) const threadStates = useAtomValue(threadStatesAtom)
const updateThreadInitSuccess = useSetAtom(updateThreadInitSuccessAtom) const updateThreadInitSuccess = useSetAtom(updateThreadInitSuccessAtom)
const activeModelParams = useAtomValue(getActiveThreadModelRuntimeParamsAtom) const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
useEffect(() => { useEffect(() => {
modelRef.current = activeModel modelRef.current = activeModel
@ -128,17 +130,22 @@ export default function useSendChatMessage() {
} }
const sendChatMessage = async () => { const sendChatMessage = async () => {
if (!currentPrompt || currentPrompt.trim().length === 0) { if (!currentPrompt || currentPrompt.trim().length === 0) return
return
}
if (!activeThread) { if (!activeThread) {
console.error('No active thread') console.error('No active thread')
return return
} }
const activeThreadState = threadStates[activeThread.id] const activeThreadState = threadStates[activeThread.id]
const runtimeParams = toRuntimeParams(activeModelParams)
const settingParams = toSettingParams(activeModelParams)
// if the thread is not initialized, we need to initialize it first // if the thread is not initialized, we need to initialize it first
if (!activeThreadState.isFinishInit) { if (
!activeThreadState.isFinishInit ||
activeThread.assistants[0].model.id !== selectedModel?.id
) {
if (!selectedModel) { if (!selectedModel) {
toaster({ title: 'Please select a model' }) toaster({ title: 'Please select a model' })
return return
@ -147,11 +154,6 @@ export default function useSendChatMessage() {
const assistantName = activeThread.assistants[0].assistant_name ?? '' const assistantName = activeThread.assistants[0].assistant_name ?? ''
const instructions = activeThread.assistants[0].instructions ?? '' const instructions = activeThread.assistants[0].instructions ?? ''
const modelParams: ModelRuntimeParams = {
...selectedModel.parameters,
...activeModelParams,
}
const updatedThread: Thread = { const updatedThread: Thread = {
...activeThread, ...activeThread,
assistants: [ assistants: [
@ -161,8 +163,8 @@ export default function useSendChatMessage() {
instructions: instructions, instructions: instructions,
model: { model: {
id: selectedModel.id, id: selectedModel.id,
settings: selectedModel.settings, settings: settingParams,
parameters: modelParams, parameters: runtimeParams,
engine: selectedModel.engine, engine: selectedModel.engine,
}, },
}, },
@ -208,13 +210,17 @@ export default function useSendChatMessage() {
const msgId = ulid() const msgId = ulid()
const modelRequest = selectedModel ?? activeThread.assistants[0].model const modelRequest = selectedModel ?? activeThread.assistants[0].model
if (runtimeParams.stream == null) {
runtimeParams.stream = true
}
const messageRequest: MessageRequest = { const messageRequest: MessageRequest = {
id: msgId, id: msgId,
threadId: activeThread.id, threadId: activeThread.id,
messages, messages,
model: { model: {
...modelRequest, ...modelRequest,
...(activeModelParams ? { parameters: activeModelParams } : {}), settings: settingParams,
parameters: runtimeParams,
}, },
} }
const timestamp = Date.now() const timestamp = Date.now()

View File

@ -1,6 +1,5 @@
import { import {
ExtensionType, ExtensionType,
ModelRuntimeParams,
Thread, Thread,
ThreadState, ThreadState,
ConversationalExtension, ConversationalExtension,
@ -12,7 +11,8 @@ import useSetActiveThread from './useSetActiveThread'
import { extensionManager } from '@/extension/ExtensionManager' import { extensionManager } from '@/extension/ExtensionManager'
import { import {
threadModelRuntimeParamsAtom, ModelParams,
threadModelParamsAtom,
threadStatesAtom, threadStatesAtom,
threadsAtom, threadsAtom,
} from '@/helpers/atoms/Thread.atom' } from '@/helpers/atoms/Thread.atom'
@ -21,7 +21,7 @@ const useThreads = () => {
const [threadStates, setThreadStates] = useAtom(threadStatesAtom) const [threadStates, setThreadStates] = useAtom(threadStatesAtom)
const [threads, setThreads] = useAtom(threadsAtom) const [threads, setThreads] = useAtom(threadsAtom)
const [threadModelRuntimeParams, setThreadModelRuntimeParams] = useAtom( const [threadModelRuntimeParams, setThreadModelRuntimeParams] = useAtom(
threadModelRuntimeParamsAtom threadModelParamsAtom
) )
const { setActiveThread } = useSetActiveThread() const { setActiveThread } = useSetActiveThread()
@ -29,7 +29,7 @@ const useThreads = () => {
try { try {
const localThreads = await getLocalThreads() const localThreads = await getLocalThreads()
const localThreadStates: Record<string, ThreadState> = {} const localThreadStates: Record<string, ThreadState> = {}
const threadModelParams: Record<string, ModelRuntimeParams> = {} const threadModelParams: Record<string, ModelParams> = {}
localThreads.forEach((thread) => { localThreads.forEach((thread) => {
if (thread.id != null) { if (thread.id != null) {
@ -42,9 +42,12 @@ const useThreads = () => {
isFinishInit: true, isFinishInit: true,
} }
// model params
const modelParams = thread.assistants?.[0]?.model?.parameters const modelParams = thread.assistants?.[0]?.model?.parameters
threadModelParams[thread.id] = modelParams const engineParams = thread.assistants?.[0]?.model?.settings
threadModelParams[thread.id] = {
...modelParams,
...engineParams,
}
} }
}) })

View File

@ -1,31 +1,34 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { import {
ConversationalExtension, ConversationalExtension,
ExtensionType, ExtensionType,
ModelRuntimeParams,
Thread, Thread,
ThreadAssistantInfo,
} from '@janhq/core' } from '@janhq/core'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { toRuntimeParams, toSettingParams } from '@/utils/model_param'
import { extensionManager } from '@/extension' import { extensionManager } from '@/extension'
import { import {
ModelParams,
activeThreadStateAtom, activeThreadStateAtom,
setThreadModelRuntimeParamsAtom, getActiveThreadModelParamsAtom,
setThreadModelParamsAtom,
threadsAtom, threadsAtom,
updateThreadAtom,
} from '@/helpers/atoms/Thread.atom' } from '@/helpers/atoms/Thread.atom'
export default function useUpdateModelParameters() { export default function useUpdateModelParameters() {
const threads = useAtomValue(threadsAtom) const threads = useAtomValue(threadsAtom)
const updateThread = useSetAtom(updateThreadAtom) const setThreadModelParams = useSetAtom(setThreadModelParamsAtom)
const setThreadModelRuntimeParams = useSetAtom(
setThreadModelRuntimeParamsAtom
)
const activeThreadState = useAtomValue(activeThreadStateAtom) const activeThreadState = useAtomValue(activeThreadStateAtom)
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
const updateModelParameter = async ( const updateModelParameter = async (
threadId: string, threadId: string,
params: ModelRuntimeParams name: string,
value: number | boolean | string
) => { ) => {
const thread = threads.find((thread) => thread.id === threadId) const thread = threads.find((thread) => thread.id === threadId)
if (!thread) { if (!thread) {
@ -37,27 +40,37 @@ export default function useUpdateModelParameters() {
console.error('No active thread') console.error('No active thread')
return return
} }
const updatedModelParams: ModelParams = {
...activeModelParams,
[name]: value,
}
// update the state // update the state
setThreadModelRuntimeParams(thread.id, params) setThreadModelParams(thread.id, updatedModelParams)
if (!activeThreadState.isFinishInit) { if (!activeThreadState.isFinishInit) {
// if thread is not initialized, we don't need to update thread.json // if thread is not initialized, we don't need to update thread.json
return return
} }
const assistants = thread.assistants.map((assistant) => { const assistants = thread.assistants.map(
assistant.model.parameters = params (assistant: ThreadAssistantInfo) => {
return assistant const runtimeParams = toRuntimeParams(updatedModelParams)
}) const settingParams = toSettingParams(updatedModelParams)
assistant.model.parameters = runtimeParams
assistant.model.settings = settingParams
return assistant
}
)
// update thread // update thread
const updatedThread: Thread = { const updatedThread: Thread = {
...thread, ...thread,
assistants, assistants,
} }
updateThread(updatedThread)
extensionManager await extensionManager
.get<ConversationalExtension>(ExtensionType.Conversational) .get<ConversationalExtension>(ExtensionType.Conversational)
?.saveThread(updatedThread) ?.saveThread(updatedThread)
} }

View File

@ -32,6 +32,9 @@ const nextConfig = {
JSON.stringify(process.env.ANALYTICS_ID) ?? JSON.stringify('xxx'), JSON.stringify(process.env.ANALYTICS_ID) ?? JSON.stringify('xxx'),
ANALYTICS_HOST: ANALYTICS_HOST:
JSON.stringify(process.env.ANALYTICS_HOST) ?? JSON.stringify('xxx'), JSON.stringify(process.env.ANALYTICS_HOST) ?? JSON.stringify('xxx'),
isMac: process.platform === 'darwin',
isWindows: process.platform === 'win32',
isLinux: process.platform === 'linux',
}), }),
] ]
return config return config

View File

@ -116,7 +116,7 @@ const ChatBody: React.FC = () => {
) : ( ) : (
<ScrollToBottom className="flex h-full w-full flex-col"> <ScrollToBottom className="flex h-full w-full flex-col">
{messages.map((message, index) => ( {messages.map((message, index) => (
<> <div key={message.id}>
<ChatItem {...message} key={message.id} /> <ChatItem {...message} key={message.id} />
{message.status === MessageStatus.Error && {message.status === MessageStatus.Error &&
@ -126,8 +126,8 @@ const ChatBody: React.FC = () => {
className="mt-10 flex flex-col items-center" className="mt-10 flex flex-col items-center"
> >
<span className="mb-3 text-center text-sm font-medium text-gray-500"> <span className="mb-3 text-center text-sm font-medium text-gray-500">
Oops! The generation was interrupted. Let&apos;s Oops! The generation was interrupted. Let&apos;s give it
give it another go! another go!
</span> </span>
<Button <Button
className="w-min" className="w-min"
@ -140,7 +140,7 @@ const ChatBody: React.FC = () => {
</Button> </Button>
</div> </div>
)} )}
</> </div>
))} ))}
</ScrollToBottom> </ScrollToBottom>
)} )}

View File

@ -0,0 +1,31 @@
import { useAtomValue } from 'jotai'
import { selectedModelAtom } from '@/containers/DropdownListSidebar'
import { getConfigurationsData } from '@/utils/componentSettings'
import { toSettingParams } from '@/utils/model_param'
import settingComponentBuilder from '../ModelSetting/settingComponentBuilder'
import { getActiveThreadModelParamsAtom } from '@/helpers/atoms/Thread.atom'
const EngineSetting: React.FC = () => {
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
const selectedModel = useAtomValue(selectedModelAtom)
if (!selectedModel || !activeModelParams) return null
const modelSettingParams = toSettingParams(activeModelParams)
const componentData = getConfigurationsData(modelSettingParams)
componentData.sort((a, b) => a.title.localeCompare(b.title))
return (
<form className="flex flex-col">
{settingComponentBuilder(componentData)}
</form>
)
}
export default EngineSetting

View File

@ -1,47 +1,33 @@
import { useForm } from 'react-hook-form' import React from 'react'
import { ModelRuntimeParams } from '@janhq/core'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { presetConfiguration } from './predefinedComponent' import { selectedModelAtom } from '@/containers/DropdownListSidebar'
import settingComponentBuilder, {
SettingComponentData,
} from './settingComponentBuilder'
import { getActiveThreadModelRuntimeParamsAtom } from '@/helpers/atoms/Thread.atom' import { getConfigurationsData } from '@/utils/componentSettings'
import { toRuntimeParams } from '@/utils/model_param'
export default function ModelSetting() { import settingComponentBuilder from './settingComponentBuilder'
const { register } = useForm()
const activeModelParams = useAtomValue(getActiveThreadModelRuntimeParamsAtom)
if (!activeModelParams) { import { getActiveThreadModelParamsAtom } from '@/helpers/atoms/Thread.atom'
return null
}
const componentData: SettingComponentData[] = [] const ModelSetting: React.FC = () => {
Object.keys(activeModelParams).forEach((key) => { const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
const componentSetting = presetConfiguration[key] const selectedModel = useAtomValue(selectedModelAtom)
if (componentSetting) { if (!selectedModel || !activeModelParams) return null
if ('value' in componentSetting.controllerData) {
componentSetting.controllerData.value = Number(
activeModelParams[key as keyof ModelRuntimeParams]
)
} else if ('checked' in componentSetting.controllerData) {
const checked = activeModelParams[
key as keyof ModelRuntimeParams
] as boolean
componentSetting.controllerData.checked = checked const modelRuntimeParams = toRuntimeParams(activeModelParams)
}
componentData.push(componentSetting) const componentData = getConfigurationsData(modelRuntimeParams)
}
}) componentData.sort((a, b) => a.title.localeCompare(b.title))
return ( return (
<form className="flex flex-col"> <form className="flex flex-col">
{settingComponentBuilder(componentData, register)} {settingComponentBuilder(componentData)}
</form> </form>
) )
} }
export default React.memo(ModelSetting)

View File

@ -1,10 +1,43 @@
import { SettingComponentData } from './settingComponentBuilder' import { SettingComponentData } from './settingComponentBuilder'
export const presetConfiguration: Record<string, SettingComponentData> = { export const presetConfiguration: Record<string, SettingComponentData> = {
prompt_template: {
name: 'prompt_template',
title: 'Prompt template',
description: 'Prompt template',
controllerType: 'input',
controllerData: {
placeholder: 'Prompt template',
value: '',
},
},
stop: {
name: 'stop',
title: 'Stop',
description: 'Stop',
controllerType: 'input',
controllerData: {
placeholder: 'Stop',
value: '',
},
},
ctx_len: {
name: 'ctx_len',
title: 'Context Length',
description: 'Context Length',
controllerType: 'slider',
controllerData: {
min: 0,
max: 4096,
step: 128,
value: 1024,
},
},
max_tokens: { max_tokens: {
name: 'max_tokens', name: 'max_tokens',
title: 'Max Tokens', title: 'Max Tokens',
description: 'Maximum context length the model can handle.', description:
'The maximum number of tokens the model will generate in a single response.',
controllerType: 'slider', controllerType: 'slider',
controllerData: { controllerData: {
min: 0, min: 0,
@ -56,4 +89,52 @@ export const presetConfiguration: Record<string, SettingComponentData> = {
value: 0.7, value: 0.7,
}, },
}, },
frequency_penalty: {
name: 'frequency_penalty',
title: 'Frequency Penalty',
description: 'Frequency Penalty',
controllerType: 'slider',
controllerData: {
min: 0,
max: 1,
step: 0.1,
value: 0.7,
},
},
presence_penalty: {
name: 'presence_penalty',
title: 'Presence Penalty',
description: 'Presence Penalty',
controllerType: 'slider',
controllerData: {
min: 0,
max: 1,
step: 0.1,
value: 0.7,
},
},
top_p: {
name: 'top_p',
title: 'Top P',
description: 'Top P',
controllerType: 'slider',
controllerData: {
min: 0,
max: 1,
step: 0.1,
value: 0.95,
},
},
n_parallel: {
name: 'n_parallel',
title: 'N Parallel',
description: 'N Parallel',
controllerType: 'slider',
controllerData: {
min: 1,
max: 4,
step: 1,
value: 1,
},
},
} }

View File

@ -1,17 +1,21 @@
/* eslint-disable no-case-declarations */ /* eslint-disable no-case-declarations */
import { FieldValues, UseFormRegister } from 'react-hook-form'
import Checkbox from '@/containers/Checkbox' import Checkbox from '@/containers/Checkbox'
import ModelConfigInput from '@/containers/ModelConfigInput'
import Slider from '@/containers/Slider' import Slider from '@/containers/Slider'
export type ControllerType = 'slider' | 'checkbox' export type ControllerType = 'slider' | 'checkbox' | 'input'
export type SettingComponentData = { export type SettingComponentData = {
name: string name: string
title: string title: string
description: string description: string
controllerType: ControllerType controllerType: ControllerType
controllerData: SliderData | CheckboxData controllerData: SliderData | CheckboxData | InputData
}
export type InputData = {
placeholder: string
value: string
} }
export type SliderData = { export type SliderData = {
@ -25,10 +29,7 @@ type CheckboxData = {
checked: boolean checked: boolean
} }
const settingComponentBuilder = ( const settingComponentBuilder = (componentData: SettingComponentData[]) => {
componentData: SettingComponentData[],
register: UseFormRegister<FieldValues>
) => {
const components = componentData.map((data) => { const components = componentData.map((data) => {
switch (data.controllerType) { switch (data.controllerType) {
case 'slider': case 'slider':
@ -42,7 +43,18 @@ const settingComponentBuilder = (
step={step} step={step}
value={value} value={value}
name={data.name} name={data.name}
register={register} />
)
case 'input':
const { placeholder, value: textValue } =
data.controllerData as InputData
return (
<ModelConfigInput
title={data.title}
key={data.name}
name={data.name}
placeholder={placeholder}
value={textValue}
/> />
) )
case 'checkbox': case 'checkbox':
@ -50,7 +62,6 @@ const settingComponentBuilder = (
return ( return (
<Checkbox <Checkbox
key={data.name} key={data.name}
register={register}
name={data.name} name={data.name}
title={data.title} title={data.title}
checked={checked} checked={checked}

View File

@ -1,6 +1,6 @@
import { join } from 'path' import React from 'react'
import { getUserSpace, openFileExplorer } from '@janhq/core' import { getUserSpace, openFileExplorer, joinPath } from '@janhq/core'
import { Input, Textarea } from '@janhq/uikit' import { Input, Textarea } from '@janhq/uikit'
@ -16,19 +16,29 @@ import DropdownListSidebar, {
import { useCreateNewThread } from '@/hooks/useCreateNewThread' import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import { toSettingParams } from '@/utils/model_param'
import EngineSetting from '../EngineSetting'
import ModelSetting from '../ModelSetting' import ModelSetting from '../ModelSetting'
import { activeThreadAtom, threadStatesAtom } from '@/helpers/atoms/Thread.atom' import {
activeThreadAtom,
getActiveThreadModelParamsAtom,
threadStatesAtom,
} from '@/helpers/atoms/Thread.atom'
export const showRightSideBarAtom = atom<boolean>(true) export const showRightSideBarAtom = atom<boolean>(true)
export default function Sidebar() { const Sidebar: React.FC = () => {
const showing = useAtomValue(showRightSideBarAtom) const showing = useAtomValue(showRightSideBarAtom)
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const selectedModel = useAtomValue(selectedModelAtom) const selectedModel = useAtomValue(selectedModelAtom)
const { updateThreadMetadata } = useCreateNewThread() const { updateThreadMetadata } = useCreateNewThread()
const threadStates = useAtomValue(threadStatesAtom) const threadStates = useAtomValue(threadStatesAtom)
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
const modelSettingParams = toSettingParams(activeModelParams)
const onReviewInFinderClick = async (type: string) => { const onReviewInFinderClick = async (type: string) => {
if (!activeThread) return if (!activeThread) return
const activeThreadState = threadStates[activeThread.id] const activeThreadState = threadStates[activeThread.id]
@ -42,23 +52,22 @@ export default function Sidebar() {
const assistantId = activeThread.assistants[0]?.assistant_id const assistantId = activeThread.assistants[0]?.assistant_id
switch (type) { switch (type) {
case 'Thread': case 'Thread':
filePath = join('threads', activeThread.id) filePath = await joinPath(['threads', activeThread.id])
break break
case 'Model': case 'Model':
if (!selectedModel) return if (!selectedModel) return
filePath = join('models', selectedModel.id) filePath = await joinPath(['models', selectedModel.id])
break break
case 'Assistant': case 'Assistant':
if (!assistantId) return if (!assistantId) return
filePath = join('assistants', assistantId) filePath = await joinPath(['assistants', assistantId])
break break
default: default:
break break
} }
if (!filePath) return if (!filePath) return
const fullPath = await joinPath([userSpace, filePath])
const fullPath = join(userSpace, filePath)
openFileExplorer(fullPath) openFileExplorer(fullPath)
} }
@ -75,23 +84,22 @@ export default function Sidebar() {
const assistantId = activeThread.assistants[0]?.assistant_id const assistantId = activeThread.assistants[0]?.assistant_id
switch (type) { switch (type) {
case 'Thread': case 'Thread':
filePath = join('threads', activeThread.id, 'thread.json') filePath = await joinPath(['threads', activeThread.id, 'thread.json'])
break break
case 'Model': case 'Model':
if (!selectedModel) return if (!selectedModel) return
filePath = join('models', selectedModel.id, 'model.json') filePath = await joinPath(['models', selectedModel.id, 'model.json'])
break break
case 'Assistant': case 'Assistant':
if (!assistantId) return if (!assistantId) return
filePath = join('assistants', assistantId, 'assistant.json') filePath = await joinPath(['assistants', assistantId, 'assistant.json'])
break break
default: default:
break break
} }
if (!filePath) return if (!filePath) return
const fullPath = await joinPath([userSpace, filePath])
const fullPath = join(userSpace, filePath)
openFileExplorer(fullPath) openFileExplorer(fullPath)
} }
@ -187,6 +195,17 @@ export default function Sidebar() {
</div> </div>
</div> </div>
</CardSidebar> </CardSidebar>
{Object.keys(modelSettingParams).length ? (
<CardSidebar
title="Engine"
onRevealInFinderClick={onReviewInFinderClick}
onViewJsonClick={onViewJsonClick}
>
<div className="p-2">
<EngineSetting />
</div>
</CardSidebar>
) : null}
<CardSidebar <CardSidebar
title="Model" title="Model"
onRevealInFinderClick={onReviewInFinderClick} onRevealInFinderClick={onReviewInFinderClick}
@ -203,3 +222,5 @@ export default function Sidebar() {
</div> </div>
) )
} }
export default React.memo(Sidebar)

View File

@ -161,7 +161,10 @@ export default function ThreadList() {
<ModalHeader> <ModalHeader>
<ModalTitle>Delete Thread</ModalTitle> <ModalTitle>Delete Thread</ModalTitle>
</ModalHeader> </ModalHeader>
<p>Are you sure you want to delete this thread?</p> <p>
Are you sure you want to delete this thread? This action
cannot be undone.
</p>
<ModalFooter> <ModalFooter>
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<ModalClose asChild> <ModalClose asChild>
@ -169,6 +172,7 @@ export default function ThreadList() {
</ModalClose> </ModalClose>
<ModalClose asChild> <ModalClose asChild>
<Button <Button
autoFocus
themes="danger" themes="danger"
onClick={() => deleteThread(thread.id)} onClick={() => deleteThread(thread.id)}
> >

View File

@ -58,17 +58,28 @@ const ExploreModelsScreen = () => {
className="w-full object-cover" className="w-full object-cover"
/> />
<div className="absolute left-1/2 top-1/2 w-1/3 -translate-x-1/2 -translate-y-1/2"> <div className="absolute left-1/2 top-1/2 w-1/3 -translate-x-1/2 -translate-y-1/2">
<SearchIcon <div className="relative">
size={20} <SearchIcon
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" size={20}
/> className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
<Input />
placeholder="Search models" <Input
className="bg-white pl-9 dark:bg-background" placeholder="Search models"
onChange={(e) => { className="bg-white pl-9 dark:bg-background"
setsearchValue(e.target.value) onChange={(e) => {
}} setsearchValue(e.target.value)
/> }}
/>
</div>
<div className="mt-2 text-center">
<a
href="https://jan.ai/guides/using-models/import-manually/"
target="_blank"
className="font-semibold text-white underline"
>
How to manually import models
</a>
</div>
</div> </div>
</div> </div>
<div className="mx-auto w-4/5 py-6"> <div className="mx-auto w-4/5 py-6">

View File

@ -5,8 +5,6 @@ import React, { useState, useEffect, useRef, useContext } from 'react'
import { Button } from '@janhq/uikit' import { Button } from '@janhq/uikit'
import Loader from '@/containers/Loader'
import { FeatureToggleContext } from '@/context/FeatureToggle' import { FeatureToggleContext } from '@/context/FeatureToggle'
import { useGetAppVersion } from '@/hooks/useGetAppVersion' import { useGetAppVersion } from '@/hooks/useGetAppVersion'
@ -18,7 +16,6 @@ import { extensionManager } from '@/extension'
const ExtensionCatalog = () => { const ExtensionCatalog = () => {
const [activeExtensions, setActiveExtensions] = useState<any[]>([]) const [activeExtensions, setActiveExtensions] = useState<any[]>([])
const [extensionCatalog, setExtensionCatalog] = useState<any[]>([]) const [extensionCatalog, setExtensionCatalog] = useState<any[]>([])
const [isLoading, setIsLoading] = useState<boolean>(false)
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const { version } = useGetAppVersion() const { version } = useGetAppVersion()
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext) const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
@ -95,8 +92,6 @@ const ExtensionCatalog = () => {
} }
} }
if (isLoading) return <Loader description="Installing ..." />
return ( return (
<div className="block w-full"> <div className="block w-full">
{extensionCatalog {extensionCatalog

View File

@ -8,6 +8,9 @@ declare global {
declare const VERSION: string declare const VERSION: string
declare const ANALYTICS_ID: string declare const ANALYTICS_ID: string
declare const ANALYTICS_HOST: string declare const ANALYTICS_HOST: string
declare const isMac: boolean
declare const isWindows: boolean
declare const isLinux: boolean
interface Core { interface Core {
api: APIFunctions api: APIFunctions
events: EventEmitter events: EventEmitter

View File

@ -0,0 +1,39 @@
import { ModelRuntimeParams, ModelSettingParams } from '@janhq/core'
import { presetConfiguration } from '@/screens/Chat/ModelSetting/predefinedComponent'
import { SettingComponentData } from '@/screens/Chat/ModelSetting/settingComponentBuilder'
import { ModelParams } from '@/helpers/atoms/Thread.atom'
export const getConfigurationsData = (
settings: ModelSettingParams | ModelRuntimeParams
) => {
const componentData: SettingComponentData[] = []
Object.keys(settings).forEach((key: string) => {
const componentSetting = presetConfiguration[key]
if (!componentSetting) {
return
}
if ('slider' === componentSetting.controllerType) {
const value = Number(settings[key as keyof ModelParams])
if ('value' in componentSetting.controllerData)
componentSetting.controllerData.value = value
} else if ('input' === componentSetting.controllerType) {
const value = settings[key as keyof ModelParams] as string
const placeholder = settings[key as keyof ModelParams] as string
if ('value' in componentSetting.controllerData)
componentSetting.controllerData.value = value
if ('placeholder' in componentSetting.controllerData)
componentSetting.controllerData.placeholder = placeholder
} else if ('checkbox' === componentSetting.controllerType) {
const checked = settings[key as keyof ModelParams] as boolean
if ('checked' in componentSetting.controllerData)
componentSetting.controllerData.checked = checked
}
componentData.push(componentSetting)
})
return componentData
}

12
web/utils/model.ts Normal file
View File

@ -0,0 +1,12 @@
import { basename } from 'path'
import { Model } from '@janhq/core'
export const modelBinFileName = (model: Model) => {
const modelFormatExt = '.gguf'
const extractedFileName = basename(model.source_url) ?? model.id
const fileName = extractedFileName.toLowerCase().endsWith(modelFormatExt)
? extractedFileName
: model.id
return fileName
}

53
web/utils/model_param.ts Normal file
View File

@ -0,0 +1,53 @@
import { ModelRuntimeParams, ModelSettingParams } from '@janhq/core'
import { ModelParams } from '@/helpers/atoms/Thread.atom'
export const toRuntimeParams = (
modelParams?: ModelParams
): ModelRuntimeParams => {
if (!modelParams) return {}
const defaultModelParams: ModelRuntimeParams = {
temperature: undefined,
token_limit: undefined,
top_k: undefined,
top_p: undefined,
stream: undefined,
max_tokens: undefined,
stop: undefined,
frequency_penalty: undefined,
presence_penalty: undefined,
}
const runtimeParams: ModelRuntimeParams = {}
for (const [key, value] of Object.entries(modelParams)) {
if (key in defaultModelParams) {
runtimeParams[key as keyof ModelRuntimeParams] = value
}
}
return runtimeParams
}
export const toSettingParams = (
modelParams?: ModelParams
): ModelSettingParams => {
if (!modelParams) return {}
const defaultSettingParams: ModelSettingParams = {
ctx_len: undefined,
ngl: undefined,
embedding: undefined,
n_parallel: undefined,
cpu_threads: undefined,
prompt_template: undefined,
}
const settingParams: ModelSettingParams = {}
for (const [key, value] of Object.entries(modelParams)) {
if (key in defaultSettingParams) {
settingParams[key as keyof ModelSettingParams] = value
}
}
return settingParams
}