Merge branch 'bugreport' of https://github.com/janhq/jan into bugreport
This commit is contained in:
commit
e614702d18
17
.github/ISSUE_TEMPLATE/documentation-request.md
vendored
17
.github/ISSUE_TEMPLATE/documentation-request.md
vendored
@ -1,17 +0,0 @@
|
||||
---
|
||||
name: "📖 Documentation request"
|
||||
about: Documentation requests
|
||||
title: 'docs: TITLE'
|
||||
labels: 'type: documentation'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Pages**
|
||||
- Page(s) that need to be done
|
||||
|
||||
**Success Criteria**
|
||||
Content that should be covered
|
||||
|
||||
**Additional context**
|
||||
Examples, reference pages, resources
|
||||
25
.github/ISSUE_TEMPLATE/epic-request.md
vendored
25
.github/ISSUE_TEMPLATE/epic-request.md
vendored
@ -1,25 +0,0 @@
|
||||
---
|
||||
name: "💥 Epic request"
|
||||
about: Suggest an idea for this project
|
||||
title: 'epic: [DESCRIPTION]'
|
||||
labels: 'type: epic'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Motivation
|
||||
-
|
||||
|
||||
## Specs
|
||||
-
|
||||
|
||||
## Designs
|
||||
[Figma](link)
|
||||
|
||||
## Tasklist
|
||||
- [ ]
|
||||
|
||||
## Not in Scope
|
||||
-
|
||||
|
||||
## Appendix
|
||||
@ -37,6 +37,33 @@ on:
|
||||
- '!README.md'
|
||||
|
||||
jobs:
|
||||
|
||||
base_branch_cov:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
- name: Use Node.js v20.9.0
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: v20.9.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn
|
||||
yarn build:core
|
||||
|
||||
- name: Run test coverage
|
||||
run: yarn test:coverage
|
||||
|
||||
- name: Upload code coverage for ref branch
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ref-lcov.info
|
||||
path: ./coverage/lcov.info
|
||||
|
||||
test-on-macos:
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, macOS, macos-desktop]
|
||||
@ -292,6 +319,56 @@ jobs:
|
||||
TURBO_TEAM: 'linux'
|
||||
TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}'
|
||||
|
||||
coverage-check:
|
||||
runs-on: [self-hosted, Linux, ubuntu-desktop]
|
||||
needs: base_branch_cov
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Getting the repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Installing node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: 'Cleanup cache'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
rm -rf ~/jan
|
||||
make clean
|
||||
|
||||
- name: Download code coverage report from base branch
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ref-lcov.info
|
||||
|
||||
- name: Linter and test coverage
|
||||
run: |
|
||||
export DISPLAY=$(w -h | awk 'NR==1 {print $2}')
|
||||
echo -e "Display ID: $DISPLAY"
|
||||
npm config set registry ${{ secrets.NPM_PROXY }} --global
|
||||
yarn config set registry ${{ secrets.NPM_PROXY }} --global
|
||||
make lint
|
||||
yarn build:test
|
||||
yarn test:coverage
|
||||
env:
|
||||
TURBO_API: '${{ secrets.TURBO_API }}'
|
||||
TURBO_TEAM: 'linux'
|
||||
TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}'
|
||||
|
||||
- name: Generate Code Coverage report
|
||||
id: code-coverage
|
||||
uses: barecheck/code-coverage-action@v1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
lcov-file: "./coverage/lcov.info"
|
||||
base-lcov-file: "./lcov.info"
|
||||
send-summary-comment: true
|
||||
show-annotations: "warning"
|
||||
|
||||
test-on-ubuntu-pr-target:
|
||||
runs-on: [self-hosted, Linux, ubuntu-desktop]
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -20,7 +20,7 @@ electron/themes
|
||||
electron/playwright-report
|
||||
server/pre-install
|
||||
package-lock.json
|
||||
|
||||
coverage
|
||||
*.log
|
||||
core/lib/**
|
||||
|
||||
@ -41,3 +41,5 @@ extensions/*-extension/bin/vulkaninfo
|
||||
.turbo
|
||||
electron/test-data
|
||||
electron/test-results
|
||||
core/test_results.html
|
||||
coverage
|
||||
|
||||
2
Makefile
2
Makefile
@ -104,7 +104,7 @@ endif
|
||||
# Testing
|
||||
test: lint
|
||||
yarn build:test
|
||||
yarn test:unit
|
||||
yarn test:coverage
|
||||
yarn test
|
||||
|
||||
# Builds and publishes the app
|
||||
|
||||
@ -4,4 +4,5 @@ module.exports = {
|
||||
moduleNameMapper: {
|
||||
'@/(.*)': '<rootDir>/src/$1',
|
||||
},
|
||||
runner: './testRunner.js',
|
||||
}
|
||||
|
||||
@ -46,6 +46,8 @@
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-jest": "^27.9.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"jest-runner": "^29.7.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"rollup": "^2.38.5",
|
||||
"rollup-plugin-commonjs": "^9.1.8",
|
||||
@ -53,7 +55,7 @@
|
||||
"rollup-plugin-node-resolve": "^5.2.0",
|
||||
"rollup-plugin-sourcemaps": "^0.6.3",
|
||||
"rollup-plugin-typescript2": "^0.36.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
|
||||
98
core/src/browser/core.test.ts
Normal file
98
core/src/browser/core.test.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { openExternalUrl } from './core';
|
||||
import { joinPath } from './core';
|
||||
import { openFileExplorer } from './core';
|
||||
import { getJanDataFolderPath } from './core';
|
||||
import { abortDownload } from './core';
|
||||
import { getFileSize } from './core';
|
||||
import { executeOnMain } from './core';
|
||||
|
||||
it('should open external url', async () => {
|
||||
const url = 'http://example.com';
|
||||
globalThis.core = {
|
||||
api: {
|
||||
openExternalUrl: jest.fn().mockResolvedValue('opened')
|
||||
}
|
||||
};
|
||||
const result = await openExternalUrl(url);
|
||||
expect(globalThis.core.api.openExternalUrl).toHaveBeenCalledWith(url);
|
||||
expect(result).toBe('opened');
|
||||
});
|
||||
|
||||
|
||||
it('should join paths', async () => {
|
||||
const paths = ['/path/one', '/path/two'];
|
||||
globalThis.core = {
|
||||
api: {
|
||||
joinPath: jest.fn().mockResolvedValue('/path/one/path/two')
|
||||
}
|
||||
};
|
||||
const result = await joinPath(paths);
|
||||
expect(globalThis.core.api.joinPath).toHaveBeenCalledWith(paths);
|
||||
expect(result).toBe('/path/one/path/two');
|
||||
});
|
||||
|
||||
|
||||
it('should open file explorer', async () => {
|
||||
const path = '/path/to/open';
|
||||
globalThis.core = {
|
||||
api: {
|
||||
openFileExplorer: jest.fn().mockResolvedValue('opened')
|
||||
}
|
||||
};
|
||||
const result = await openFileExplorer(path);
|
||||
expect(globalThis.core.api.openFileExplorer).toHaveBeenCalledWith(path);
|
||||
expect(result).toBe('opened');
|
||||
});
|
||||
|
||||
|
||||
it('should get jan data folder path', async () => {
|
||||
globalThis.core = {
|
||||
api: {
|
||||
getJanDataFolderPath: jest.fn().mockResolvedValue('/path/to/jan/data')
|
||||
}
|
||||
};
|
||||
const result = await getJanDataFolderPath();
|
||||
expect(globalThis.core.api.getJanDataFolderPath).toHaveBeenCalled();
|
||||
expect(result).toBe('/path/to/jan/data');
|
||||
});
|
||||
|
||||
|
||||
it('should abort download', async () => {
|
||||
const fileName = 'testFile';
|
||||
globalThis.core = {
|
||||
api: {
|
||||
abortDownload: jest.fn().mockResolvedValue('aborted')
|
||||
}
|
||||
};
|
||||
const result = await abortDownload(fileName);
|
||||
expect(globalThis.core.api.abortDownload).toHaveBeenCalledWith(fileName);
|
||||
expect(result).toBe('aborted');
|
||||
});
|
||||
|
||||
|
||||
it('should get file size', async () => {
|
||||
const url = 'http://example.com/file';
|
||||
globalThis.core = {
|
||||
api: {
|
||||
getFileSize: jest.fn().mockResolvedValue(1024)
|
||||
}
|
||||
};
|
||||
const result = await getFileSize(url);
|
||||
expect(globalThis.core.api.getFileSize).toHaveBeenCalledWith(url);
|
||||
expect(result).toBe(1024);
|
||||
});
|
||||
|
||||
|
||||
it('should execute function on main process', async () => {
|
||||
const extension = 'testExtension';
|
||||
const method = 'testMethod';
|
||||
const args = ['arg1', 'arg2'];
|
||||
globalThis.core = {
|
||||
api: {
|
||||
invokeExtensionFunc: jest.fn().mockResolvedValue('result')
|
||||
}
|
||||
};
|
||||
const result = await executeOnMain(extension, method, ...args);
|
||||
expect(globalThis.core.api.invokeExtensionFunc).toHaveBeenCalledWith(extension, method, ...args);
|
||||
expect(result).toBe('result');
|
||||
});
|
||||
37
core/src/browser/events.test.ts
Normal file
37
core/src/browser/events.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { events } from './events';
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
it('should emit an event', () => {
|
||||
const mockObject = { key: 'value' };
|
||||
globalThis.core = {
|
||||
events: {
|
||||
emit: jest.fn()
|
||||
}
|
||||
};
|
||||
events.emit('testEvent', mockObject);
|
||||
expect(globalThis.core.events.emit).toHaveBeenCalledWith('testEvent', mockObject);
|
||||
});
|
||||
|
||||
|
||||
it('should remove an observer for an event', () => {
|
||||
const mockHandler = jest.fn();
|
||||
globalThis.core = {
|
||||
events: {
|
||||
off: jest.fn()
|
||||
}
|
||||
};
|
||||
events.off('testEvent', mockHandler);
|
||||
expect(globalThis.core.events.off).toHaveBeenCalledWith('testEvent', mockHandler);
|
||||
});
|
||||
|
||||
|
||||
it('should add an observer for an event', () => {
|
||||
const mockHandler = jest.fn();
|
||||
globalThis.core = {
|
||||
events: {
|
||||
on: jest.fn()
|
||||
}
|
||||
};
|
||||
events.on('testEvent', mockHandler);
|
||||
expect(globalThis.core.events.on).toHaveBeenCalledWith('testEvent', mockHandler);
|
||||
});
|
||||
46
core/src/browser/extension.test.ts
Normal file
46
core/src/browser/extension.test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { BaseExtension } from './extension'
|
||||
|
||||
class TestBaseExtension extends BaseExtension {
|
||||
onLoad(): void {}
|
||||
onUnload(): void {}
|
||||
}
|
||||
|
||||
describe('BaseExtension', () => {
|
||||
let baseExtension: TestBaseExtension
|
||||
|
||||
beforeEach(() => {
|
||||
baseExtension = new TestBaseExtension('https://example.com', 'TestExtension')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it('should have the correct properties', () => {
|
||||
expect(baseExtension.name).toBe('TestExtension')
|
||||
expect(baseExtension.productName).toBeUndefined()
|
||||
expect(baseExtension.url).toBe('https://example.com')
|
||||
expect(baseExtension.active).toBeUndefined()
|
||||
expect(baseExtension.description).toBeUndefined()
|
||||
expect(baseExtension.version).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for type()', () => {
|
||||
expect(baseExtension.type()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should have abstract methods onLoad() and onUnload()', () => {
|
||||
expect(baseExtension.onLoad).toBeDefined()
|
||||
expect(baseExtension.onUnload).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have installationState() return "NotRequired"', async () => {
|
||||
const installationState = await baseExtension.installationState()
|
||||
expect(installationState).toBe('NotRequired')
|
||||
})
|
||||
|
||||
it('should install the extension', async () => {
|
||||
await baseExtension.install()
|
||||
// Add your assertions here
|
||||
})
|
||||
})
|
||||
60
core/src/browser/extensions/engines/helpers/sse.test.ts
Normal file
60
core/src/browser/extensions/engines/helpers/sse.test.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { lastValueFrom, Observable } from 'rxjs'
|
||||
import { requestInference } from './sse'
|
||||
|
||||
describe('requestInference', () => {
|
||||
it('should send a request to the inference server and return an Observable', () => {
|
||||
// Mock the fetch function
|
||||
const mockFetch: any = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ choices: [{ message: { content: 'Generated response' } }] }),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
// Add other required properties here
|
||||
})
|
||||
)
|
||||
jest.spyOn(global, 'fetch').mockImplementation(mockFetch)
|
||||
|
||||
// Define the test inputs
|
||||
const inferenceUrl = 'https://inference-server.com'
|
||||
const requestBody = { message: 'Hello' }
|
||||
const model = { id: 'model-id', parameters: { stream: false } }
|
||||
|
||||
// Call the function
|
||||
const result = requestInference(inferenceUrl, requestBody, model)
|
||||
|
||||
// Assert the expected behavior
|
||||
expect(result).toBeInstanceOf(Observable)
|
||||
expect(lastValueFrom(result)).resolves.toEqual('Generated response')
|
||||
})
|
||||
|
||||
it('returns 401 error', () => {
|
||||
// Mock the fetch function
|
||||
const mockFetch: any = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: { message: 'Wrong API Key', code: 'invalid_api_key' } }),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
status: 401,
|
||||
statusText: 'invalid_api_key',
|
||||
// Add other required properties here
|
||||
})
|
||||
)
|
||||
jest.spyOn(global, 'fetch').mockImplementation(mockFetch)
|
||||
|
||||
// Define the test inputs
|
||||
const inferenceUrl = 'https://inference-server.com'
|
||||
const requestBody = { message: 'Hello' }
|
||||
const model = { id: 'model-id', parameters: { stream: false } }
|
||||
|
||||
// Call the function
|
||||
const result = requestInference(inferenceUrl, requestBody, model)
|
||||
|
||||
// Assert the expected behavior
|
||||
expect(result).toBeInstanceOf(Observable)
|
||||
expect(lastValueFrom(result)).rejects.toEqual({ message: 'Wrong API Key', code: 'invalid_api_key' })
|
||||
})
|
||||
})
|
||||
32
core/src/browser/index.test.ts
Normal file
32
core/src/browser/index.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import * as Core from './core';
|
||||
import * as Events from './events';
|
||||
import * as FileSystem from './fs';
|
||||
import * as Extension from './extension';
|
||||
import * as Extensions from './extensions';
|
||||
import * as Tools from './tools';
|
||||
|
||||
describe('Module Tests', () => {
|
||||
it('should export Core module', () => {
|
||||
expect(Core).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export Event module', () => {
|
||||
expect(Events).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export Filesystem module', () => {
|
||||
expect(FileSystem).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export Extension module', () => {
|
||||
expect(Extension).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export all base extensions', () => {
|
||||
expect(Extensions).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export all base tools', () => {
|
||||
expect(Tools).toBeDefined();
|
||||
});
|
||||
});
|
||||
10
core/src/node/api/common/adapter.test.ts
Normal file
10
core/src/node/api/common/adapter.test.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { RequestAdapter } from './adapter';
|
||||
|
||||
it('should return undefined for unknown route', () => {
|
||||
const adapter = new RequestAdapter();
|
||||
const route = 'unknownRoute';
|
||||
|
||||
const result = adapter.process(route, 'arg1', 'arg2');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
25
core/src/node/api/common/handler.test.ts
Normal file
25
core/src/node/api/common/handler.test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { CoreRoutes } from '../../../types/api';
|
||||
import { RequestHandler } from './handler';
|
||||
import { RequestAdapter } from './adapter';
|
||||
|
||||
it('should not call handler if CoreRoutes is empty', () => {
|
||||
const mockHandler = jest.fn();
|
||||
const mockObserver = jest.fn();
|
||||
const requestHandler = new RequestHandler(mockHandler, mockObserver);
|
||||
|
||||
CoreRoutes.length = 0; // Ensure CoreRoutes is empty
|
||||
|
||||
requestHandler.handle();
|
||||
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should initialize handler and adapter correctly', () => {
|
||||
const mockHandler = jest.fn();
|
||||
const mockObserver = jest.fn();
|
||||
const requestHandler = new RequestHandler(mockHandler, mockObserver);
|
||||
|
||||
expect(requestHandler.handler).toBe(mockHandler);
|
||||
expect(requestHandler.adapter).toBeInstanceOf(RequestAdapter);
|
||||
});
|
||||
40
core/src/node/api/processors/app.test.ts
Normal file
40
core/src/node/api/processors/app.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { App } from './app';
|
||||
|
||||
it('should call stopServer', () => {
|
||||
const app = new App();
|
||||
const stopServerMock = jest.fn().mockResolvedValue('Server stopped');
|
||||
jest.mock('@janhq/server', () => ({
|
||||
stopServer: stopServerMock
|
||||
}));
|
||||
const result = app.stopServer();
|
||||
expect(stopServerMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should correctly retrieve basename', () => {
|
||||
const app = new App();
|
||||
const result = app.baseName('/path/to/file.txt');
|
||||
expect(result).toBe('file.txt');
|
||||
});
|
||||
|
||||
it('should correctly identify subdirectories', () => {
|
||||
const app = new App();
|
||||
const basePath = process.platform === 'win32' ? 'C:\\path\\to' : '/path/to';
|
||||
const subPath = process.platform === 'win32' ? 'C:\\path\\to\\subdir' : '/path/to/subdir';
|
||||
const result = app.isSubdirectory(basePath, subPath);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly join multiple paths', () => {
|
||||
const app = new App();
|
||||
const result = app.joinPath(['path', 'to', 'file']);
|
||||
const expectedPath = process.platform === 'win32' ? 'path\\to\\file' : 'path/to/file';
|
||||
expect(result).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it('should call correct function with provided arguments using process method', () => {
|
||||
const app = new App();
|
||||
const mockFunc = jest.fn();
|
||||
app.joinPath = mockFunc;
|
||||
app.process('joinPath', ['path1', 'path2']);
|
||||
expect(mockFunc).toHaveBeenCalledWith(['path1', 'path2']);
|
||||
});
|
||||
59
core/src/node/api/processors/download.test.ts
Normal file
59
core/src/node/api/processors/download.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { Downloader } from './download';
|
||||
import { DownloadEvent } from '../../../types/api';
|
||||
import { DownloadManager } from '../../helper/download';
|
||||
|
||||
it('should handle getFileSize errors correctly', async () => {
|
||||
const observer = jest.fn();
|
||||
const url = 'http://example.com/file';
|
||||
|
||||
const downloader = new Downloader(observer);
|
||||
const requestMock = jest.fn((options, callback) => {
|
||||
callback(new Error('Test error'), null);
|
||||
});
|
||||
jest.mock('request', () => requestMock);
|
||||
|
||||
await expect(downloader.getFileSize(observer, url)).rejects.toThrow('Test error');
|
||||
});
|
||||
|
||||
|
||||
it('should pause download correctly', () => {
|
||||
const observer = jest.fn();
|
||||
const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file';
|
||||
|
||||
const downloader = new Downloader(observer);
|
||||
const pauseMock = jest.fn();
|
||||
DownloadManager.instance.networkRequests[fileName] = { pause: pauseMock };
|
||||
|
||||
downloader.pauseDownload(observer, fileName);
|
||||
|
||||
expect(pauseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resume download correctly', () => {
|
||||
const observer = jest.fn();
|
||||
const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file';
|
||||
|
||||
const downloader = new Downloader(observer);
|
||||
const resumeMock = jest.fn();
|
||||
DownloadManager.instance.networkRequests[fileName] = { resume: resumeMock };
|
||||
|
||||
downloader.resumeDownload(observer, fileName);
|
||||
|
||||
expect(resumeMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle aborting a download correctly', () => {
|
||||
const observer = jest.fn();
|
||||
const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file';
|
||||
|
||||
const downloader = new Downloader(observer);
|
||||
const abortMock = jest.fn();
|
||||
DownloadManager.instance.networkRequests[fileName] = { abort: abortMock };
|
||||
|
||||
downloader.abortDownload(observer, fileName);
|
||||
|
||||
expect(abortMock).toHaveBeenCalled();
|
||||
expect(observer).toHaveBeenCalledWith(DownloadEvent.onFileDownloadError, expect.objectContaining({
|
||||
error: 'aborted'
|
||||
}));
|
||||
});
|
||||
9
core/src/node/api/processors/extension.test.ts
Normal file
9
core/src/node/api/processors/extension.test.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Extension } from './extension';
|
||||
|
||||
it('should call function associated with key in process method', () => {
|
||||
const mockFunc = jest.fn();
|
||||
const extension = new Extension();
|
||||
(extension as any).testKey = mockFunc;
|
||||
extension.process('testKey', 'arg1', 'arg2');
|
||||
expect(mockFunc).toHaveBeenCalledWith('arg1', 'arg2');
|
||||
});
|
||||
18
core/src/node/api/processors/fs.test.ts
Normal file
18
core/src/node/api/processors/fs.test.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { FileSystem } from './fs';
|
||||
|
||||
it('should throw an error when the route does not exist in process', async () => {
|
||||
const fileSystem = new FileSystem();
|
||||
await expect(fileSystem.process('nonExistentRoute', 'arg1')).rejects.toThrow();
|
||||
});
|
||||
|
||||
|
||||
it('should throw an error for invalid argument in mkdir', async () => {
|
||||
const fileSystem = new FileSystem();
|
||||
expect(() => fileSystem.mkdir(123)).toThrow('mkdir error: Invalid argument [123]');
|
||||
});
|
||||
|
||||
|
||||
it('should throw an error for invalid argument in rm', async () => {
|
||||
const fileSystem = new FileSystem();
|
||||
expect(() => fileSystem.rm(123)).toThrow('rm error: Invalid argument [123]');
|
||||
});
|
||||
34
core/src/node/api/processors/fsExt.test.ts
Normal file
34
core/src/node/api/processors/fsExt.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { FSExt } from './fsExt';
|
||||
import { defaultAppConfig } from '../../helper';
|
||||
|
||||
it('should handle errors in writeBlob', () => {
|
||||
const fsExt = new FSExt();
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
fsExt.writeBlob('invalid-path', 'data');
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should call correct function in process method', () => {
|
||||
const fsExt = new FSExt();
|
||||
const mockFunction = jest.fn();
|
||||
(fsExt as any).mockFunction = mockFunction;
|
||||
fsExt.process('mockFunction', 'arg1', 'arg2');
|
||||
expect(mockFunction).toHaveBeenCalledWith('arg1', 'arg2');
|
||||
});
|
||||
|
||||
|
||||
it('should return correct user home path', () => {
|
||||
const fsExt = new FSExt();
|
||||
const userHomePath = fsExt.getUserHomePath();
|
||||
expect(userHomePath).toBe(defaultAppConfig().data_folder);
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('should return empty array when no files are provided', async () => {
|
||||
const fsExt = new FSExt();
|
||||
const result = await fsExt.getGgufFiles([]);
|
||||
expect(result.supportedFiles).toEqual([]);
|
||||
expect(result.unsupportedFiles).toEqual([]);
|
||||
});
|
||||
0
core/src/node/api/processors/processor.test.ts
Normal file
0
core/src/node/api/processors/processor.test.ts
Normal file
62
core/src/node/api/restful/app/download.test.ts
Normal file
62
core/src/node/api/restful/app/download.test.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { HttpServer } from '../../HttpServer'
|
||||
import { DownloadManager } from '../../../helper/download'
|
||||
|
||||
describe('downloadRouter', () => {
|
||||
let app: HttpServer
|
||||
|
||||
beforeEach(() => {
|
||||
app = {
|
||||
register: jest.fn(),
|
||||
post: jest.fn(),
|
||||
get: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
put: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
it('should return download progress for a given modelId', async () => {
|
||||
const modelId = '123'
|
||||
const downloadProgress = { progress: 50 }
|
||||
|
||||
DownloadManager.instance.downloadProgressMap[modelId] = downloadProgress as any
|
||||
|
||||
const req = { params: { modelId } }
|
||||
const res = {
|
||||
status: jest.fn(),
|
||||
send: jest.fn(),
|
||||
}
|
||||
|
||||
jest.spyOn(app, 'get').mockImplementation((path, handler) => {
|
||||
if (path === `/download/getDownloadProgress/${modelId}`) {
|
||||
res.status(200)
|
||||
res.send(downloadProgress)
|
||||
}
|
||||
})
|
||||
|
||||
app.get(`/download/getDownloadProgress/${modelId}`, req as any)
|
||||
expect(res.status).toHaveBeenCalledWith(200)
|
||||
expect(res.send).toHaveBeenCalledWith(downloadProgress)
|
||||
})
|
||||
|
||||
it('should return 404 if download progress is not found', async () => {
|
||||
const modelId = '123'
|
||||
|
||||
const req = { params: { modelId } }
|
||||
const res = {
|
||||
status: jest.fn(),
|
||||
send: jest.fn(),
|
||||
}
|
||||
|
||||
|
||||
jest.spyOn(app, 'get').mockImplementation((path, handler) => {
|
||||
if (path === `/download/getDownloadProgress/${modelId}`) {
|
||||
res.status(404)
|
||||
res.send({ message: 'Download progress not found' })
|
||||
}
|
||||
})
|
||||
app.get(`/download/getDownloadProgress/${modelId}`, req as any)
|
||||
expect(res.status).toHaveBeenCalledWith(404)
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'Download progress not found' })
|
||||
})
|
||||
})
|
||||
16
core/src/node/api/restful/app/handlers.test.ts
Normal file
16
core/src/node/api/restful/app/handlers.test.ts
Normal file
@ -0,0 +1,16 @@
|
||||
//
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
import { HttpServer } from '../../HttpServer';
|
||||
import { handleRequests } from './handlers';
|
||||
import { Handler, RequestHandler } from '../../common/handler';
|
||||
|
||||
it('should initialize RequestHandler and call handle', () => {
|
||||
const mockHandle = jest.fn();
|
||||
jest.spyOn(RequestHandler.prototype, 'handle').mockImplementation(mockHandle);
|
||||
|
||||
const mockApp = { post: jest.fn() };
|
||||
handleRequests(mockApp as unknown as HttpServer);
|
||||
|
||||
expect(mockHandle).toHaveBeenCalled();
|
||||
});
|
||||
21
core/src/node/api/restful/common.test.ts
Normal file
21
core/src/node/api/restful/common.test.ts
Normal file
@ -0,0 +1,21 @@
|
||||
|
||||
import { commonRouter } from './common';
|
||||
import { JanApiRouteConfiguration } from './helper/configuration';
|
||||
|
||||
test('commonRouter sets up routes for each key in JanApiRouteConfiguration', async () => {
|
||||
const mockHttpServer = {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
put: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
await commonRouter(mockHttpServer as any);
|
||||
|
||||
const expectedRoutes = Object.keys(JanApiRouteConfiguration);
|
||||
expectedRoutes.forEach((key) => {
|
||||
expect(mockHttpServer.get).toHaveBeenCalledWith(`/${key}`, expect.any(Function));
|
||||
expect(mockHttpServer.get).toHaveBeenCalledWith(`/${key}/:id`, expect.any(Function));
|
||||
expect(mockHttpServer.delete).toHaveBeenCalledWith(`/${key}/:id`, expect.any(Function));
|
||||
});
|
||||
});
|
||||
24
core/src/node/api/restful/helper/configuration.test.ts
Normal file
24
core/src/node/api/restful/helper/configuration.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { JanApiRouteConfiguration } from './configuration'
|
||||
|
||||
describe('JanApiRouteConfiguration', () => {
|
||||
it('should have the correct models configuration', () => {
|
||||
const modelsConfig = JanApiRouteConfiguration.models;
|
||||
expect(modelsConfig.dirName).toBe('models');
|
||||
expect(modelsConfig.metadataFileName).toBe('model.json');
|
||||
expect(modelsConfig.delete.object).toBe('model');
|
||||
});
|
||||
|
||||
it('should have the correct assistants configuration', () => {
|
||||
const assistantsConfig = JanApiRouteConfiguration.assistants;
|
||||
expect(assistantsConfig.dirName).toBe('assistants');
|
||||
expect(assistantsConfig.metadataFileName).toBe('assistant.json');
|
||||
expect(assistantsConfig.delete.object).toBe('assistant');
|
||||
});
|
||||
|
||||
it('should have the correct threads configuration', () => {
|
||||
const threadsConfig = JanApiRouteConfiguration.threads;
|
||||
expect(threadsConfig.dirName).toBe('threads');
|
||||
expect(threadsConfig.metadataFileName).toBe('thread.json');
|
||||
expect(threadsConfig.delete.object).toBe('thread');
|
||||
});
|
||||
});
|
||||
16
core/src/node/api/restful/v1.test.ts
Normal file
16
core/src/node/api/restful/v1.test.ts
Normal file
@ -0,0 +1,16 @@
|
||||
|
||||
import { v1Router } from './v1';
|
||||
import { commonRouter } from './common';
|
||||
|
||||
test('should define v1Router function', () => {
|
||||
expect(v1Router).toBeDefined();
|
||||
});
|
||||
|
||||
test('should register commonRouter', () => {
|
||||
const mockApp = {
|
||||
register: jest.fn(),
|
||||
};
|
||||
v1Router(mockApp as any);
|
||||
expect(mockApp.register).toHaveBeenCalledWith(commonRouter);
|
||||
});
|
||||
|
||||
122
core/src/node/extension/extension.test.ts
Normal file
122
core/src/node/extension/extension.test.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import Extension from './extension';
|
||||
import { join } from 'path';
|
||||
import 'pacote';
|
||||
|
||||
it('should set active and call emitUpdate', () => {
|
||||
const extension = new Extension();
|
||||
extension.emitUpdate = jest.fn();
|
||||
|
||||
extension.setActive(true);
|
||||
|
||||
expect(extension._active).toBe(true);
|
||||
expect(extension.emitUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should return correct specifier', () => {
|
||||
const origin = 'test-origin';
|
||||
const options = { version: '1.0.0' };
|
||||
const extension = new Extension(origin, options);
|
||||
|
||||
expect(extension.specifier).toBe('test-origin@1.0.0');
|
||||
});
|
||||
|
||||
|
||||
it('should set origin and installOptions in constructor', () => {
|
||||
const origin = 'test-origin';
|
||||
const options = { someOption: true };
|
||||
const extension = new Extension(origin, options);
|
||||
|
||||
expect(extension.origin).toBe(origin);
|
||||
expect(extension.installOptions.someOption).toBe(true);
|
||||
expect(extension.installOptions.fullMetadata).toBe(true); // default option
|
||||
});
|
||||
|
||||
it('should install extension and set url', async () => {
|
||||
const origin = 'test-origin';
|
||||
const options = {};
|
||||
const extension = new Extension(origin, options);
|
||||
|
||||
const mockManifest = {
|
||||
name: 'test-name',
|
||||
productName: 'Test Product',
|
||||
version: '1.0.0',
|
||||
main: 'index.js',
|
||||
description: 'Test description'
|
||||
};
|
||||
|
||||
jest.mock('pacote', () => ({
|
||||
manifest: jest.fn().mockResolvedValue(mockManifest),
|
||||
extract: jest.fn().mockResolvedValue(null)
|
||||
}));
|
||||
|
||||
extension.emitUpdate = jest.fn();
|
||||
await extension._install();
|
||||
|
||||
expect(extension.url).toBe('extension://test-name/index.js');
|
||||
expect(extension.emitUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should call all listeners in emitUpdate', () => {
|
||||
const extension = new Extension();
|
||||
const callback1 = jest.fn();
|
||||
const callback2 = jest.fn();
|
||||
|
||||
extension.subscribe('listener1', callback1);
|
||||
extension.subscribe('listener2', callback2);
|
||||
|
||||
extension.emitUpdate();
|
||||
|
||||
expect(callback1).toHaveBeenCalledWith(extension);
|
||||
expect(callback2).toHaveBeenCalledWith(extension);
|
||||
});
|
||||
|
||||
|
||||
it('should remove listener in unsubscribe', () => {
|
||||
const extension = new Extension();
|
||||
const callback = jest.fn();
|
||||
|
||||
extension.subscribe('testListener', callback);
|
||||
extension.unsubscribe('testListener');
|
||||
|
||||
expect(extension.listeners['testListener']).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
it('should add listener in subscribe', () => {
|
||||
const extension = new Extension();
|
||||
const callback = jest.fn();
|
||||
|
||||
extension.subscribe('testListener', callback);
|
||||
|
||||
expect(extension.listeners['testListener']).toBe(callback);
|
||||
});
|
||||
|
||||
|
||||
it('should set properties from manifest', async () => {
|
||||
const origin = 'test-origin';
|
||||
const options = {};
|
||||
const extension = new Extension(origin, options);
|
||||
|
||||
const mockManifest = {
|
||||
name: 'test-name',
|
||||
productName: 'Test Product',
|
||||
version: '1.0.0',
|
||||
main: 'index.js',
|
||||
description: 'Test description'
|
||||
};
|
||||
|
||||
jest.mock('pacote', () => ({
|
||||
manifest: jest.fn().mockResolvedValue(mockManifest)
|
||||
}));
|
||||
|
||||
await extension.getManifest();
|
||||
|
||||
expect(extension.name).toBe('test-name');
|
||||
expect(extension.productName).toBe('Test Product');
|
||||
expect(extension.version).toBe('1.0.0');
|
||||
expect(extension.main).toBe('index.js');
|
||||
expect(extension.description).toBe('Test description');
|
||||
});
|
||||
|
||||
28
core/src/node/extension/manager.test.ts
Normal file
28
core/src/node/extension/manager.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import * as fs from 'fs';
|
||||
import { join } from 'path';
|
||||
import { ExtensionManager } from './manager';
|
||||
|
||||
it('should throw an error when an invalid path is provided', () => {
|
||||
const manager = new ExtensionManager();
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
expect(() => manager.setExtensionsPath('')).toThrow('Invalid path provided to the extensions folder');
|
||||
});
|
||||
|
||||
|
||||
it('should return an empty string when extensionsPath is not set', () => {
|
||||
const manager = new ExtensionManager();
|
||||
expect(manager.getExtensionsFile()).toBe(join('', 'extensions.json'));
|
||||
});
|
||||
|
||||
|
||||
it('should return undefined if no path is set', () => {
|
||||
const manager = new ExtensionManager();
|
||||
expect(manager.getExtensionsPath()).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
it('should return the singleton instance', () => {
|
||||
const instance1 = new ExtensionManager();
|
||||
const instance2 = new ExtensionManager();
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
43
core/src/node/extension/store.test.ts
Normal file
43
core/src/node/extension/store.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { getAllExtensions } from './store';
|
||||
import { getActiveExtensions } from './store';
|
||||
import { getExtension } from './store';
|
||||
|
||||
test('should return empty array when no extensions added', () => {
|
||||
expect(getAllExtensions()).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
test('should throw error when extension does not exist', () => {
|
||||
expect(() => getExtension('nonExistentExtension')).toThrow('Extension nonExistentExtension does not exist');
|
||||
});
|
||||
|
||||
import { addExtension } from './store';
|
||||
import Extension from './extension';
|
||||
|
||||
test('should return all extensions when multiple extensions added', () => {
|
||||
const ext1 = new Extension('ext1');
|
||||
ext1.name = 'ext1';
|
||||
const ext2 = new Extension('ext2');
|
||||
ext2.name = 'ext2';
|
||||
|
||||
addExtension(ext1, false);
|
||||
addExtension(ext2, false);
|
||||
|
||||
expect(getAllExtensions()).toEqual([ext1, ext2]);
|
||||
});
|
||||
|
||||
|
||||
|
||||
test('should return only active extensions', () => {
|
||||
const ext1 = new Extension('ext1');
|
||||
ext1.name = 'ext1';
|
||||
ext1.setActive(true);
|
||||
const ext2 = new Extension('ext2');
|
||||
ext2.name = 'ext2';
|
||||
ext2.setActive(false);
|
||||
|
||||
addExtension(ext1, false);
|
||||
addExtension(ext2, false);
|
||||
|
||||
expect(getActiveExtensions()).toEqual([ext1]);
|
||||
});
|
||||
14
core/src/node/helper/config.test.ts
Normal file
14
core/src/node/helper/config.test.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { getEngineConfiguration } from './config';
|
||||
import { getAppConfigurations, defaultAppConfig } from './config';
|
||||
|
||||
it('should return undefined for invalid engine ID', async () => {
|
||||
const config = await getEngineConfiguration('invalid_engine');
|
||||
expect(config).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
it('should return default config when CI is e2e', () => {
|
||||
process.env.CI = 'e2e';
|
||||
const config = getAppConfigurations();
|
||||
expect(config).toEqual(defaultAppConfig());
|
||||
});
|
||||
11
core/src/node/helper/download.test.ts
Normal file
11
core/src/node/helper/download.test.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { DownloadManager } from './download';
|
||||
|
||||
it('should set a network request for a specific file', () => {
|
||||
const downloadManager = new DownloadManager();
|
||||
const fileName = 'testFile';
|
||||
const request = { url: 'http://example.com' };
|
||||
|
||||
downloadManager.setRequest(fileName, request);
|
||||
|
||||
expect(downloadManager.networkRequests[fileName]).toEqual(request);
|
||||
});
|
||||
47
core/src/node/helper/logger.test.ts
Normal file
47
core/src/node/helper/logger.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Logger, LoggerManager } from './logger';
|
||||
|
||||
it('should flush queued logs to registered loggers', () => {
|
||||
class TestLogger extends Logger {
|
||||
name = 'testLogger';
|
||||
log(args: any): void {
|
||||
console.log(args);
|
||||
}
|
||||
}
|
||||
const loggerManager = new LoggerManager();
|
||||
const testLogger = new TestLogger();
|
||||
loggerManager.register(testLogger);
|
||||
const logSpy = jest.spyOn(testLogger, 'log');
|
||||
loggerManager.log('test log');
|
||||
expect(logSpy).toHaveBeenCalledWith('test log');
|
||||
});
|
||||
|
||||
|
||||
it('should unregister a logger', () => {
|
||||
class TestLogger extends Logger {
|
||||
name = 'testLogger';
|
||||
log(args: any): void {
|
||||
console.log(args);
|
||||
}
|
||||
}
|
||||
const loggerManager = new LoggerManager();
|
||||
const testLogger = new TestLogger();
|
||||
loggerManager.register(testLogger);
|
||||
loggerManager.unregister('testLogger');
|
||||
const retrievedLogger = loggerManager.get('testLogger');
|
||||
expect(retrievedLogger).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
it('should register and retrieve a logger', () => {
|
||||
class TestLogger extends Logger {
|
||||
name = 'testLogger';
|
||||
log(args: any): void {
|
||||
console.log(args);
|
||||
}
|
||||
}
|
||||
const loggerManager = new LoggerManager();
|
||||
const testLogger = new TestLogger();
|
||||
loggerManager.register(testLogger);
|
||||
const retrievedLogger = loggerManager.get('testLogger');
|
||||
expect(retrievedLogger).toBe(testLogger);
|
||||
});
|
||||
23
core/src/node/helper/module.test.ts
Normal file
23
core/src/node/helper/module.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ModuleManager } from './module';
|
||||
|
||||
it('should clear all imported modules', () => {
|
||||
const moduleManager = new ModuleManager();
|
||||
moduleManager.setModule('module1', { key: 'value1' });
|
||||
moduleManager.setModule('module2', { key: 'value2' });
|
||||
moduleManager.clearImportedModules();
|
||||
expect(moduleManager.requiredModules).toEqual({});
|
||||
});
|
||||
|
||||
|
||||
it('should set a module correctly', () => {
|
||||
const moduleManager = new ModuleManager();
|
||||
moduleManager.setModule('testModule', { key: 'value' });
|
||||
expect(moduleManager.requiredModules['testModule']).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
|
||||
it('should return the singleton instance', () => {
|
||||
const instance1 = new ModuleManager();
|
||||
const instance2 = new ModuleManager();
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
29
core/src/node/helper/path.test.ts
Normal file
29
core/src/node/helper/path.test.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { normalizeFilePath } from './path'
|
||||
|
||||
import { jest } from '@jest/globals'
|
||||
describe('Test file normalize', () => {
|
||||
test('returns no file protocol prefix on Unix', async () => {
|
||||
expect(normalizeFilePath('file://test.txt')).toBe('test.txt')
|
||||
expect(normalizeFilePath('file:/test.txt')).toBe('test.txt')
|
||||
})
|
||||
test('returns no file protocol prefix on Windows', async () => {
|
||||
expect(normalizeFilePath('file:\\\\test.txt')).toBe('test.txt')
|
||||
expect(normalizeFilePath('file:\\test.txt')).toBe('test.txt')
|
||||
})
|
||||
|
||||
test('returns correct path when Electron is available and app is not packaged', () => {
|
||||
const electronMock = {
|
||||
app: {
|
||||
getAppPath: jest.fn().mockReturnValue('/mocked/path'),
|
||||
isPackaged: false,
|
||||
},
|
||||
protocol: {},
|
||||
}
|
||||
jest.mock('electron', () => electronMock)
|
||||
|
||||
const { appResourcePath } = require('./path')
|
||||
|
||||
const expectedPath = process.platform === 'win32' ? '\\mocked\\path' : '/mocked/path'
|
||||
expect(appResourcePath()).toBe(expectedPath)
|
||||
})
|
||||
})
|
||||
15
core/src/node/helper/resource.test.ts
Normal file
15
core/src/node/helper/resource.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { getSystemResourceInfo } from './resource';
|
||||
|
||||
it('should return the correct system resource information with a valid CPU count', async () => {
|
||||
const mockCpuCount = 4;
|
||||
jest.spyOn(require('./config'), 'physicalCpuCount').mockResolvedValue(mockCpuCount);
|
||||
const logSpy = jest.spyOn(require('./logger'), 'log').mockImplementation(() => {});
|
||||
|
||||
const result = await getSystemResourceInfo();
|
||||
|
||||
expect(result).toEqual({
|
||||
numCpuPhysicalCore: mockCpuCount,
|
||||
memAvailable: 0,
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledWith(`[CORTEX]::CPU information - ${mockCpuCount}`);
|
||||
});
|
||||
@ -55,6 +55,7 @@ export enum AppEvent {
|
||||
onSelectedText = 'onSelectedText',
|
||||
|
||||
onDeepLink = 'onDeepLink',
|
||||
onMainViewStateChange = 'onMainViewStateChange',
|
||||
}
|
||||
|
||||
export enum DownloadRoute {
|
||||
|
||||
7
core/src/types/assistant/assistantEvent.test.ts
Normal file
7
core/src/types/assistant/assistantEvent.test.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { AssistantEvent } from './assistantEvent';
|
||||
it('dummy test', () => { expect(true).toBe(true); });
|
||||
|
||||
it('should contain OnAssistantsUpdate event', () => {
|
||||
expect(AssistantEvent.OnAssistantsUpdate).toBe('OnAssistantsUpdate');
|
||||
});
|
||||
|
||||
@ -16,7 +16,7 @@ export type DownloadState = {
|
||||
|
||||
error?: string
|
||||
extensionId?: string
|
||||
downloadType?: DownloadType
|
||||
downloadType?: DownloadType | string
|
||||
localPath?: string
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ export type DownloadRequest = {
|
||||
*/
|
||||
extensionId?: string
|
||||
|
||||
downloadType?: DownloadType
|
||||
downloadType?: DownloadType | string
|
||||
}
|
||||
|
||||
type DownloadTime = {
|
||||
|
||||
10
core/testRunner.js
Normal file
10
core/testRunner.js
Normal file
@ -0,0 +1,10 @@
|
||||
const jestRunner = require('jest-runner');
|
||||
|
||||
class EmptyTestFileRunner extends jestRunner.default {
|
||||
async runTests(tests, watcher, onStart, onResult, onFailure, options) {
|
||||
const nonEmptyTests = tests.filter(test => test.context.hasteFS.getSize(test.path) > 0);
|
||||
return super.runTests(nonEmptyTests, watcher, onStart, onResult, onFailure, options);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EmptyTestFileRunner;
|
||||
@ -1,12 +0,0 @@
|
||||
import { normalizeFilePath } from "../../src/node/helper/path";
|
||||
|
||||
describe("Test file normalize", () => {
|
||||
test("returns no file protocol prefix on Unix", async () => {
|
||||
expect(normalizeFilePath("file://test.txt")).toBe("test.txt");
|
||||
expect(normalizeFilePath("file:/test.txt")).toBe("test.txt");
|
||||
});
|
||||
test("returns no file protocol prefix on Windows", async () => {
|
||||
expect(normalizeFilePath("file:\\\\test.txt")).toBe("test.txt");
|
||||
expect(normalizeFilePath("file:\\test.txt")).toBe("test.txt");
|
||||
});
|
||||
});
|
||||
@ -16,4 +16,5 @@
|
||||
"types": ["@types/jest"],
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
|
||||
@ -6,9 +6,10 @@ export const mainWindowConfig: Electron.BrowserWindowConstructorOptions = {
|
||||
minWidth: DEFAULT_MIN_WIDTH,
|
||||
minHeight: DEFAULT_MIN_HEIGHT,
|
||||
show: true,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
// we want to go frameless on windows and linux
|
||||
transparent: process.platform === 'darwin',
|
||||
frame: process.platform === 'darwin',
|
||||
titleBarStyle: 'hiddenInset',
|
||||
vibrancy: 'fullscreen-ui',
|
||||
visualEffectState: 'active',
|
||||
backgroundMaterial: 'acrylic',
|
||||
|
||||
@ -166,6 +166,15 @@ class WindowManager {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send main view state to the main app.
|
||||
*/
|
||||
sendMainViewState(route: string) {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send(AppEvent.onMainViewStateChange, route)
|
||||
}
|
||||
}
|
||||
|
||||
cleanUp(): void {
|
||||
if (!this.mainWindow?.isDestroyed()) {
|
||||
this.mainWindow?.close()
|
||||
|
||||
@ -3,6 +3,7 @@ import { app, Menu, shell, dialog } from 'electron'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { log } from '@janhq/core/node'
|
||||
const isMac = process.platform === 'darwin'
|
||||
import { windowManager } from '../managers/window'
|
||||
|
||||
const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
|
||||
{
|
||||
@ -43,6 +44,14 @@ const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{
|
||||
label: `Settings`,
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: () => {
|
||||
windowManager.showMainWindow()
|
||||
windowManager.sendMainViewState('Settings')
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' },
|
||||
],
|
||||
|
||||
3
jest.config.js
Normal file
3
jest.config.js
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
projects: ['<rootDir>/core', '<rootDir>/web', '<rootDir>/joi'],
|
||||
}
|
||||
8
joi/jest.config.js
Normal file
8
joi/jest.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/*.test.*'],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testEnvironment: 'jsdom',
|
||||
}
|
||||
0
joi/jest.setup.js
Normal file
0
joi/jest.setup.js
Normal file
@ -21,7 +21,8 @@
|
||||
"bugs": "https://github.com/codecentrum/piksel/issues",
|
||||
"scripts": {
|
||||
"dev": "rollup -c -w",
|
||||
"build": "rimraf ./dist && rollup -c"
|
||||
"build": "rimraf ./dist && rollup -c",
|
||||
"test": "jest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@ -38,13 +39,22 @@
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"autoprefixer": "10.4.16",
|
||||
"tailwindcss": "^3.4.1"
|
||||
"jest": "^29.7.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-transform-css": "^6.0.1",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.6",
|
||||
"rollup": "^4.12.0",
|
||||
|
||||
64
joi/src/core/Accordion/Accordion.test.tsx
Normal file
64
joi/src/core/Accordion/Accordion.test.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { Accordion, AccordionItem } from './index'
|
||||
|
||||
// Mock the SCSS import
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('Accordion', () => {
|
||||
it('renders accordion with items', () => {
|
||||
render(
|
||||
<Accordion defaultValue={['item1']}>
|
||||
<AccordionItem value="item1" title="Item 1">
|
||||
Content 1
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item2" title="Item 2">
|
||||
Content 2
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands and collapses accordion items', () => {
|
||||
render(
|
||||
<Accordion defaultValue={[]}>
|
||||
<AccordionItem value="item1" title="Item 1">
|
||||
Content 1
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
const trigger = screen.getByText('Item 1')
|
||||
|
||||
// Initially, content should not be visible
|
||||
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByText('Content 1')).toBeInTheDocument()
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('respects defaultValue prop', () => {
|
||||
render(
|
||||
<Accordion defaultValue={['item2']}>
|
||||
<AccordionItem value="item1" title="Item 1">
|
||||
Content 1
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item2" title="Item 2">
|
||||
Content 2
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Content 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
83
joi/src/core/Badge/Badge.test.tsx
Normal file
83
joi/src/core/Badge/Badge.test.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Badge, badgeConfig } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('@joi/core/Badge', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<Badge>Test Badge</Badge>)
|
||||
const badge = screen.getByText('Test Badge')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(badge).toHaveClass('badge')
|
||||
expect(badge).toHaveClass('badge--primary')
|
||||
expect(badge).toHaveClass('badge--medium')
|
||||
expect(badge).toHaveClass('badge--solid')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Badge className="custom-class">Test Badge</Badge>)
|
||||
const badge = screen.getByText('Test Badge')
|
||||
expect(badge).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('renders with different themes', () => {
|
||||
const themes = Object.keys(badgeConfig.variants.theme)
|
||||
themes.forEach((theme) => {
|
||||
render(<Badge theme={theme as any}>Test Badge {theme}</Badge>)
|
||||
const badge = screen.getByText(`Test Badge ${theme}`)
|
||||
expect(badge).toHaveClass(`badge--${theme}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders with different variants', () => {
|
||||
const variants = Object.keys(badgeConfig.variants.variant)
|
||||
variants.forEach((variant) => {
|
||||
render(<Badge variant={variant as any}>Test Badge {variant}</Badge>)
|
||||
const badge = screen.getByText(`Test Badge ${variant}`)
|
||||
expect(badge).toHaveClass(`badge--${variant}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const sizes = Object.keys(badgeConfig.variants.size)
|
||||
sizes.forEach((size) => {
|
||||
render(<Badge size={size as any}>Test Badge {size}</Badge>)
|
||||
const badge = screen.getByText(`Test Badge ${size}`)
|
||||
expect(badge).toHaveClass(`badge--${size}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('fails when a new theme is added without updating the test', () => {
|
||||
const expectedThemes = [
|
||||
'primary',
|
||||
'secondary',
|
||||
'warning',
|
||||
'success',
|
||||
'info',
|
||||
'destructive',
|
||||
]
|
||||
const actualThemes = Object.keys(badgeConfig.variants.theme)
|
||||
expect(actualThemes).toEqual(expectedThemes)
|
||||
})
|
||||
|
||||
it('fails when a new variant is added without updating the test', () => {
|
||||
const expectedVariant = ['solid', 'soft', 'outline']
|
||||
const actualVariants = Object.keys(badgeConfig.variants.variant)
|
||||
expect(actualVariants).toEqual(expectedVariant)
|
||||
})
|
||||
|
||||
it('fails when a new size is added without updating the test', () => {
|
||||
const expectedSizes = ['small', 'medium', 'large']
|
||||
const actualSizes = Object.keys(badgeConfig.variants.size)
|
||||
expect(actualSizes).toEqual(expectedSizes)
|
||||
})
|
||||
|
||||
it('fails when a new variant CVA is added without updating the test', () => {
|
||||
const expectedVariantsCVA = ['theme', 'variant', 'size']
|
||||
const actualVariant = Object.keys(badgeConfig.variants)
|
||||
expect(actualVariant).toEqual(expectedVariantsCVA)
|
||||
})
|
||||
})
|
||||
@ -6,7 +6,7 @@ import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import './styles.scss'
|
||||
|
||||
const badgeVariants = cva('badge', {
|
||||
export const badgeConfig = {
|
||||
variants: {
|
||||
theme: {
|
||||
primary: 'badge--primary',
|
||||
@ -28,11 +28,13 @@ const badgeVariants = cva('badge', {
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
theme: 'primary',
|
||||
size: 'medium',
|
||||
variant: 'solid',
|
||||
theme: 'primary' as const,
|
||||
size: 'medium' as const,
|
||||
variant: 'solid' as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const badgeVariants = cva('badge', badgeConfig)
|
||||
|
||||
export interface BadgeProps
|
||||
extends HTMLAttributes<HTMLDivElement>,
|
||||
|
||||
68
joi/src/core/Button/Button.test.tsx
Normal file
68
joi/src/core/Button/Button.test.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Button, buttonConfig } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<Button>Click me</Button>)
|
||||
const button = screen.getByRole('button', { name: /click me/i })
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button).toHaveClass('btn btn--primary btn--medium btn--solid')
|
||||
})
|
||||
|
||||
it('renders as a child component when asChild is true', () => {
|
||||
render(
|
||||
<Button asChild>
|
||||
<a href="/">Link Button</a>
|
||||
</Button>
|
||||
)
|
||||
const link = screen.getByRole('link', { name: /link button/i })
|
||||
expect(link).toBeInTheDocument()
|
||||
expect(link).toHaveClass('btn btn--primary btn--medium btn--solid')
|
||||
})
|
||||
|
||||
it.each(Object.keys(buttonConfig.variants.theme))(
|
||||
'renders with theme %s',
|
||||
(theme) => {
|
||||
render(<Button theme={theme as any}>Theme Button</Button>)
|
||||
const button = screen.getByRole('button', { name: /theme button/i })
|
||||
expect(button).toHaveClass(`btn btn--${theme}`)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(Object.keys(buttonConfig.variants.variant))(
|
||||
'renders with variant %s',
|
||||
(variant) => {
|
||||
render(<Button variant={variant as any}>Variant Button</Button>)
|
||||
const button = screen.getByRole('button', { name: /variant button/i })
|
||||
expect(button).toHaveClass(`btn btn--${variant}`)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(Object.keys(buttonConfig.variants.size))(
|
||||
'renders with size %s',
|
||||
(size) => {
|
||||
render(<Button size={size as any}>Size Button</Button>)
|
||||
const button = screen.getByRole('button', { name: /size button/i })
|
||||
expect(button).toHaveClass(`btn btn--${size}`)
|
||||
}
|
||||
)
|
||||
|
||||
it('renders with block prop', () => {
|
||||
render(<Button block>Block Button</Button>)
|
||||
const button = screen.getByRole('button', { name: /block button/i })
|
||||
expect(button).toHaveClass('btn btn--block')
|
||||
})
|
||||
|
||||
it('merges custom className with generated classes', () => {
|
||||
render(<Button className="custom-class">Custom Class Button</Button>)
|
||||
const button = screen.getByRole('button', { name: /custom class button/i })
|
||||
expect(button).toHaveClass(
|
||||
'btn btn--primary btn--medium btn--solid custom-class'
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -7,7 +7,7 @@ import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import './styles.scss'
|
||||
|
||||
const buttonVariants = cva('btn', {
|
||||
export const buttonConfig = {
|
||||
variants: {
|
||||
theme: {
|
||||
primary: 'btn--primary',
|
||||
@ -30,12 +30,13 @@ const buttonVariants = cva('btn', {
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
theme: 'primary',
|
||||
size: 'medium',
|
||||
variant: 'solid',
|
||||
block: false,
|
||||
theme: 'primary' as const,
|
||||
size: 'medium' as const,
|
||||
variant: 'solid' as const,
|
||||
block: false as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
const buttonVariants = cva('btn', buttonConfig)
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
|
||||
@ -2,10 +2,18 @@ import React, { ReactNode } from 'react'
|
||||
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
|
||||
import { Tooltip } from '../Tooltip'
|
||||
|
||||
import './styles.scss'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type TabsProps = {
|
||||
options: { name: string; value: string }[]
|
||||
options: {
|
||||
name: string
|
||||
value: string
|
||||
disabled?: boolean
|
||||
tooltipContent?: string
|
||||
}[]
|
||||
children: ReactNode
|
||||
defaultValue?: string
|
||||
value: string
|
||||
@ -15,11 +23,15 @@ type TabsProps = {
|
||||
type TabsContentProps = {
|
||||
value: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TabsContent = ({ value, children }: TabsContentProps) => {
|
||||
const TabsContent = ({ value, children, className }: TabsContentProps) => {
|
||||
return (
|
||||
<TabsPrimitive.Content className="tabs__content" value={value}>
|
||||
<TabsPrimitive.Content
|
||||
className={twMerge('tabs__content', className)}
|
||||
value={value}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Content>
|
||||
)
|
||||
@ -40,11 +52,27 @@ const Tabs = ({
|
||||
>
|
||||
<TabsPrimitive.List className="tabs__list">
|
||||
{options.map((option, i) => {
|
||||
return (
|
||||
return option.disabled ? (
|
||||
<Tooltip
|
||||
key={i}
|
||||
content={option.tooltipContent}
|
||||
trigger={
|
||||
<TabsPrimitive.Trigger
|
||||
key={i}
|
||||
className="tabs__trigger"
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.name}
|
||||
</TabsPrimitive.Trigger>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TabsPrimitive.Trigger
|
||||
key={i}
|
||||
className="tabs__trigger"
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.name}
|
||||
</TabsPrimitive.Trigger>
|
||||
|
||||
@ -21,6 +21,10 @@
|
||||
&:focus {
|
||||
position: relative;
|
||||
}
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
|
||||
55
joi/src/hooks/useClickOutside/useClickOutside.test.tsx
Normal file
55
joi/src/hooks/useClickOutside/useClickOutside.test.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import { render, fireEvent, act } from '@testing-library/react'
|
||||
import { useClickOutside } from './index'
|
||||
|
||||
// Mock component to test the hook
|
||||
const TestComponent: React.FC<{ onClickOutside: () => void }> = ({
|
||||
onClickOutside,
|
||||
}) => {
|
||||
const ref = useClickOutside(onClickOutside)
|
||||
return <div ref={ref as React.RefObject<HTMLDivElement>}>Test</div>
|
||||
}
|
||||
|
||||
describe('@joi/hooks/useClickOutside', () => {
|
||||
it('should call handler when clicking outside', () => {
|
||||
const handleClickOutside = jest.fn()
|
||||
const { container } = render(
|
||||
<TestComponent onClickOutside={handleClickOutside} />
|
||||
)
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseDown(document.body)
|
||||
})
|
||||
|
||||
expect(handleClickOutside).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call handler when clicking inside', () => {
|
||||
const handleClickOutside = jest.fn()
|
||||
const { getByText } = render(
|
||||
<TestComponent onClickOutside={handleClickOutside} />
|
||||
)
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseDown(getByText('Test'))
|
||||
})
|
||||
|
||||
expect(handleClickOutside).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should work with custom events', () => {
|
||||
const handleClickOutside = jest.fn()
|
||||
const TestComponentWithCustomEvent: React.FC = () => {
|
||||
const ref = useClickOutside(handleClickOutside, ['click'])
|
||||
return <div ref={ref as React.RefObject<HTMLDivElement>}>Test</div>
|
||||
}
|
||||
|
||||
render(<TestComponentWithCustomEvent />)
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(document.body)
|
||||
})
|
||||
|
||||
expect(handleClickOutside).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
102
joi/src/hooks/useClipboard/useClipboard.test.ts
Normal file
102
joi/src/hooks/useClipboard/useClipboard.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useClipboard } from './index'
|
||||
|
||||
// Mock the navigator.clipboard
|
||||
const mockClipboard = {
|
||||
writeText: jest.fn(() => Promise.resolve()),
|
||||
}
|
||||
Object.assign(navigator, { clipboard: mockClipboard })
|
||||
|
||||
describe('@joi/hooks/useClipboard', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.spyOn(window, 'setTimeout')
|
||||
jest.spyOn(window, 'clearTimeout')
|
||||
mockClipboard.writeText.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should copy text to clipboard', async () => {
|
||||
const { result } = renderHook(() => useClipboard())
|
||||
|
||||
await act(async () => {
|
||||
result.current.copy('Test text')
|
||||
})
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test text')
|
||||
expect(result.current.copied).toBe(true)
|
||||
expect(result.current.error).toBe(null)
|
||||
})
|
||||
|
||||
it('should set error if clipboard write fails', async () => {
|
||||
mockClipboard.writeText.mockRejectedValueOnce(
|
||||
new Error('Clipboard write failed')
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useClipboard())
|
||||
|
||||
await act(async () => {
|
||||
result.current.copy('Test text')
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error)
|
||||
expect(result.current.error?.message).toBe('Clipboard write failed')
|
||||
})
|
||||
|
||||
it('should set error if clipboard is not supported', async () => {
|
||||
const originalClipboard = navigator.clipboard
|
||||
// @ts-ignore
|
||||
delete navigator.clipboard
|
||||
|
||||
const { result } = renderHook(() => useClipboard())
|
||||
|
||||
await act(async () => {
|
||||
result.current.copy('Test text')
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error)
|
||||
expect(result.current.error?.message).toBe(
|
||||
'useClipboard: navigator.clipboard is not supported'
|
||||
)
|
||||
|
||||
// Restore clipboard support
|
||||
Object.assign(navigator, { clipboard: originalClipboard })
|
||||
})
|
||||
|
||||
it('should reset copied state after timeout', async () => {
|
||||
const { result } = renderHook(() => useClipboard({ timeout: 1000 }))
|
||||
|
||||
await act(async () => {
|
||||
result.current.copy('Test text')
|
||||
})
|
||||
|
||||
expect(result.current.copied).toBe(true)
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
expect(result.current.copied).toBe(false)
|
||||
})
|
||||
|
||||
it('should reset state when reset is called', async () => {
|
||||
const { result } = renderHook(() => useClipboard())
|
||||
|
||||
await act(async () => {
|
||||
result.current.copy('Test text')
|
||||
})
|
||||
|
||||
expect(result.current.copied).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
|
||||
expect(result.current.copied).toBe(false)
|
||||
expect(result.current.error).toBe(null)
|
||||
})
|
||||
})
|
||||
90
joi/src/hooks/useMediaQuery/useMediaQuery.test.ts
Normal file
90
joi/src/hooks/useMediaQuery/useMediaQuery.test.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useMediaQuery } from './index'
|
||||
|
||||
describe('@joi/hooks/useMediaQuery', () => {
|
||||
const matchMediaMock = jest.fn()
|
||||
|
||||
beforeAll(() => {
|
||||
window.matchMedia = matchMediaMock
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
matchMediaMock.mockClear()
|
||||
})
|
||||
|
||||
it('should return initial value when getInitialValueInEffect is true', () => {
|
||||
matchMediaMock.mockImplementation(() => ({
|
||||
matches: true,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMediaQuery('(min-width: 768px)', true, {
|
||||
getInitialValueInEffect: true,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should return correct value based on media query', () => {
|
||||
matchMediaMock.mockImplementation(() => ({
|
||||
matches: true,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'))
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should update value when media query changes', () => {
|
||||
let listener: ((event: { matches: boolean }) => void) | null = null
|
||||
|
||||
matchMediaMock.mockImplementation(() => ({
|
||||
matches: false,
|
||||
addEventListener: (_, cb) => {
|
||||
listener = cb
|
||||
},
|
||||
removeEventListener: jest.fn(),
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'))
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
if (listener) {
|
||||
listener({ matches: true })
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle older browsers without addEventListener', () => {
|
||||
let listener: ((event: { matches: boolean }) => void) | null = null
|
||||
|
||||
matchMediaMock.mockImplementation(() => ({
|
||||
matches: false,
|
||||
addListener: (cb) => {
|
||||
listener = cb
|
||||
},
|
||||
removeListener: jest.fn(),
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'))
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
if (listener) {
|
||||
listener({ matches: true })
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
})
|
||||
39
joi/src/hooks/useOs/useOs.test.ts
Normal file
39
joi/src/hooks/useOs/useOs.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useOs } from './index'
|
||||
|
||||
const platforms = {
|
||||
windows: [
|
||||
'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0',
|
||||
],
|
||||
macos: [
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0',
|
||||
],
|
||||
linux: [
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36',
|
||||
],
|
||||
ios: [
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1',
|
||||
],
|
||||
android: [
|
||||
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36',
|
||||
],
|
||||
undetermined: ['UNKNOWN'],
|
||||
} as const
|
||||
|
||||
describe('@joi/hooks/useOS', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
Object.entries(platforms).forEach(([os, userAgents]) => {
|
||||
it.each(userAgents)(`should detect %s platform on ${os}`, (userAgent) => {
|
||||
jest
|
||||
.spyOn(window.navigator, 'userAgent', 'get')
|
||||
.mockReturnValueOnce(userAgent)
|
||||
|
||||
const { result } = renderHook(() => useOs())
|
||||
|
||||
expect(result.current).toBe(os)
|
||||
})
|
||||
})
|
||||
})
|
||||
32
joi/src/hooks/usePageLeave/usePageLeave.test.ts
Normal file
32
joi/src/hooks/usePageLeave/usePageLeave.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import { usePageLeave } from './index'
|
||||
|
||||
describe('@joi/hooks/usePageLeave', () => {
|
||||
it('should call onPageLeave when mouse leaves the document', () => {
|
||||
const onPageLeaveMock = jest.fn()
|
||||
const { result } = renderHook(() => usePageLeave(onPageLeaveMock))
|
||||
|
||||
fireEvent.mouseLeave(document.documentElement)
|
||||
|
||||
expect(onPageLeaveMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should remove event listener on unmount', () => {
|
||||
const onPageLeaveMock = jest.fn()
|
||||
const removeEventListenerSpy = jest.spyOn(
|
||||
document.documentElement,
|
||||
'removeEventListener'
|
||||
)
|
||||
|
||||
const { unmount } = renderHook(() => usePageLeave(onPageLeaveMock))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'mouseleave',
|
||||
expect.any(Function)
|
||||
)
|
||||
removeEventListenerSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
56
joi/src/hooks/useTextSelection/useTextSelection.test.ts
Normal file
56
joi/src/hooks/useTextSelection/useTextSelection.test.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useTextSelection } from './index'
|
||||
|
||||
describe('@joi/hooks/useTextSelection', () => {
|
||||
let mockSelection: Selection
|
||||
|
||||
beforeEach(() => {
|
||||
mockSelection = {
|
||||
toString: jest.fn(),
|
||||
removeAllRanges: jest.fn(),
|
||||
addRange: jest.fn(),
|
||||
} as unknown as Selection
|
||||
|
||||
jest.spyOn(document, 'getSelection').mockReturnValue(mockSelection)
|
||||
jest.spyOn(document, 'addEventListener')
|
||||
jest.spyOn(document, 'removeEventListener')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should return the initial selection', () => {
|
||||
const { result } = renderHook(() => useTextSelection())
|
||||
expect(result.current).toBe(mockSelection)
|
||||
})
|
||||
|
||||
it('should add and remove event listener', () => {
|
||||
const { unmount } = renderHook(() => useTextSelection())
|
||||
|
||||
expect(document.addEventListener).toHaveBeenCalledWith(
|
||||
'selectionchange',
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(document.removeEventListener).toHaveBeenCalledWith(
|
||||
'selectionchange',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should update selection when selectionchange event is triggered', () => {
|
||||
const { result } = renderHook(() => useTextSelection())
|
||||
|
||||
const newMockSelection = { toString: jest.fn() } as unknown as Selection
|
||||
jest.spyOn(document, 'getSelection').mockReturnValue(newMockSelection)
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('selectionchange'))
|
||||
})
|
||||
|
||||
expect(result.current).toBe(newMockSelection)
|
||||
})
|
||||
})
|
||||
@ -3,6 +3,7 @@
|
||||
"target": "esnext",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist/types",
|
||||
"types": ["jest", "@testing-library/jest-dom"],
|
||||
"module": "esnext",
|
||||
"lib": ["es6", "dom", "es2016", "es2017"],
|
||||
"sourceMap": true,
|
||||
|
||||
@ -19,7 +19,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "yarn workspace jan lint && yarn workspace @janhq/web lint",
|
||||
"test:unit": "yarn workspace @janhq/core test",
|
||||
"test:unit": "jest",
|
||||
"test:coverage": "jest --coverage --collectCoverageFrom='src/**/*.{ts,tsx}'",
|
||||
"test": "yarn workspace jan test:e2e",
|
||||
"test-local": "yarn lint && yarn build:test && yarn test",
|
||||
"pre-install:darwin": "find extensions -type f -path \"**/*.tgz\" -exec cp {} pre-install \\;",
|
||||
|
||||
@ -12,9 +12,12 @@ import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { localEngines } from '@/utils/modelEngine'
|
||||
|
||||
import { mainViewStateAtom, showLeftPanelAtom } from '@/helpers/atoms/App.atom'
|
||||
import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom'
|
||||
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
|
||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
import {
|
||||
reduceTransparentAtom,
|
||||
selectedSettingAtom,
|
||||
@ -28,6 +31,7 @@ export default function RibbonPanel() {
|
||||
const matches = useMediaQuery('(max-width: 880px)')
|
||||
const reduceTransparent = useAtomValue(reduceTransparentAtom)
|
||||
const setSelectedSetting = useSetAtom(selectedSettingAtom)
|
||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||
|
||||
const onMenuClick = (state: MainViewState) => {
|
||||
if (mainViewState === state) return
|
||||
@ -37,6 +41,10 @@ export default function RibbonPanel() {
|
||||
setEditMessage('')
|
||||
}
|
||||
|
||||
const isDownloadALocalModel = downloadedModels.some((x) =>
|
||||
localEngines.includes(x.engine)
|
||||
)
|
||||
|
||||
const RibbonNavMenus = [
|
||||
{
|
||||
name: 'Thread',
|
||||
@ -77,7 +85,10 @@ export default function RibbonPanel() {
|
||||
'border-none',
|
||||
!showLeftPanel && !reduceTransparent && 'border-none',
|
||||
matches && !reduceTransparent && 'border-none',
|
||||
reduceTransparent && ' bg-[hsla(var(--ribbon-panel-bg))]'
|
||||
reduceTransparent && ' bg-[hsla(var(--ribbon-panel-bg))]',
|
||||
mainViewState === MainViewState.Thread &&
|
||||
!isDownloadALocalModel &&
|
||||
'border-none'
|
||||
)}
|
||||
>
|
||||
{RibbonNavMenus.filter((menu) => !!menu).map((menu, i) => {
|
||||
|
||||
@ -46,6 +46,16 @@ const BaseLayout = () => {
|
||||
}
|
||||
}, [setMainViewState])
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI?.onMainViewStateChange(
|
||||
(_event: string, route: string) => {
|
||||
if (route === 'Settings') {
|
||||
setMainViewState(MainViewState.Settings)
|
||||
}
|
||||
}
|
||||
)
|
||||
}, [setMainViewState])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
import Image from 'next/image'
|
||||
|
||||
import { InferenceEngine } from '@janhq/core'
|
||||
import { InferenceEngine, Model } from '@janhq/core'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@ -28,6 +28,7 @@ import ModelLabel from '@/containers/ModelLabel'
|
||||
|
||||
import SetupRemoteModel from '@/containers/SetupRemoteModel'
|
||||
|
||||
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
|
||||
import useDownloadModel from '@/hooks/useDownloadModel'
|
||||
import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
|
||||
import useRecommendedModel from '@/hooks/useRecommendedModel'
|
||||
@ -92,6 +93,8 @@ const ModelDropdown = ({
|
||||
)
|
||||
const preserveModelSettings = useAtomValue(preserveModelSettingsAtom)
|
||||
|
||||
const { updateThreadMetadata } = useCreateNewThread()
|
||||
|
||||
useClickOutside(() => !filterOptionsOpen && setOpen(false), null, [
|
||||
dropdownOptions,
|
||||
toggle,
|
||||
@ -101,6 +104,13 @@ const ModelDropdown = ({
|
||||
showEngineListModelAtom
|
||||
)
|
||||
|
||||
const isModelSupportRagAndTools = useCallback((model: Model) => {
|
||||
return (
|
||||
model?.engine === InferenceEngine.openai ||
|
||||
localEngines.includes(model?.engine as InferenceEngine)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const filteredDownloadedModels = useMemo(
|
||||
() =>
|
||||
configuredModels
|
||||
@ -161,6 +171,26 @@ const ModelDropdown = ({
|
||||
setOpen(false)
|
||||
|
||||
if (activeThread) {
|
||||
// Change assistand tools based on model support RAG
|
||||
updateThreadMetadata({
|
||||
...activeThread,
|
||||
assistants: [
|
||||
{
|
||||
...activeThread.assistants[0],
|
||||
tools: [
|
||||
{
|
||||
type: 'retrieval',
|
||||
enabled: isModelSupportRagAndTools(model as Model),
|
||||
settings: {
|
||||
...(activeThread.assistants[0].tools &&
|
||||
activeThread.assistants[0].tools[0]?.settings),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Default setting ctx_len for the model for a better onboarding experience
|
||||
// TODO: When Cortex support hardware instructions, we should remove this
|
||||
const defaultContextLength = preserveModelSettings
|
||||
@ -201,8 +231,10 @@ const ModelDropdown = ({
|
||||
downloadedModels,
|
||||
activeThread,
|
||||
setSelectedModel,
|
||||
isModelSupportRagAndTools,
|
||||
setThreadModelParams,
|
||||
updateModelParameter,
|
||||
updateThreadMetadata,
|
||||
preserveModelSettings,
|
||||
]
|
||||
)
|
||||
@ -433,158 +465,73 @@ const ModelDropdown = ({
|
||||
|
||||
{engine === InferenceEngine.nitro &&
|
||||
!isDownloadALocalModel &&
|
||||
showModel && (
|
||||
<>
|
||||
{!searchText.length ? (
|
||||
<ul className="pb-2">
|
||||
{featuredModel.map((model) => {
|
||||
const isDownloading = downloadingModels.some(
|
||||
(md) => md.id === model.id
|
||||
)
|
||||
return (
|
||||
<li
|
||||
key={model.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
|
||||
showModel &&
|
||||
!searchText.length && (
|
||||
<ul className="pb-2">
|
||||
{featuredModel.map((model) => {
|
||||
const isDownloading = downloadingModels.some(
|
||||
(md) => md.id === model.id
|
||||
)
|
||||
return (
|
||||
<li
|
||||
key={model.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className="line-clamp-1 text-[hsla(var(--text-secondary))]"
|
||||
title={model.name}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className="line-clamp-1 text-[hsla(var(--text-secondary))]"
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</p>
|
||||
<ModelLabel
|
||||
metadata={model.metadata}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||
<span className="font-medium">
|
||||
{toGibibytes(model.metadata.size)}
|
||||
</span>
|
||||
{!isDownloading ? (
|
||||
<DownloadCloudIcon
|
||||
size={18}
|
||||
className="cursor-pointer text-[hsla(var(--app-link))]"
|
||||
onClick={() => downloadModel(model)}
|
||||
/>
|
||||
) : (
|
||||
Object.values(downloadStates)
|
||||
.filter((x) => x.modelId === model.id)
|
||||
.map((item) => (
|
||||
<ProgressCircle
|
||||
key={item.modelId}
|
||||
percentage={
|
||||
formatDownloadPercentage(
|
||||
item?.percent,
|
||||
{
|
||||
hidePercentage: true,
|
||||
}
|
||||
) as number
|
||||
{model.name}
|
||||
</p>
|
||||
<ModelLabel
|
||||
metadata={model.metadata}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||
<span className="font-medium">
|
||||
{toGibibytes(model.metadata.size)}
|
||||
</span>
|
||||
{!isDownloading ? (
|
||||
<DownloadCloudIcon
|
||||
size={18}
|
||||
className="cursor-pointer text-[hsla(var(--app-link))]"
|
||||
onClick={() => downloadModel(model)}
|
||||
/>
|
||||
) : (
|
||||
Object.values(downloadStates)
|
||||
.filter((x) => x.modelId === model.id)
|
||||
.map((item) => (
|
||||
<ProgressCircle
|
||||
key={item.modelId}
|
||||
percentage={
|
||||
formatDownloadPercentage(
|
||||
item?.percent,
|
||||
{
|
||||
hidePercentage: true,
|
||||
}
|
||||
size={100}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<>
|
||||
{filteredDownloadedModels
|
||||
.filter(
|
||||
(x) => x.engine === InferenceEngine.nitro
|
||||
)
|
||||
.filter((x) => {
|
||||
if (searchText.length === 0) {
|
||||
return downloadedModels.find(
|
||||
(c) => c.id === x.id
|
||||
)
|
||||
} else {
|
||||
return x
|
||||
}
|
||||
})
|
||||
.map((model) => {
|
||||
const isDownloading = downloadingModels.some(
|
||||
(md) => md.id === model.id
|
||||
)
|
||||
const isdDownloaded = downloadedModels.some(
|
||||
(c) => c.id === model.id
|
||||
)
|
||||
return (
|
||||
<li
|
||||
key={model.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
|
||||
onClick={() => {
|
||||
if (isdDownloaded) {
|
||||
onClickModelItem(model.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={twMerge(
|
||||
'line-clamp-1',
|
||||
!isdDownloaded &&
|
||||
'text-[hsla(var(--text-secondary))]'
|
||||
)}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</p>
|
||||
<ModelLabel
|
||||
metadata={model.metadata}
|
||||
compact
|
||||
) as number
|
||||
}
|
||||
size={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||
{!isdDownloaded && (
|
||||
<span className="font-medium">
|
||||
{toGibibytes(model.metadata.size)}
|
||||
</span>
|
||||
)}
|
||||
{!isDownloading && !isdDownloaded ? (
|
||||
<DownloadCloudIcon
|
||||
size={18}
|
||||
className="cursor-pointer text-[hsla(var(--app-link))]"
|
||||
onClick={() => downloadModel(model)}
|
||||
/>
|
||||
) : (
|
||||
Object.values(downloadStates)
|
||||
.filter(
|
||||
(x) => x.modelId === model.id
|
||||
)
|
||||
.map((item) => (
|
||||
<ProgressCircle
|
||||
key={item.modelId}
|
||||
percentage={
|
||||
formatDownloadPercentage(
|
||||
item?.percent,
|
||||
{
|
||||
hidePercentage: true,
|
||||
}
|
||||
) as number
|
||||
}
|
||||
size={100}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ul className="pb-2">
|
||||
{filteredDownloadedModels
|
||||
.filter((x) => x.engine === engine)
|
||||
.filter((y) => {
|
||||
if (localEngines.includes(y.engine)) {
|
||||
if (
|
||||
localEngines.includes(y.engine) &&
|
||||
!searchText.length
|
||||
) {
|
||||
return downloadedModels.find((c) => c.id === y.id)
|
||||
} else {
|
||||
return y
|
||||
@ -592,11 +539,17 @@ const ModelDropdown = ({
|
||||
})
|
||||
.map((model) => {
|
||||
if (!showModel) return null
|
||||
const isDownloading = downloadingModels.some(
|
||||
(md) => md.id === model.id
|
||||
)
|
||||
const isdDownloaded = downloadedModels.some(
|
||||
(c) => c.id === model.id
|
||||
)
|
||||
return (
|
||||
<li
|
||||
key={model.id}
|
||||
className={twMerge(
|
||||
'cursor-pointer px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]',
|
||||
'flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]',
|
||||
!apiKey
|
||||
? 'cursor-not-allowed text-[hsla(var(--text-tertiary))]'
|
||||
: 'text-[hsla(var(--text-primary))]'
|
||||
@ -607,13 +560,54 @@ const ModelDropdown = ({
|
||||
!localEngines.includes(model.engine)
|
||||
)
|
||||
return null
|
||||
onClickModelItem(model.id)
|
||||
if (isdDownloaded) {
|
||||
onClickModelItem(model.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-shrink-0 gap-x-2">
|
||||
<p className="line-clamp-1 " title={model.name}>
|
||||
<div className="flex gap-x-2">
|
||||
<p
|
||||
className={twMerge(
|
||||
'line-clamp-1',
|
||||
!isdDownloaded &&
|
||||
'text-[hsla(var(--text-secondary))]'
|
||||
)}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</p>
|
||||
<ModelLabel metadata={model.metadata} compact />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||
{!isdDownloaded && (
|
||||
<span className="font-medium">
|
||||
{toGibibytes(model.metadata.size)}
|
||||
</span>
|
||||
)}
|
||||
{!isDownloading && !isdDownloaded ? (
|
||||
<DownloadCloudIcon
|
||||
size={18}
|
||||
className="cursor-pointer text-[hsla(var(--app-link))]"
|
||||
onClick={() => downloadModel(model)}
|
||||
/>
|
||||
) : (
|
||||
Object.values(downloadStates)
|
||||
.filter((x) => x.modelId === model.id)
|
||||
.map((item) => (
|
||||
<ProgressCircle
|
||||
key={item.modelId}
|
||||
percentage={
|
||||
formatDownloadPercentage(
|
||||
item?.percent,
|
||||
{
|
||||
hidePercentage: true,
|
||||
}
|
||||
) as number
|
||||
}
|
||||
size={100}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@ -1,24 +1,33 @@
|
||||
import { Fragment, PropsWithChildren, useEffect } from 'react'
|
||||
import { Fragment, PropsWithChildren, useEffect, useRef } from 'react'
|
||||
|
||||
import { useMediaQuery } from '@janhq/joi'
|
||||
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useAtom } from 'jotai'
|
||||
|
||||
import { showLeftPanelAtom, showRightPanelAtom } from '@/helpers/atoms/App.atom'
|
||||
|
||||
const Responsive = ({ children }: PropsWithChildren) => {
|
||||
const matches = useMediaQuery('(max-width: 880px)')
|
||||
const setShowLeftPanel = useSetAtom(showLeftPanelAtom)
|
||||
const setShowRightPanel = useSetAtom(showRightPanelAtom)
|
||||
const [showLeftPanel, setShowLeftPanel] = useAtom(showLeftPanelAtom)
|
||||
const [showRightPanel, setShowRightPanel] = useAtom(showRightPanelAtom)
|
||||
|
||||
// Refs to store the last known state of the panels
|
||||
const lastLeftPanelState = useRef<boolean>(true)
|
||||
const lastRightPanelState = useRef<boolean>(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (matches) {
|
||||
// Store the last known state before closing the panels
|
||||
lastLeftPanelState.current = showLeftPanel
|
||||
lastRightPanelState.current = showRightPanel
|
||||
|
||||
setShowLeftPanel(false)
|
||||
setShowRightPanel(false)
|
||||
} else {
|
||||
setShowLeftPanel(true)
|
||||
setShowRightPanel(true)
|
||||
// Restore the last known state when the screen is resized back
|
||||
setShowLeftPanel(lastLeftPanelState.current)
|
||||
setShowRightPanel(lastRightPanelState.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [matches, setShowLeftPanel, setShowRightPanel])
|
||||
|
||||
return <Fragment>{children}</Fragment>
|
||||
|
||||
@ -28,8 +28,10 @@ const SliderRightPanel = ({
|
||||
onValueChanged,
|
||||
}: Props) => {
|
||||
const [showTooltip, setShowTooltip] = useState({ max: false, min: false })
|
||||
const [val, setVal] = useState(value.toString())
|
||||
|
||||
useClickOutside(() => setShowTooltip({ max: false, min: false }), null, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 flex items-center gap-x-2">
|
||||
@ -48,7 +50,10 @@ const SliderRightPanel = ({
|
||||
<div className="relative w-full">
|
||||
<Slider
|
||||
value={[value]}
|
||||
onValueChange={(e) => onValueChanged?.(e[0])}
|
||||
onValueChange={(e) => {
|
||||
onValueChanged?.(e[0])
|
||||
setVal(e[0].toString())
|
||||
}}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
@ -63,24 +68,29 @@ const SliderRightPanel = ({
|
||||
open={showTooltip.max || showTooltip.min}
|
||||
trigger={
|
||||
<Input
|
||||
type="number"
|
||||
type="text"
|
||||
className="-mt-4 h-8 w-[60px]"
|
||||
min={min}
|
||||
max={max}
|
||||
value={String(value)}
|
||||
value={val}
|
||||
disabled={disabled}
|
||||
textAlign="right"
|
||||
onBlur={(e) => {
|
||||
if (Number(e.target.value) > Number(max)) {
|
||||
onValueChanged?.(Number(max))
|
||||
setVal(max.toString())
|
||||
setShowTooltip({ max: true, min: false })
|
||||
} else if (Number(e.target.value) < Number(min)) {
|
||||
onValueChanged?.(Number(min))
|
||||
setVal(min.toString())
|
||||
setShowTooltip({ max: false, min: true })
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
onValueChanged?.(Number(e.target.value))
|
||||
onValueChanged?.(e.target.value)
|
||||
if (/^\d*\.?\d*$/.test(e.target.value)) {
|
||||
setVal(e.target.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HuggingFaceRepoData } from '@janhq/core/.'
|
||||
import { HuggingFaceRepoData } from '@janhq/core'
|
||||
import { atom } from 'jotai'
|
||||
|
||||
// modals
|
||||
|
||||
@ -1,32 +1,317 @@
|
||||
import { memo } from 'react'
|
||||
import React, { Fragment, useState } from 'react'
|
||||
|
||||
import { Button } from '@janhq/joi'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { InferenceEngine } from '@janhq/core'
|
||||
import { Button, Input, Progress, ScrollArea } from '@janhq/joi'
|
||||
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { SearchIcon, DownloadCloudIcon } from 'lucide-react'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import LogoMark from '@/containers/Brand/Logo/Mark'
|
||||
import CenterPanelContainer from '@/containers/CenterPanelContainer'
|
||||
|
||||
import ProgressCircle from '@/containers/Loader/ProgressCircle'
|
||||
|
||||
import ModelLabel from '@/containers/ModelLabel'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
import useDownloadModel from '@/hooks/useDownloadModel'
|
||||
|
||||
const EmptyModel = () => {
|
||||
import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
|
||||
|
||||
import { formatDownloadPercentage, toGibibytes } from '@/utils/converter'
|
||||
import {
|
||||
getLogoEngine,
|
||||
getTitleByEngine,
|
||||
localEngines,
|
||||
} from '@/utils/modelEngine'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
import {
|
||||
configuredModelsAtom,
|
||||
getDownloadingModelAtom,
|
||||
} from '@/helpers/atoms/Model.atom'
|
||||
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
|
||||
|
||||
type Props = {
|
||||
extensionHasSettings: {
|
||||
name?: string
|
||||
setting: string
|
||||
apiKey: string
|
||||
provider: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const OnDeviceStarterScreen = ({ extensionHasSettings }: Props) => {
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const downloadingModels = useAtomValue(getDownloadingModelAtom)
|
||||
const { downloadModel } = useDownloadModel()
|
||||
const downloadStates = useAtomValue(modelDownloadStateAtom)
|
||||
const setSelectedSetting = useSetAtom(selectedSettingAtom)
|
||||
|
||||
const configuredModels = useAtomValue(configuredModelsAtom)
|
||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||
|
||||
const featuredModel = configuredModels.filter((x) =>
|
||||
x.metadata.tags.includes('Featured')
|
||||
)
|
||||
|
||||
const remoteModel = configuredModels.filter(
|
||||
(x) => !localEngines.includes(x.engine)
|
||||
)
|
||||
|
||||
const filteredModels = configuredModels.filter((model) => {
|
||||
return (
|
||||
localEngines.includes(model.engine) &&
|
||||
model.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const remoteModelEngine = remoteModel.map((x) => x.engine)
|
||||
const groupByEngine = remoteModelEngine.filter(function (item, index) {
|
||||
if (remoteModelEngine.indexOf(item) === index) return item
|
||||
})
|
||||
|
||||
const itemsPerRow = 5
|
||||
|
||||
const getRows = (array: string[], itemsPerRow: number) => {
|
||||
const rows = []
|
||||
for (let i = 0; i < array.length; i += itemsPerRow) {
|
||||
rows.push(array.slice(i, i + itemsPerRow))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const rows = getRows(groupByEngine, itemsPerRow)
|
||||
|
||||
const [visibleRows, setVisibleRows] = useState(1)
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center text-center">
|
||||
<LogoMark className="mx-auto mb-4 animate-wave" width={56} height={56} />
|
||||
<h1 className="text-base font-semibold">Welcome!</h1>
|
||||
<p className="mt-1 text-[hsla(var(--text-secondary))]">
|
||||
You need to download your first model
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => setMainViewState(MainViewState.Hub)}
|
||||
>
|
||||
Explore The Hub
|
||||
</Button>
|
||||
</div>
|
||||
<CenterPanelContainer>
|
||||
<ScrollArea className="flex h-full w-full items-center">
|
||||
<div className="relative mt-4 flex h-full w-full flex-col items-center justify-center">
|
||||
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center py-16 text-center">
|
||||
<LogoMark
|
||||
className="mx-auto mb-4 animate-wave"
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
<h1 className="text-base font-semibold">Select a model to start</h1>
|
||||
<div className="mt-6 w-full lg:w-1/2">
|
||||
<Fragment>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
placeholder="Search..."
|
||||
prefixIcon={<SearchIcon size={16} />}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
'absolute left-0 top-10 max-h-[240px] w-full overflow-x-auto rounded-lg border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))]',
|
||||
!searchValue.length ? 'invisible' : 'visible'
|
||||
)}
|
||||
>
|
||||
{!filteredModels.length ? (
|
||||
<div className="p-3 text-center">
|
||||
<p className="line-clamp-1 text-[hsla(var(--text-secondary))]">
|
||||
No Result Found
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredModels.map((model) => {
|
||||
const isDownloading = downloadingModels.some(
|
||||
(md) => md.id === model.id
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={twMerge('line-clamp-1')}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</p>
|
||||
<ModelLabel metadata={model.metadata} compact />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||
<span className="font-medium">
|
||||
{toGibibytes(model.metadata.size)}
|
||||
</span>
|
||||
{!isDownloading ? (
|
||||
<DownloadCloudIcon
|
||||
size={18}
|
||||
className="cursor-pointer text-[hsla(var(--app-link))]"
|
||||
onClick={() => downloadModel(model)}
|
||||
/>
|
||||
) : (
|
||||
Object.values(downloadStates)
|
||||
.filter((x) => x.modelId === model.id)
|
||||
.map((item) => (
|
||||
<ProgressCircle
|
||||
key={item.modelId}
|
||||
percentage={
|
||||
formatDownloadPercentage(
|
||||
item?.percent,
|
||||
{
|
||||
hidePercentage: true,
|
||||
}
|
||||
) as number
|
||||
}
|
||||
size={100}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<h2 className="text-[hsla(var(--text-secondary))]">
|
||||
On-device Models
|
||||
</h2>
|
||||
<p
|
||||
className="cursor-pointer text-sm text-[hsla(var(--text-secondary))]"
|
||||
onClick={() => {
|
||||
setMainViewState(MainViewState.Hub)
|
||||
}}
|
||||
>
|
||||
See All
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{featuredModel.slice(0, 2).map((featModel) => {
|
||||
const isDownloading = downloadingModels.some(
|
||||
(md) => md.id === featModel.id
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={featModel.id}
|
||||
className="my-2 flex items-center justify-between gap-2 border-b border-[hsla(var(--app-border))] py-4 last:border-none"
|
||||
>
|
||||
<div className="w-full text-left">
|
||||
<h6>{featModel.name}</h6>
|
||||
<p className="mt-1 text-[hsla(var(--text-secondary))]">
|
||||
{featModel.metadata.author}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isDownloading ? (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{Object.values(downloadStates).map((item, i) => (
|
||||
<div
|
||||
className="flex w-full items-center gap-2"
|
||||
key={i}
|
||||
>
|
||||
<Progress
|
||||
className="w-full"
|
||||
value={
|
||||
formatDownloadPercentage(item?.percent, {
|
||||
hidePercentage: true,
|
||||
}) as number
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<div className="flex gap-x-2">
|
||||
<span className="font-medium text-[hsla(var(--primary-bg))]">
|
||||
{formatDownloadPercentage(item?.percent)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
theme="ghost"
|
||||
className="!bg-[hsla(var(--secondary-bg))]"
|
||||
onClick={() => downloadModel(featModel)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="mb-4 mt-8 flex items-center justify-between">
|
||||
<h2 className="text-[hsla(var(--text-secondary))]">
|
||||
Cloud Models
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center gap-6">
|
||||
{rows.slice(0, visibleRows).map((row, rowIndex) => {
|
||||
return (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className="my-2 flex items-center justify-normal gap-10"
|
||||
>
|
||||
{row.map((remoteEngine) => {
|
||||
const engineLogo = getLogoEngine(
|
||||
remoteEngine as InferenceEngine
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer flex-col items-center justify-center gap-4"
|
||||
key={remoteEngine}
|
||||
onClick={() => {
|
||||
setMainViewState(MainViewState.Settings)
|
||||
setSelectedSetting(
|
||||
extensionHasSettings.find((x) =>
|
||||
x.name?.toLowerCase().includes(remoteEngine)
|
||||
)?.setting as string
|
||||
)
|
||||
}}
|
||||
>
|
||||
{engineLogo && (
|
||||
<Image
|
||||
width={48}
|
||||
height={48}
|
||||
src={engineLogo}
|
||||
alt="Engine logo"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<p>
|
||||
{getTitleByEngine(
|
||||
remoteEngine as InferenceEngine
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{visibleRows < rows.length && (
|
||||
<button
|
||||
onClick={() => setVisibleRows(visibleRows + 1)}
|
||||
className="mt-4 text-[hsla(var(--text-secondary))]"
|
||||
>
|
||||
See More
|
||||
</button>
|
||||
)}
|
||||
</Fragment>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CenterPanelContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(EmptyModel)
|
||||
export default OnDeviceStarterScreen
|
||||
|
||||
@ -11,18 +11,15 @@ import ChatItem from '../ChatItem'
|
||||
|
||||
import LoadModelError from '../LoadModelError'
|
||||
|
||||
import EmptyModel from './EmptyModel'
|
||||
import EmptyThread from './EmptyThread'
|
||||
|
||||
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
|
||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const ChatBody = () => {
|
||||
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||
|
||||
const loadModelError = useAtomValue(loadModelErrorAtom)
|
||||
|
||||
if (!downloadedModels.length) return <EmptyModel />
|
||||
if (!messages.length) return <EmptyThread />
|
||||
|
||||
return (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { MessageStatus } from '@janhq/core'
|
||||
import { InferenceEngine, MessageStatus } from '@janhq/core'
|
||||
|
||||
import { TextArea, Button, Tooltip, useClickOutside, Badge } from '@janhq/joi'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
@ -24,12 +24,15 @@ import { useActiveModel } from '@/hooks/useActiveModel'
|
||||
|
||||
import useSendChatMessage from '@/hooks/useSendChatMessage'
|
||||
|
||||
import { localEngines } from '@/utils/modelEngine'
|
||||
|
||||
import FileUploadPreview from '../FileUploadPreview'
|
||||
import ImageUploadPreview from '../ImageUploadPreview'
|
||||
|
||||
import { showRightPanelAtom } from '@/helpers/atoms/App.atom'
|
||||
import { experimentalFeatureEnabledAtom } from '@/helpers/atoms/AppConfig.atom'
|
||||
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
|
||||
import { selectedModelAtom } from '@/helpers/atoms/Model.atom'
|
||||
import { spellCheckAtom } from '@/helpers/atoms/Setting.atom'
|
||||
import {
|
||||
activeSettingInputBoxAtom,
|
||||
@ -53,6 +56,7 @@ const ChatInput = () => {
|
||||
activeSettingInputBoxAtom
|
||||
)
|
||||
const { sendChatMessage } = useSendChatMessage()
|
||||
const selectedModel = useAtomValue(selectedModelAtom)
|
||||
|
||||
const activeThreadId = useAtomValue(getActiveThreadIdAtom)
|
||||
const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage)
|
||||
@ -124,6 +128,10 @@ const ChatInput = () => {
|
||||
stopInference()
|
||||
}
|
||||
|
||||
const isModelSupportRagAndTools =
|
||||
selectedModel?.engine === InferenceEngine.openai ||
|
||||
localEngines.includes(selectedModel?.engine as InferenceEngine)
|
||||
|
||||
/**
|
||||
* Handles the change event of the extension file input element by setting the file name state.
|
||||
* Its to be used to display the extension file name of the selected file.
|
||||
@ -198,6 +206,7 @@ const ChatInput = () => {
|
||||
</Button>
|
||||
}
|
||||
disabled={
|
||||
isModelSupportRagAndTools &&
|
||||
activeThread?.assistants[0].tools &&
|
||||
activeThread?.assistants[0].tools[0]?.enabled
|
||||
}
|
||||
@ -217,12 +226,16 @@ const ChatInput = () => {
|
||||
)}
|
||||
{activeThread?.assistants[0].tools &&
|
||||
activeThread?.assistants[0].tools[0]?.enabled ===
|
||||
false && (
|
||||
false &&
|
||||
isModelSupportRagAndTools && (
|
||||
<span>
|
||||
Turn on Retrieval in Assistant Settings to use
|
||||
this feature.
|
||||
Turn on Retrieval in Tools settings to use this
|
||||
feature
|
||||
</span>
|
||||
)}
|
||||
{!isModelSupportRagAndTools && (
|
||||
<span>Not supported for this model</span>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
|
||||
import { SettingComponentProps, SliderComponentProps } from '@janhq/core/.'
|
||||
import {
|
||||
InferenceEngine,
|
||||
SettingComponentProps,
|
||||
SliderComponentProps,
|
||||
} from '@janhq/core'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@ -24,6 +28,7 @@ import { useCreateNewThread } from '@/hooks/useCreateNewThread'
|
||||
import useUpdateModelParameters from '@/hooks/useUpdateModelParameters'
|
||||
|
||||
import { getConfigurationsData } from '@/utils/componentSettings'
|
||||
import { localEngines } from '@/utils/modelEngine'
|
||||
import { toRuntimeParams, toSettingParams } from '@/utils/modelParam'
|
||||
|
||||
import PromptTemplateSetting from './PromptTemplateSetting'
|
||||
@ -39,6 +44,10 @@ import {
|
||||
|
||||
import { activeTabThreadRightPanelAtom } from '@/helpers/atoms/ThreadRightPanel.atom'
|
||||
|
||||
const INFERENCE_SETTINGS = 'Inference Settings'
|
||||
const MODEL_SETTINGS = 'Model Settings'
|
||||
const ENGINE_SETTINGS = 'Engine Settings'
|
||||
|
||||
const ThreadRightPanel = () => {
|
||||
const activeThread = useAtomValue(activeThreadAtom)
|
||||
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
|
||||
@ -49,6 +58,10 @@ const ThreadRightPanel = () => {
|
||||
const { updateThreadMetadata } = useCreateNewThread()
|
||||
const experimentalFeature = useAtomValue(experimentalFeatureEnabledAtom)
|
||||
|
||||
const isModelSupportRagAndTools =
|
||||
selectedModel?.engine === InferenceEngine.openai ||
|
||||
localEngines.includes(selectedModel?.engine as InferenceEngine)
|
||||
|
||||
const setEngineParamsUpdate = useSetAtom(engineParamsUpdateAtom)
|
||||
const { stopModel } = useActiveModel()
|
||||
const { updateModelParameter } = useUpdateModelParameters()
|
||||
@ -189,7 +202,16 @@ const ThreadRightPanel = () => {
|
||||
options={[
|
||||
{ name: 'Assistant', value: 'assistant' },
|
||||
{ name: 'Model', value: 'model' },
|
||||
...(experimentalFeature ? [{ name: 'Tools', value: 'tools' }] : []),
|
||||
...(experimentalFeature
|
||||
? [
|
||||
{
|
||||
name: 'Tools',
|
||||
value: 'tools',
|
||||
disabled: !isModelSupportRagAndTools,
|
||||
tooltipContent: 'Not supported for this model',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
value={activeTabThreadRightPanel as string}
|
||||
onValueChange={(value) => setActiveTabThreadRightPanel(value)}
|
||||
@ -221,8 +243,8 @@ const ThreadRightPanel = () => {
|
||||
<Accordion defaultValue={[]}>
|
||||
{settings.runtimeSettings.length !== 0 && (
|
||||
<AccordionItem
|
||||
title="Inference Parameters"
|
||||
value="Inference Parameters"
|
||||
title={INFERENCE_SETTINGS}
|
||||
value={INFERENCE_SETTINGS}
|
||||
>
|
||||
<ModelSetting
|
||||
componentProps={settings.runtimeSettings}
|
||||
@ -232,16 +254,13 @@ const ThreadRightPanel = () => {
|
||||
)}
|
||||
|
||||
{promptTemplateSettings.length !== 0 && (
|
||||
<AccordionItem title="Model Parameters" value="Model Parameters">
|
||||
<AccordionItem title={MODEL_SETTINGS} value={MODEL_SETTINGS}>
|
||||
<PromptTemplateSetting componentData={promptTemplateSettings} />
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{settings.engineSettings.length !== 0 && (
|
||||
<AccordionItem
|
||||
title="Engine Parameters"
|
||||
value="Engine Parameters"
|
||||
>
|
||||
<AccordionItem title={ENGINE_SETTINGS} value={ENGINE_SETTINGS}>
|
||||
<EngineSetting
|
||||
componentData={settings.engineSettings}
|
||||
onValueChanged={onValueChanged}
|
||||
|
||||
@ -1,17 +1,92 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
|
||||
import ThreadLeftPanel from '@/screens/Thread/ThreadLeftPanel'
|
||||
|
||||
import { localEngines } from '@/utils/modelEngine'
|
||||
|
||||
import ThreadCenterPanel from './ThreadCenterPanel'
|
||||
import OnDeviceStarterScreen from './ThreadCenterPanel/ChatBody/EmptyModel'
|
||||
import ModalCleanThread from './ThreadLeftPanel/ModalCleanThread'
|
||||
import ModalDeleteThread from './ThreadLeftPanel/ModalDeleteThread'
|
||||
import ModalEditTitleThread from './ThreadLeftPanel/ModalEditTitleThread'
|
||||
import ThreadRightPanel from './ThreadRightPanel'
|
||||
|
||||
import { extensionManager } from '@/extension'
|
||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
import { threadsAtom } from '@/helpers/atoms/Thread.atom'
|
||||
|
||||
const ThreadScreen = () => {
|
||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||
const threads = useAtomValue(threadsAtom)
|
||||
|
||||
const isDownloadALocalModel = downloadedModels.some((x) =>
|
||||
localEngines.includes(x.engine)
|
||||
)
|
||||
|
||||
const [extensionHasSettings, setExtensionHasSettings] = useState<
|
||||
{ name?: string; setting: string; apiKey: string; provider: string }[]
|
||||
>([])
|
||||
|
||||
useEffect(() => {
|
||||
const getAllSettings = async () => {
|
||||
const extensionsMenu: {
|
||||
name?: string
|
||||
setting: string
|
||||
apiKey: string
|
||||
provider: string
|
||||
}[] = []
|
||||
const extensions = extensionManager.getAll()
|
||||
|
||||
for (const extension of extensions) {
|
||||
if (typeof extension.getSettings === 'function') {
|
||||
const settings = await extension.getSettings()
|
||||
|
||||
if (
|
||||
(settings && settings.length > 0) ||
|
||||
(await extension.installationState()) !== 'NotRequired'
|
||||
) {
|
||||
extensionsMenu.push({
|
||||
name: extension.productName,
|
||||
setting: extension.name,
|
||||
apiKey:
|
||||
'apiKey' in extension && typeof extension.apiKey === 'string'
|
||||
? extension.apiKey
|
||||
: '',
|
||||
provider:
|
||||
'provider' in extension &&
|
||||
typeof extension.provider === 'string'
|
||||
? extension.provider
|
||||
: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setExtensionHasSettings(extensionsMenu)
|
||||
}
|
||||
getAllSettings()
|
||||
}, [])
|
||||
|
||||
const isAnyRemoteModelConfigured = extensionHasSettings.some(
|
||||
(x) => x.apiKey.length > 1
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-1 overflow-x-hidden">
|
||||
<ThreadLeftPanel />
|
||||
<ThreadCenterPanel />
|
||||
<ThreadRightPanel />
|
||||
{!isAnyRemoteModelConfigured &&
|
||||
!isDownloadALocalModel &&
|
||||
!threads.length ? (
|
||||
<>
|
||||
<OnDeviceStarterScreen extensionHasSettings={extensionHasSettings} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ThreadLeftPanel />
|
||||
<ThreadCenterPanel />
|
||||
<ThreadRightPanel />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Showing variant modal action for thread screen */}
|
||||
<ModalEditTitleThread />
|
||||
|
||||
35
web/utils/Stack.test.ts
Normal file
35
web/utils/Stack.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
|
||||
import { Stack } from './Stack';
|
||||
|
||||
it('should return elements in reverse order', () => {
|
||||
const stack = new Stack<number>();
|
||||
stack.push(1);
|
||||
stack.push(2);
|
||||
stack.push(3);
|
||||
const reversedOutput = stack.reverseOutput();
|
||||
expect(reversedOutput).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
|
||||
it('should pop an element from the stack', () => {
|
||||
const stack = new Stack<number>();
|
||||
stack.push(1);
|
||||
const poppedElement = stack.pop();
|
||||
expect(poppedElement).toBe(1);
|
||||
expect(stack.isEmpty()).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
it('should push an element to the stack', () => {
|
||||
const stack = new Stack<number>();
|
||||
stack.push(1);
|
||||
expect(stack.isEmpty()).toBe(false);
|
||||
expect(stack.size()).toBe(1);
|
||||
expect(stack.peek()).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
it('should initialize as empty', () => {
|
||||
const stack = new Stack<number>();
|
||||
expect(stack.isEmpty()).toBe(true);
|
||||
});
|
||||
22
web/utils/componentSettings.test.ts
Normal file
22
web/utils/componentSettings.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
|
||||
import { getConfigurationsData } from './componentSettings';
|
||||
|
||||
it('should process checkbox setting', () => {
|
||||
const settings = { embedding: true };
|
||||
const result = getConfigurationsData(settings);
|
||||
expect(result[0].controllerProps.value).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
it('should process input setting and handle array input', () => {
|
||||
const settings = { prompt_template: ['Hello', 'World', ''] };
|
||||
const result = getConfigurationsData(settings);
|
||||
expect(result[0].controllerProps.value).toBe('Hello World ');
|
||||
});
|
||||
|
||||
|
||||
it('should return an empty array when settings object is empty', () => {
|
||||
const settings = {};
|
||||
const result = getConfigurationsData(settings);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
27
web/utils/datetime.test.ts
Normal file
27
web/utils/datetime.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
|
||||
import { displayDate } from './datetime';
|
||||
import { isToday } from './datetime';
|
||||
|
||||
test('should return only time for today\'s timestamp', () => {
|
||||
const today = new Date();
|
||||
const timestamp = today.getTime();
|
||||
const expectedTime = today.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
expect(displayDate(timestamp)).toBe(expectedTime);
|
||||
});
|
||||
|
||||
|
||||
test('should return N/A for undefined timestamp', () => {
|
||||
expect(displayDate()).toBe('N/A');
|
||||
});
|
||||
|
||||
|
||||
test('should return true for today\'s timestamp', () => {
|
||||
const today = new Date();
|
||||
const timestamp = today.setHours(0, 0, 0, 0);
|
||||
expect(isToday(timestamp)).toBe(true);
|
||||
});
|
||||
17
web/utils/jsonToCssVariables.test.ts
Normal file
17
web/utils/jsonToCssVariables.test.ts
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
import cssVars from './jsonToCssVariables';
|
||||
|
||||
test('should convert nested JSON object to CSS variables', () => {
|
||||
const input = { theme: { color: 'blue', font: { size: '14px', weight: 'bold' } } };
|
||||
const expectedOutput = '--theme-color: blue;--theme-font-size: 14px;--theme-font-weight: bold;';
|
||||
const result = cssVars(input);
|
||||
expect(result).toBe(expectedOutput);
|
||||
});
|
||||
|
||||
|
||||
test('should convert simple JSON object to CSS variables', () => {
|
||||
const input = { color: 'red', fontSize: '16px' };
|
||||
const expectedOutput = '--color: red;--fontSize: 16px;';
|
||||
const result = cssVars(input);
|
||||
expect(result).toBe(expectedOutput);
|
||||
});
|
||||
@ -1,5 +1,3 @@
|
||||
// @auto-generated
|
||||
|
||||
import { utilizedMemory } from './memory'
|
||||
|
||||
test('test_utilizedMemory_arbitraryValues', () => {
|
||||
|
||||
18
web/utils/predefinedComponent.test.ts
Normal file
18
web/utils/predefinedComponent.test.ts
Normal file
@ -0,0 +1,18 @@
|
||||
|
||||
import { presetConfiguration } from './predefinedComponent';
|
||||
|
||||
it('should have correct configuration for prompt_template', () => {
|
||||
const config = presetConfiguration['prompt_template'];
|
||||
expect(config).toEqual({
|
||||
key: 'prompt_template',
|
||||
title: 'Prompt template',
|
||||
description: `A predefined text or framework that guides the AI model's response generation. It includes placeholders or instructions for the model to fill in or expand upon.`,
|
||||
controllerType: 'input',
|
||||
controllerProps: {
|
||||
placeholder: 'Prompt template',
|
||||
value: '',
|
||||
},
|
||||
requireModelReload: true,
|
||||
configType: 'setting',
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import { SettingComponentProps } from '@janhq/core/.'
|
||||
import { SettingComponentProps } from '@janhq/core'
|
||||
|
||||
export const presetConfiguration: Record<string, SettingComponentProps> = {
|
||||
prompt_template: {
|
||||
|
||||
9
web/utils/thread.test.ts
Normal file
9
web/utils/thread.test.ts
Normal file
@ -0,0 +1,9 @@
|
||||
|
||||
import { generateThreadId } from './thread';
|
||||
|
||||
test('shouldGenerateThreadIdWithCorrectFormat', () => {
|
||||
const assistantId = 'assistant123';
|
||||
const threadId = generateThreadId(assistantId);
|
||||
const regex = /^assistant123_\d{10}$/;
|
||||
expect(threadId).toMatch(regex);
|
||||
});
|
||||
25
web/utils/titleUtils.test.ts
Normal file
25
web/utils/titleUtils.test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
import { openFileTitle } from './titleUtils';
|
||||
|
||||
test('should return "Open Containing Folder" when neither isMac nor isWindows is true', () => {
|
||||
(global as any).isMac = false;
|
||||
(global as any).isWindows = false;
|
||||
const result = openFileTitle();
|
||||
expect(result).toBe('Open Containing Folder');
|
||||
});
|
||||
|
||||
|
||||
test('should return "Show in File Explorer" when isWindows is true', () => {
|
||||
(global as any).isMac = false;
|
||||
(global as any).isWindows = true;
|
||||
const result = openFileTitle();
|
||||
expect(result).toBe('Show in File Explorer');
|
||||
});
|
||||
|
||||
|
||||
test('should return "Show in Finder" when isMac is true', () => {
|
||||
(global as any).isMac = true;
|
||||
(global as any).isWindows = false;
|
||||
const result = openFileTitle();
|
||||
expect(result).toBe('Show in Finder');
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user