Merge branch 'main' into add-guides

This commit is contained in:
Daniel 2023-11-03 17:44:05 +08:00 committed by GitHub
commit 888936843b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
133 changed files with 1927 additions and 4560 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
**/node_modules

View File

@ -52,7 +52,7 @@ jobs:
- name: Install yarn dependencies
run: |
yarn install
yarn build:plugins-darwin
yarn build:pull-plugins
env:
APP_PATH: "."
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
@ -104,7 +104,7 @@ jobs:
run: |
yarn config set network-timeout 300000
yarn install
yarn build:plugins
yarn build:pull-plugins
- name: Build and publish app
run: |
@ -153,7 +153,7 @@ jobs:
run: |
yarn config set network-timeout 300000
yarn install
yarn build:plugins
yarn build:pull-plugins
- name: Build and publish app
run: |

View File

@ -46,8 +46,8 @@ jobs:
yarn config set network-timeout 300000
yarn install
yarn lint
yarn build:plugins
yarn build
yarn build:pull-plugins
yarn build:test
yarn test
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
@ -76,9 +76,8 @@ jobs:
run: |
yarn config set network-timeout 300000
yarn install
yarn lint
yarn build:plugins
yarn build:win32
yarn build:pull-plugins
yarn build:test-win32
yarn test
test-on-ubuntu:
@ -105,7 +104,6 @@ jobs:
echo -e "Display ID: $DISPLAY"
yarn config set network-timeout 300000
yarn install
yarn lint
yarn build:plugins
yarn build:linux
yarn build:pull-plugins
yarn build:test-linux
yarn test

6
.gitignore vendored
View File

@ -16,3 +16,9 @@ package-lock.json
*.log
plugin-core/lib
core/lib/**
# Nitro binary files
plugins/inference-plugin/nitro/*/nitro
plugins/inference-plugin/nitro/*/*.exe
plugins/inference-plugin/nitro/*/*.dll
plugins/inference-plugin/nitro/*/*.metal

39
Dockerfile Normal file
View File

@ -0,0 +1,39 @@
FROM node:20-bullseye AS base
# 1. Install dependencies only when needed
FROM base AS deps
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN yarn install
# # 2. Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# This will do the trick, use the corresponding env file for each environment.
RUN yarn workspace server install
RUN yarn server:prod
# 3. Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# RUN addgroup -g 1001 -S nodejs;
COPY --from=builder /app/server/build ./
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder /app/server/node_modules ./node_modules
COPY --from=builder /app/server/package.json ./package.json
EXPOSE 4000 3928
ENV PORT 4000
ENV APPDATA /app/data
CMD ["node", "main.js"]

View File

@ -1,14 +0,0 @@
newadr:
@echo "Initiating an ADR..."
@last_number=$$(ls $(CURDIR)/adr-[0-9][0-9][0-9]-* | sort -V | tail -n 1 | cut -d '-' -f 2); \
last_number=$$(echo $$last_number | sed 's/^0*//'); \
next_number=$$(printf "%03d" $$(( $$last_number + 1 ))); \
read -p "Enter ADR title: " title; \
title=$$(echo $$title | tr ' ' '-'); \
cp $(CURDIR)/adr-template.md $(CURDIR)/adr-$$next_number-$$title.md; \
date=$$(date +%Y-%m-%d); \
usernames=$$(git config user.name); \
sed -i '' 's/{ADR-NUM}/'$$next_number'/g' $(CURDIR)/adr-$$next_number-$$title.md; \
sed -i '' 's/{TITLE}/'$$title'/g' $(CURDIR)/adr-$$next_number-$$title.md; \
sed -i '' 's/{DATE}/'$$date'/g' $(CURDIR)/adr-$$next_number-$$title.md; \
sed -i '' 's/{USERNAMES}/'$$usernames'/g' $(CURDIR)/adr-$$next_number-$$title.md

View File

@ -1,31 +0,0 @@
# ADR {ADR-NUM}: {TITLE}
## Changelog
- {DATE}: Initial draft
## Authors
- {USERNAMES}
## Status
What is the status, such as proposed, accepted, rejected, deprecated, superseded, etc.?
{Proposed|Accepted|Rejected}
## Context
What is the issue that we're seeing that is motivating this decision or change?
## Decision
What is the change that we're proposing and/or doing?
## Consequences
What becomes easier or more difficult to do because of this change?
## Alternatives
## Reference

View File

@ -28,6 +28,13 @@ const downloadFile: (url: string, fileName: string) => Promise<any> = (url, file
const deleteFile: (path: string) => Promise<any> = (path) =>
window.coreAPI?.deleteFile(path) ?? window.electronAPI?.deleteFile(path);
/**
* Retrieves the path to the app data directory using the `coreAPI` object.
* If the `coreAPI` object is not available, the function returns `undefined`.
* @returns A Promise that resolves with the path to the app data directory, or `undefined` if the `coreAPI` object is not available.
*/
const appDataPath: () => Promise<any> = () => window.coreAPI?.appDataPath();
/** Register extension point function type definition
*
*/
@ -46,9 +53,10 @@ export const core = {
invokePluginFunc,
downloadFile,
deleteFile,
appDataPath,
};
/**
* Functions exports
*/
export { invokePluginFunc, downloadFile, deleteFile };
export { invokePluginFunc, downloadFile, deleteFile, appDataPath };

View File

@ -1,6 +1,6 @@
{
"name": "@janhq/core",
"version": "0.1.8",
"version": "0.1.9",
"description": "Plugin core lib",
"keywords": [
"jan",

View File

@ -87,12 +87,19 @@ export default function Home() {
Run Your Own AI
</h1>
<p className="text-xl leading-relaxed lg:text-2xl lg:leading-relaxed text-gray-500 dark:text-gray-400">
Run Large Language Models locally on&nbsp;
{/* Run Large Language Models locally on&nbsp;
<span className="dark:text-white text-black">Mac</span>
,&nbsp;
<span className="dark:text-white text-black">Windows</span>
&nbsp;or&nbsp;
<span className="dark:text-white text-black">Linux</span>.
<span className="dark:text-white text-black">Linux</span>. */}
Jan is a powerful&nbsp;
<span className="dark:text-white text-black">Personal AI</span>
&nbsp;built to run locally on your machine,
<br/>
with a rich&nbsp;
<span className="dark:text-white text-black">app</span>&nbsp;and&nbsp;
<span className="dark:text-white text-black">plugin ecosystem</span>.
</p>
<div className="my-6 flex flex-col-reverse md:flex-row items-center justify-center gap-4 relative z-20">

View File

@ -1,116 +0,0 @@
import Ep from './ExtensionPoint'
/** @type {Ep} */
let ep
const changeListener = jest.fn()
const objectRsp = { foo: 'bar' }
const funcRsp = arr => {
arr || (arr = [])
arr.push({ foo: 'baz' })
return arr
}
beforeEach(() => {
ep = new Ep('test-ep')
ep.register('test-ext-obj', objectRsp)
ep.register('test-ext-func', funcRsp, 10)
ep.onRegister('test', changeListener)
})
it('should create a new extension point by providing a name', () => {
expect(ep.name).toEqual('test-ep')
})
it('should register extension with extension point', () => {
expect(ep._extensions).toContainEqual({
name: 'test-ext-func',
response: funcRsp,
priority: 10
})
})
it('should register extension with a default priority of 0 if not provided', () => {
expect(ep._extensions).toContainEqual({
name: 'test-ext-obj',
response: objectRsp,
priority: 0
})
})
it('should execute the change listeners on registering a new extension', () => {
changeListener.mockClear()
ep.register('test-change-listener', true)
expect(changeListener.mock.calls.length).toBeTruthy()
})
it('should unregister an extension with the provided name if it exists', () => {
ep.unregister('test-ext-obj')
expect(ep._extensions).not.toContainEqual(
expect.objectContaining({
name: 'test-ext-obj'
})
)
})
it('should not unregister any extensions if the provided name does not exist', () => {
ep.unregister('test-ext-invalid')
expect(ep._extensions.length).toBe(2)
})
it('should execute the change listeners on unregistering an extension', () => {
changeListener.mockClear()
ep.unregister('test-ext-obj')
expect(changeListener.mock.calls.length).toBeTruthy()
})
it('should empty the registry of all extensions on clearing', () => {
ep.clear()
expect(ep._extensions).toEqual([])
})
it('should execute the change listeners on clearing extensions', () => {
changeListener.mockClear()
ep.clear()
expect(changeListener.mock.calls.length).toBeTruthy()
})
it('should return the relevant extension using the get method', () => {
const ext = ep.get('test-ext-obj')
expect(ext).toEqual({ foo: 'bar' })
})
it('should return the false using the get method if the extension does not exist', () => {
const ext = ep.get('test-ext-invalid')
expect(ext).toBeUndefined()
})
it('should provide an array with all responses, including promises where necessary, using the execute method', async () => {
ep.register('test-ext-async', () => new Promise(resolve => setTimeout(resolve, 0, { foo: 'delayed' })))
const arr = ep.execute([])
const res = await Promise.all(arr)
expect(res).toContainEqual({ foo: 'bar' })
expect(res).toContainEqual([{ foo: 'baz' }])
expect(res).toContainEqual({ foo: 'delayed' })
expect(res.length).toBe(3)
})
it('should provide an array including all responses in priority order, using the executeSerial method provided with an array', async () => {
const res = await ep.executeSerial([])
expect(res).toEqual([{ "foo": "bar" }, { "foo": "baz" }])
})
it('should provide an array including the last response using the executeSerial method provided with something other than an array', async () => {
const res = await ep.executeSerial()
expect(res).toEqual([{ "foo": "baz" }])
})

View File

@ -1,22 +0,0 @@
import { setImporter } from "./import-manager"
import Plugin from './Plugin'
describe('triggerExport', () => {
it('should call the provided export on the plugin\'s main file', async () => {
// Set up mock importer with mock main plugin file
const mockExport = jest.fn()
const mockImporter = jest.fn(() => ({
lifeCycleFn: mockExport
}))
setImporter(mockImporter)
// Call triggerExport on new plugin
const plgUrl = 'main'
const plugin = new Plugin('test', plgUrl, ['ap1'], true)
await plugin.triggerExport('lifeCycleFn')
// Check results
expect(mockImporter.mock.lastCall).toEqual([plgUrl])
expect(mockExport.mock.calls.length).toBeTruthy()
})
})

View File

@ -1,307 +0,0 @@
import { setup } from './index'
import { register, trigger, remove, clear, get } from "./activation-manager";
import { add } from './extension-manager'
let mockPlugins = {}
setup({
importer(plugin) { return mockPlugins[plugin] }
})
afterEach(() => {
clear()
mockPlugins = {}
})
describe('register', () => {
it('should add a new activation point to the register when a new, valid plugin is registered',
() => {
register({
name: 'test',
url: 'testPkg',
activationPoints: ['ap1', 'ap2'],
active: true
})
expect(get()).toEqual([
{
plugin: 'test',
url: 'testPkg',
activationPoint: 'ap1',
activated: false
},
{
plugin: 'test',
url: 'testPkg',
activationPoint: 'ap2',
activated: false
}
])
}
)
it('should not add an activation point to the register when an existing, valid plugin is registered',
() => {
register({
name: 'test',
url: 'testPkg',
activationPoints: ['ap1', 'ap2'],
active: true
})
register({
name: 'test',
url: 'testPkg',
activationPoints: ['ap2', 'ap3'],
active: true
})
expect(get()).toEqual([
{
plugin: 'test',
url: 'testPkg',
activationPoint: 'ap1',
activated: false
},
{
plugin: 'test',
url: 'testPkg',
activationPoint: 'ap2',
activated: false
},
{
plugin: 'test',
url: 'testPkg',
activationPoint: 'ap3',
activated: false
},
])
}
)
it('should throw an error when an invalid plugin is registered',
() => {
const noActivationPoints = () => register({
name: 'test',
url: 'testPkg',
active: true
})
expect(noActivationPoints).toThrow(/does not have any activation points set up in its manifest/)
}
)
})
describe('trigger', () => {
it('should trigger all and only the activations with for the given execution point on triggering an execution, using the defined importer',
async () => {
const triggered = []
mockPlugins.plugin1 = {
ap1() { triggered.push('plugin1-ap1') }
}
mockPlugins.plugin2 = {
ap2() { triggered.push('plugin2-ap2') }
}
mockPlugins.plugin3 = {
ap1() { triggered.push('plugin3-ap1') },
ap2() { triggered.push('plugin3-ap2') }
}
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1'],
active: true
})
register({
name: 'plugin2',
url: 'plugin2',
activationPoints: ['ap2'],
active: true
})
register({
name: 'plugin3',
url: 'plugin3',
activationPoints: ['ap1', 'ap2'],
active: true
})
await trigger('ap1')
expect(triggered).toEqual(['plugin1-ap1', 'plugin3-ap1'])
}
)
it('should return an error if an activation point is triggered on a plugin that does not include it',
async () => {
mockPlugins.plugin1 = {
wrongAp() { }
}
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1']
})
await expect(() => trigger('ap1')).rejects.toThrow(/was triggered but does not exist on plugin/)
}
)
it('should provide the registered extension points to the triggered activation point if presetEPs is set to true in the setup',
async () => {
setup({
importer(plugin) { return mockPlugins[plugin] },
presetEPs: true,
})
let ap1Res
mockPlugins.plugin1 = {
ap1: eps => ap1Res = eps
}
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1']
})
add('ep1')
add('ep2')
await trigger('ap1')
expect(ap1Res.ep1.constructor.name).toEqual('ExtensionPoint')
expect(ap1Res.ep2.constructor.name).toEqual('ExtensionPoint')
}
)
it('should allow registration, execution and serial execution of execution points when an activation point is triggered if presetEPs is set to false in the setup',
async () => {
setup({
importer(plugin) { return mockPlugins[plugin] },
})
let ap1Res
mockPlugins.plugin1 = {
ap1: eps => ap1Res = eps
}
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1']
})
await trigger('ap1')
expect(typeof ap1Res.register).toBe('function')
expect(typeof ap1Res.execute).toBe('function')
expect(typeof ap1Res.executeSerial).toBe('function')
}
)
it('should not provide any reference to extension points during activation point triggering if presetEPs is set to null in the setup',
async () => {
setup({
importer(plugin) { return mockPlugins[plugin] },
presetEPs: null,
})
let ap1Res = true
mockPlugins.plugin1 = {
ap1: eps => ap1Res = eps
}
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1']
})
await trigger('ap1')
expect(ap1Res).not.toBeDefined()
}
)
})
describe('remove and clear', () => {
beforeEach(() => {
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1', 'ap2'],
active: true
})
register({
name: 'plugin2',
url: 'plugin2',
activationPoints: ['ap2', 'ap3'],
active: true
})
})
it('should remove all and only the activations for the given plugin from the register when removing activations',
() => {
remove('plugin1')
expect(get()).toEqual([
{
plugin: 'plugin2',
url: 'plugin2',
activationPoint: 'ap2',
activated: false
},
{
plugin: 'plugin2',
url: 'plugin2',
activationPoint: 'ap3',
activated: false
},
])
}
)
it('should not remove any activations from the register if no plugin name is provided',
() => {
remove()
expect(get()).toEqual([
{
plugin: 'plugin1',
url: 'plugin1',
activationPoint: 'ap1',
activated: false
},
{
plugin: 'plugin1',
url: 'plugin1',
activationPoint: 'ap2',
activated: false
},
{
plugin: 'plugin2',
url: 'plugin2',
activationPoint: 'ap2',
activated: false
},
{
plugin: 'plugin2',
url: 'plugin2',
activationPoint: 'ap3',
activated: false
},
])
}
)
it('should remove all activations from the register when clearing the register',
() => {
clear()
expect(get()).toEqual([])
}
)
})

View File

@ -1,116 +0,0 @@
import { add, remove, register, get, execute, executeSerial, unregisterAll } from './extension-manager'
import ExtensionPoint from './ExtensionPoint'
beforeEach(() => {
add('ep1')
add('ep2')
})
afterEach(() => {
remove('ep1')
remove('ep2')
remove('ep3')
})
describe('get', () => {
it('should return the extension point with the given name if it exists', () => {
expect(get('ep1')).toBeInstanceOf(ExtensionPoint)
})
it('should return all extension points if no name is provided', () => {
expect(get()).toEqual(expect.objectContaining({ ep1: expect.any(ExtensionPoint) }))
expect(get()).toEqual(expect.objectContaining({ ep2: expect.any(ExtensionPoint) }))
})
})
describe('Add and remove', () => {
it('should add a new extension point with the given name using the add function', () => {
add('ep1')
expect(get('ep1')).toBeInstanceOf(ExtensionPoint)
})
it('should remove only the extension point with the given name using the remove function', () => {
remove('ep1')
expect(get()).not.toEqual(expect.objectContaining({ ep1: expect.anything() }))
expect(get()).toEqual(expect.objectContaining({ ep2: expect.any(ExtensionPoint) }))
})
it('should not remove any extension points if no name is provided using the remove function', () => {
remove()
expect(get()).toEqual(expect.objectContaining({ ep1: expect.any(ExtensionPoint) }))
expect(get()).toEqual(expect.objectContaining({ ep2: expect.any(ExtensionPoint) }))
})
})
describe('register', () => {
it('should register an extension to an existing extension point if the point has already been created', () => {
register('ep1', 'extension1', { foo: 'bar' })
expect(get('ep1')._extensions).toContainEqual(expect.objectContaining({ name: 'extension1' }))
})
it('should create an extension point and register an extension to it if the point has not yet been created', () => {
register('ep3', 'extension1', { foo: 'bar' })
expect(get('ep3')._extensions).toContainEqual(expect.objectContaining({ name: 'extension1' }))
})
})
describe('unregisterAll', () => {
it('should unregister all extension points matching the give name regex', () => {
// Register example extensions
register('ep1', 'remove1', { foo: 'bar' })
register('ep2', 'remove2', { foo: 'bar' })
register('ep1', 'keep', { foo: 'bar' })
// Remove matching extensions
unregisterAll(/remove/)
// Extract all registered extensions
const eps = Object.values(get()).map(ep => ep._extensions)
const extensions = eps.flat()
// Test extracted extensions
expect(extensions).toContainEqual(expect.objectContaining({ name: 'keep' }))
expect(extensions).not.toContainEqual(expect.objectContaining({ name: 'ep1' }))
expect(extensions).not.toContainEqual(expect.objectContaining({ name: 'ep2' }))
})
})
describe('execute', () => {
it('should execute the extensions registered to the named extension point with the provided input', () => {
const result = []
register('ep1', 'extension1', input => result.push(input + 'bar'))
register('ep1', 'extension2', input => result.push(input + 'baz'))
execute('ep1', 'foo')
expect(result).toEqual(['foobar', 'foobaz'])
})
it('should throw an error if the named extension point does not exist', () => {
register('ep1', 'extension1', { foo: 'bar' })
expect(() => execute('ep3')).toThrow(/not a valid extension point/)
})
})
describe('executeSerial', () => {
it('should execute the extensions in serial registered to the named extension point with the provided input', async () => {
register('ep1', 'extension1', input => input + 'bar')
register('ep1', 'extension2', input => input + 'baz')
const result = await executeSerial('ep1', 'foo')
expect(result).toEqual('foobarbaz')
})
it('should throw an error if the named extension point does not exist', () => {
register('ep1', 'extension1', { foo: 'bar' })
expect(() => executeSerial('ep3')).toThrow(/not a valid extension point/)
})
})

View File

@ -1,28 +0,0 @@
import { setup } from "."
import { importer, presetEPs } from "./import-manager"
describe('setup', () => {
const mockImporter = jest.fn()
it('should store the importer function', () => {
setup({ importer: mockImporter })
expect(importer).toBe(mockImporter)
})
it('should set presetEPS to false if not provided', () => {
expect(presetEPs).toBe(false)
})
it('should set presetEPS to the provided value if it is true', () => {
setup({ presetEPs: true })
expect(presetEPs).toBe(true)
})
it('should set presetEPS to the provided value if it is null', () => {
setup({ presetEPs: null })
expect(presetEPs).toBe(null)
})
})

View File

@ -1,196 +0,0 @@
jest.mock('electron', () => {
const handlers = {}
return {
ipcMain: {
handle(channel, callback) {
handlers[channel] = callback
}
},
ipcRenderer: {
invoke(channel, ...args) {
return Promise.resolve(handlers[channel].call(undefined, 'event', ...args))
}
},
webContents: {
getAllWebContents: jest.fn(() => [])
},
contextBridge: {
exposeInMainWorld(key, val) {
global.window = { [key]: val }
}
}
}
})
jest.mock('../pluginMgr/store', () => {
const setActive = jest.fn(() => true)
const uninstall = jest.fn()
const update = jest.fn(() => true)
const isUpdateAvailable = jest.fn(() => false)
class Plugin {
constructor(name) {
this.name = name
this.activationPoints = ['test']
}
setActive = setActive
uninstall = uninstall
update = update
isUpdateAvailable = isUpdateAvailable
}
return {
getPlugin: jest.fn(name => new Plugin(name)),
getActivePlugins: jest.fn(() => [new Plugin('test')]),
installPlugins: jest.fn(async plugins => plugins.map(name => new Plugin(name))),
removePlugin: jest.fn()
}
})
const { rmSync } = require('fs')
const { webContents } = require('electron')
const useFacade = require('./index')
const { getActive, install, toggleActive, uninstall, update, updatesAvailable, registerActive } = require('../execution/facade')
const { setPluginsPath, setConfirmInstall } = require('../pluginMgr/globals')
const router = require('../pluginMgr/router')
const { getPlugin, getActivePlugins, removePlugin } = require('../pluginMgr/store')
const { get: getActivations } = require('../execution/activation-manager')
const pluginsPath = './testPlugins'
const confirmInstall = jest.fn(() => true)
beforeAll(async () => {
setPluginsPath(pluginsPath)
router()
useFacade()
})
afterAll(() => {
rmSync(pluginsPath, { recursive: true })
})
describe('install', () => {
it('should return cancelled state if the confirmPlugin callback returns falsy', async () => {
setConfirmInstall(() => false)
const plugins = await install(['test-install'])
expect(plugins).toEqual(false)
})
it('should perform a security check of the install using confirmInstall if facade is used', async () => {
setConfirmInstall(confirmInstall)
await install(['test-install'])
expect(confirmInstall.mock.calls.length).toBeTruthy()
})
it('should register all installed plugins', async () => {
const pluginName = 'test-install'
await install([pluginName])
expect(getActivations()).toContainEqual(expect.objectContaining({
plugin: pluginName
}))
})
it('should return a list of plugins', async () => {
setConfirmInstall(confirmInstall)
const pluginName = 'test-install'
const plugins = await install([pluginName])
expect(plugins).toEqual([expect.objectContaining({ name: pluginName })])
})
})
describe('uninstall', () => {
it('should uninstall all plugins with the provided name, remove it from the store and refresh all renderers', async () => {
// Reset mock functions
const mockUninstall = getPlugin().uninstall
mockUninstall.mockClear()
removePlugin.mockClear()
webContents.getAllWebContents.mockClear()
getPlugin.mockClear()
// Uninstall plugins
const specs = ['test-uninstall-1', 'test-uninstall-2']
await uninstall(specs)
// Test result
expect(getPlugin.mock.calls).toEqual(specs.map(spec => [spec]))
expect(mockUninstall.mock.calls.length).toBeTruthy()
expect(removePlugin.mock.calls.length).toBeTruthy()
expect(webContents.getAllWebContents.mock.calls.length).toBeTruthy()
})
})
describe('getActive', () => {
it('should return all active plugins', async () => {
getActivePlugins.mockClear()
await getActive()
expect(getActivePlugins.mock.calls.length).toBeTruthy()
})
})
describe('registerActive', () => {
it('should register all active plugins', async () => {
await registerActive()
expect(getActivations()).toContainEqual(expect.objectContaining({
plugin: 'test'
}))
})
})
describe('update', () => {
const specs = ['test-uninstall-1', 'test-uninstall-2']
const mockUpdate = getPlugin().update
beforeAll(async () => {
// Reset mock functions
mockUpdate.mockClear()
webContents.getAllWebContents.mockClear()
getPlugin.mockClear()
// Update plugins
await update(specs)
})
it('should call the update function on all provided plugins', async () => {
// Check result
expect(getPlugin.mock.calls).toEqual(specs.map(spec => [spec]))
expect(mockUpdate.mock.calls.length).toBe(2)
})
it('should reload the renderers if reload is true', () => {
expect(webContents.getAllWebContents.mock.calls.length).toBeTruthy()
})
it('should not reload the renderer if reload is false', async () => {
webContents.getAllWebContents.mockClear()
await update(['test-uninstall'], false)
expect(webContents.getAllWebContents.mock.calls.length).toBeFalsy()
})
})
describe('toggleActive', () => {
it('call the setActive function on the plugin with the provided name, with the provided active state', async () => {
await toggleActive('test-toggleActive', true)
expect(getPlugin.mock.lastCall).toEqual(['test-toggleActive'])
const mockSetActive = getPlugin().setActive
expect(mockSetActive.mock.lastCall).toEqual([true])
})
})
describe('updatesAvailable', () => {
it('should return the new versions for the provided plugins if provided', async () => {
// Reset mock functions
const mockIsUpdAvailable = getPlugin().isUpdateAvailable
mockIsUpdAvailable.mockClear()
getPlugin.mockClear()
// Get available updates
const testPlugin1 = 'test-plugin-1'
const testPlugin2 = 'test-update-2'
const updates = await updatesAvailable([testPlugin1, testPlugin2])
expect(updates).toEqual({
[testPlugin1]: false,
[testPlugin2]: false,
})
})
})

View File

@ -1,212 +0,0 @@
import { init } from "."
import { join } from 'path'
import Plugin from "./Plugin"
import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "fs"
const pluginsDir = './testPlugins'
const testPluginDir = './testPluginSrc'
const testPluginName = 'test-plugin'
const manifest = join(testPluginDir, 'package.json')
const main = 'index'
/** @type Plugin */
let plugin
beforeAll(() => {
init({
confirmInstall: () => true,
pluginsPath: pluginsDir,
})
mkdirSync(testPluginDir)
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
activationPoints: [],
main,
}), 'utf8')
plugin = new Plugin(testPluginDir)
})
afterAll(() => {
rmSync(pluginsDir, { recursive: true })
rmSync(testPluginDir, { recursive: true })
})
describe('subscribe', () => {
let res = false
it('should register the provided callback', () => {
plugin.subscribe('test', () => res = true)
plugin.setActive(true)
expect(res).toBeTruthy()
})
})
describe('unsubscribe', () => {
it(`should remove the provided callback from the register
after which it should not be executed anymore when the plugin is updated`, () => {
let res = false
plugin.subscribe('test', () => res = true)
plugin.unsubscribe('test')
plugin.setActive(true)
expect(res).toBeFalsy()
})
})
describe('install', () => {
beforeAll(async () => {
await plugin._install()
})
it('should store all the relevant manifest values on the plugin', async () => {
expect(plugin).toMatchObject({
origin: testPluginDir,
installOptions: {
version: false,
fullMetadata: false,
},
name: testPluginName,
url: `plugin://${testPluginName}/${main}`,
activationPoints: []
})
})
it('should create a folder for the plugin if it does not yet exist and copy the plugin files to it', () => {
expect(existsSync(join(pluginsDir, testPluginName))).toBeTruthy()
})
it('should replace the existing plugin files in the plugin folder if it already exist', async () => {
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
activationPoints: [],
main: 'updated',
}), 'utf8')
await plugin._install()
const savedPkg = JSON.parse(readFileSync(join(pluginsDir, testPluginName, 'package.json')))
expect(savedPkg.main).toBe('updated')
})
it('should throw an error and the plugin should be set to inactive if no manifest could be found', async () => {
rmSync(join(testPluginDir, 'package.json'))
await expect(() => plugin._install()).rejects.toThrow(/does not contain a valid manifest/)
})
it('should throw an error and the plugin should be set to inactive if plugin does not contain any activation points', async () => {
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
main,
}), 'utf8')
await expect(() => plugin._install()).rejects.toThrow('The plugin does not contain any activation points')
expect(plugin.active).toBe(false)
})
})
describe('update', () => {
let updatedPlugin
let subscription = false
let beforeUpd
beforeAll(async () => {
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
activationPoints: [],
version: '0.0.1',
main,
}), 'utf8')
await plugin._install()
plugin.subscribe('test', () => subscription = true)
beforeUpd = Object.assign({}, plugin)
await plugin.update()
})
it('should not do anything if no version update is available', () => {
expect(beforeUpd).toMatchObject(plugin)
})
it('should update the plugin files to the latest version if there is a new version available for the plugin', async () => {
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
activationPoints: [],
version: '0.0.2',
main,
}), 'utf8')
await plugin.update()
expect(plugin).toMatchObject({
origin: testPluginDir,
installOptions: {
version: false,
fullMetadata: false,
},
name: testPluginName,
version: '0.0.2',
url: `plugin://${testPluginName}/${main}`,
activationPoints: []
})
})
it('should execute callbacks subscribed to this plugin, providing the plugin as a parameter', () => {
expect(subscription).toBeTruthy()
})
})
describe('isUpdateAvailable', () => {
it('should return false if no new version is available', async () => {
await expect(plugin.isUpdateAvailable()).resolves.toBe(false)
})
it('should return the latest version number if a new version is available', async () => {
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
activationPoints: [],
version: '0.0.3',
main,
}), 'utf8')
await expect(plugin.isUpdateAvailable()).resolves.toBe('0.0.3')
})
})
describe('setActive', () => {
it('should set the plugin to be active', () => {
plugin.setActive(true)
expect(plugin.active).toBeTruthy()
})
it('should execute callbacks subscribed to this plugin, providing the plugin as a parameter', () => {
let res = false
plugin.subscribe('test', () => res = true)
plugin.setActive(true)
expect(res).toBeTruthy()
})
})
describe('uninstall', () => {
let subscription = false
beforeAll(async () => {
plugin.subscribe('test', () => subscription = true)
await plugin.uninstall()
})
it('should remove the installed plugin from the plugins folder', () => {
expect(existsSync(join(pluginsDir, testPluginName))).toBe(false)
})
it('should execute callbacks subscribed to this plugin, providing the plugin as a parameter', () => {
expect(subscription).toBeTruthy()
})
})

View File

@ -1,57 +0,0 @@
import { existsSync, mkdirSync, writeFileSync } from "fs"
import { join, resolve } from "path"
export let pluginsPath = null
/**
* @private
* Set path to plugins directory and create the directory if it does not exist.
* @param {string} plgPath path to plugins directory
*/
export function setPluginsPath(plgPath) {
// Create folder if it does not exist
let plgDir
try {
plgDir = resolve(plgPath)
if (plgDir.length < 2) throw new Error()
if (!existsSync(plgDir)) mkdirSync(plgDir)
const pluginsJson = join(plgDir, 'plugins.json')
if (!existsSync(pluginsJson)) writeFileSync(pluginsJson, '{}', 'utf8')
pluginsPath = plgDir
} catch (error) {
throw new Error('Invalid path provided to the plugins folder')
}
}
/**
* @private
* Get the path to the plugins.json file.
* @returns location of plugins.json
*/
export function getPluginsFile() { return join(pluginsPath, 'plugins.json') }
export let confirmInstall = function () {
return new Error(
'The facade.confirmInstall callback needs to be set in when initializing Pluggable Electron in the main process.'
)
}
/**
* @private
* Set callback to use as confirmInstall.
* @param {confirmInstall} cb Callback
*/
export function setConfirmInstall(cb) { confirmInstall = cb }
/**
* This function is executed when plugins are installed to verify that the user indeed wants to install the plugin.
* @callback confirmInstall
* @param {Array.<string>} plg The specifiers used to locate the packages (from NPM or local file)
* @returns {Promise<boolean>} Whether to proceed with the plugin installation
*/

View File

@ -1,150 +0,0 @@
import { usePlugins, getStore, init } from './index'
import { installPlugins, getPlugin, getAllPlugins, getActivePlugins, addPlugin, removePlugin } from './store'
import Plugin from './Plugin'
import { existsSync, rmSync, mkdirSync, writeFileSync } from 'fs'
import { join } from 'path'
import { protocol } from 'electron'
// Set up variables for test folders and test plugins.
const pluginDir = './testPlugins'
const registeredPluginName = 'registered-plugin'
const demoPlugin = {
origin: ".\\demo-plugin\\demo-plugin-1.5.0.tgz",
installOptions: {
version: false,
fullMetadata: false
},
name: "demoPlugin",
version: "1.5.0",
activationPoints: [
"init"
],
main: "index.js",
_active: true,
url: "plugin://demo-plugin/index.js"
}
describe('before setting a plugin path', () => {
describe('getStore', () => {
it('should throw an error if called without a plugin path set', () => {
expect(() => getStore()).toThrowError('The plugin path has not yet been set up. Please run usePlugins before accessing the store')
})
})
describe('usePlugins', () => {
it('should throw an error if called without a plugin path whilst no plugin path is set', () => {
expect(() => usePlugins()).toThrowError('A path to the plugins folder is required to use Pluggable Electron')
})
it('should throw an error if called with an invalid plugin path', () => {
expect(() => usePlugins('http://notsupported')).toThrowError('Invalid path provided to the plugins folder')
})
it('should create the plugin path if it does not yet exist', () => {
// Execute usePlugins with a folder that does not exist
const newPluginDir = './test-new-plugins'
usePlugins(newPluginDir)
expect(existsSync(newPluginDir)).toBe(true)
// Remove created folder to clean up
rmSync(newPluginDir, { recursive: true })
})
})
})
describe('after setting a plugin path', () => {
let pm
beforeAll(() => {
// Create folders to contain plugins
mkdirSync(pluginDir)
// Create initial
writeFileSync(join(pluginDir, 'plugins.json'), JSON.stringify({ demoPlugin }), 'utf8')
// Register a plugin before using plugins
const registeredPLugin = new Plugin(registeredPluginName)
registeredPLugin.name = registeredPluginName
addPlugin(registeredPLugin, false)
// Load plugins
pm = usePlugins(pluginDir)
})
afterAll(() => {
rmSync(pluginDir, { recursive: true })
})
describe('getStore', () => {
it('should return the plugin lifecycle functions if no plugin path is provided', () => {
expect(getStore()).toEqual({
installPlugins,
getPlugin,
getAllPlugins,
getActivePlugins,
removePlugin,
})
})
})
describe('usePlugins', () => {
it('should return the plugin lifecycle functions if a plugin path is provided', () => {
expect(pm).toEqual({
installPlugins,
getPlugin,
getAllPlugins,
getActivePlugins,
removePlugin,
})
})
it('should load the plugins defined in plugins.json in the provided plugins folder if a plugin path is provided', () => {
expect(getPlugin('demoPlugin')).toEqual(demoPlugin)
})
it('should unregister any registered plugins before registering the new ones if a plugin path is provided', () => {
expect(() => getPlugin(registeredPluginName)).toThrowError(`Plugin ${registeredPluginName} does not exist`)
})
})
})
describe('init', () => {
// Enabling the facade and registering the confirm install function is tested with the router.
let pm
beforeAll(() => {
// Create test plugins folder
mkdirSync(pluginDir)
// Initialize Pluggable Electron without a plugin folder
pm = init({ confirmInstall: () => true })
})
afterAll(() => {
// Remove test plugins folder
rmSync(pluginDir, { recursive: true })
})
it('should make the plugin files available through the plugin protocol', async () => {
expect(protocol.isProtocolRegistered('plugin')).toBeTruthy()
})
it('should return an empty object if no plugin path is provided', () => {
expect(pm).toEqual({})
})
it('should return the plugin lifecycle functions if a plugin path is provided', () => {
pm = init({
confirmInstall: () => true,
pluginsPath: pluginDir,
})
expect(pm).toEqual({
installPlugins,
getPlugin,
getAllPlugins,
getActivePlugins,
removePlugin,
})
})
})

View File

@ -1,91 +0,0 @@
import { ipcMain, webContents } from "electron"
import { getPlugin, getActivePlugins, installPlugins, removePlugin, getAllPlugins } from "./store"
import { pluginsPath, confirmInstall } from './globals'
// Throw an error if pluginsPath has not yet been provided by usePlugins.
const checkPluginsPath = () => {
if (!pluginsPath) throw Error('Path to plugins folder has not yet been set up.')
}
let active = false
/**
* Provide the renderer process access to the plugins.
**/
export default function () {
if (active) return
// Register IPC route to install a plugin
ipcMain.handle('pluggable:install', async (e, plugins) => {
checkPluginsPath()
// Validate install request from backend for security.
const specs = plugins.map(plg => typeof plg === 'object' ? plg.specifier : plg)
const conf = await confirmInstall(specs)
if (!conf) return { cancelled: true }
// Install and activate all provided plugins
const installed = await installPlugins(plugins)
return JSON.parse(JSON.stringify(installed))
})
// Register IPC route to uninstall a plugin
ipcMain.handle('pluggable:uninstall', async (e, plugins, reload) => {
checkPluginsPath()
// Uninstall all provided plugins
for (const plg of plugins) {
const plugin = getPlugin(plg)
await plugin.uninstall()
removePlugin(plugin.name)
}
// Reload all renderer pages if needed
reload && webContents.getAllWebContents().forEach(wc => wc.reload())
return true
})
// Register IPC route to update a plugin
ipcMain.handle('pluggable:update', (e, plugins, reload) => {
checkPluginsPath()
// Update all provided plugins
let updated = []
for (const plg of plugins) {
const plugin = getPlugin(plg)
const res = plugin.update()
if (res) updated.push(plugin)
}
// Reload all renderer pages if needed
if (updated.length && reload) webContents.getAllWebContents().forEach(wc => wc.reload())
return JSON.parse(JSON.stringify(updated))
})
// Register IPC route to check if updates are available for a plugin
ipcMain.handle('pluggable:updatesAvailable', (e, names) => {
checkPluginsPath()
const plugins = names ? names.map(name => getPlugin(name)) : getAllPlugins()
const updates = {}
for (const plugin of plugins) {
updates[plugin.name] = plugin.isUpdateAvailable()
}
return updates
})
// Register IPC route to get the list of active plugins
ipcMain.handle('pluggable:getActivePlugins', () => {
checkPluginsPath()
return JSON.parse(JSON.stringify(getActivePlugins()))
})
// Register IPC route to toggle the active state of a plugin
ipcMain.handle('pluggable:togglePluginActive', (e, plg, active) => {
checkPluginsPath()
const plugin = getPlugin(plg)
return JSON.parse(JSON.stringify(plugin.setActive(active)))
})
active = true
}

View File

@ -1,108 +0,0 @@
import { getActivePlugins, getAllPlugins, getPlugin, installPlugins } from './store'
import { init } from "."
import { join } from 'path'
import Plugin from "./Plugin"
import { mkdirSync, writeFileSync, rmSync } from "fs"
// Temporary directory to install plugins to
const pluginsDir = './testPlugins'
// Temporary directory containing the active plugin to install
const activePluginDir = './activePluginSrc'
const activePluginName = 'active-plugin'
const activeManifest = join(activePluginDir, 'package.json')
// Temporary directory containing the inactive plugin to install
const inactivePluginDir = './inactivePluginSrc'
const inactivePluginName = 'inactive-plugin'
const inactiveManifest = join(inactivePluginDir, 'package.json')
// Mock name for the entry file in the plugins
const main = 'index'
/** @type Array.<Plugin> */
let activePlugins
/** @type Array.<Plugin> */
let inactivePlugins
beforeAll(async () => {
// Initialize pluggable Electron
init({
confirmInstall: () => true,
pluginsPath: pluginsDir,
})
// Create active plugin
mkdirSync(activePluginDir)
writeFileSync(activeManifest, JSON.stringify({
name: activePluginName,
activationPoints: [],
main,
}), 'utf8')
// Create active plugin
mkdirSync(inactivePluginDir)
writeFileSync(inactiveManifest, JSON.stringify({
name: inactivePluginName,
activationPoints: [],
main,
}), 'utf8')
// Install plugins
activePlugins = await installPlugins([activePluginDir], true)
activePlugins[0].setActive(true)
inactivePlugins = await installPlugins([{
specifier: inactivePluginDir,
activate: false
}], true)
})
afterAll(() => {
// Remove all test files and folders
rmSync(pluginsDir, { recursive: true })
rmSync(activePluginDir, { recursive: true })
rmSync(inactivePluginDir, { recursive: true })
})
describe('installPlugins', () => {
it('should create a new plugin found at the given location and return it if store is false', async () => {
const res = await installPlugins([activePluginDir], false)
expect(res[0]).toBeInstanceOf(Plugin)
})
it('should create a new plugin found at the given location and register it if store is true', () => {
expect(activePlugins[0]).toBeInstanceOf(Plugin)
expect(getPlugin(activePluginName)).toBe(activePlugins[0])
})
it('should activate the installed plugin by default', () => {
expect(getPlugin(activePluginName).active).toBe(true)
})
it('should set plugin to inactive if activate is set to false in the install options', async () => {
expect(inactivePlugins[0].active).toBe(false)
})
})
describe('getPlugin', () => {
it('should return the plugin with the given name if it is registered', () => {
expect(getPlugin(activePluginName)).toBeInstanceOf(Plugin)
})
it('should return an error if the plugin with the given name is not registered', () => {
expect(() => getPlugin('wrongName')).toThrowError('Plugin wrongName does not exist')
})
})
describe('getAllPlugins', () => {
it('should return a list of all registered plugins', () => {
expect(getAllPlugins()).toEqual([activePlugins[0], inactivePlugins[0]])
})
})
describe('getActivePlugins', () => {
it('should return a list of all and only the registered plugins that are active', () => {
expect(getActivePlugins()).toEqual(activePlugins)
})
})

View File

@ -1,23 +1,23 @@
const { ipcRenderer, contextBridge } = require("electron");
function useFacade() {
export function useFacade() {
const interfaces = {
install(plugins) {
install(plugins: any[]) {
return ipcRenderer.invoke("pluggable:install", plugins);
},
uninstall(plugins, reload) {
uninstall(plugins: any[], reload: boolean) {
return ipcRenderer.invoke("pluggable:uninstall", plugins, reload);
},
getActive() {
return ipcRenderer.invoke("pluggable:getActivePlugins");
},
update(plugins, reload) {
update(plugins: any[], reload: boolean) {
return ipcRenderer.invoke("pluggable:update", plugins, reload);
},
updatesAvailable(plugin) {
updatesAvailable(plugin: any) {
return ipcRenderer.invoke("pluggable:updatesAvailable", plugin);
},
toggleActive(plugin, active) {
toggleActive(plugin: any, active: boolean) {
return ipcRenderer.invoke("pluggable:togglePluginActive", plugin, active);
},
};
@ -28,5 +28,3 @@ function useFacade() {
return interfaces;
}
module.exports = useFacade;

View File

@ -0,0 +1,36 @@
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { join, resolve } from "path";
export let pluginsPath: string | undefined = undefined;
/**
* @private
* Set path to plugins directory and create the directory if it does not exist.
* @param {string} plgPath path to plugins directory
*/
export function setPluginsPath(plgPath: string) {
// Create folder if it does not exist
let plgDir;
try {
plgDir = resolve(plgPath);
if (plgDir.length < 2) throw new Error();
if (!existsSync(plgDir)) mkdirSync(plgDir);
const pluginsJson = join(plgDir, "plugins.json");
if (!existsSync(pluginsJson)) writeFileSync(pluginsJson, "{}", "utf8");
pluginsPath = plgDir;
} catch (error) {
throw new Error("Invalid path provided to the plugins folder");
}
}
/**
* @private
* Get the path to the plugins.json file.
* @returns location of plugins.json
*/
export function getPluginsFile() {
return join(pluginsPath ?? "", "plugins.json");
}

View File

@ -1,11 +1,23 @@
import { readFileSync } from "fs"
import { protocol } from 'electron'
import { normalize } from "path"
import { readFileSync } from "fs";
import { protocol } from "electron";
import { normalize } from "path";
import Plugin from "./Plugin"
import { getAllPlugins, removePlugin, persistPlugins, installPlugins, getPlugin, getActivePlugins, addPlugin } from "./store"
import { pluginsPath as storedPluginsPath, setPluginsPath, getPluginsFile, setConfirmInstall } from './globals'
import router from "./router"
import Plugin from "./plugin";
import {
getAllPlugins,
removePlugin,
persistPlugins,
installPlugins,
getPlugin,
getActivePlugins,
addPlugin,
} from "./store";
import {
pluginsPath as storedPluginsPath,
setPluginsPath,
getPluginsFile,
} from "./globals";
import router from "./router";
/**
* Sets up the required communication between the main and renderer processes.
@ -17,24 +29,24 @@ import router from "./router"
* @returns {pluginManager|Object} A set of functions used to manage the plugin lifecycle if usePlugins is provided.
* @function
*/
export function init(options) {
if (!Object.prototype.hasOwnProperty.call(options, 'useFacade') || options.useFacade) {
// Store the confirmInstall function
setConfirmInstall(options.confirmInstall)
export function init(options: any) {
if (
!Object.prototype.hasOwnProperty.call(options, "useFacade") ||
options.useFacade
) {
// Enable IPC to be used by the facade
router()
router();
}
// Create plugins protocol to serve plugins to renderer
registerPluginProtocol()
registerPluginProtocol();
// perform full setup if pluginsPath is provided
if (options.pluginsPath) {
return usePlugins(options.pluginsPath)
return usePlugins(options.pluginsPath);
}
return {}
return {};
}
/**
@ -43,11 +55,11 @@ export function init(options) {
* @returns {boolean} Whether the protocol registration was successful
*/
function registerPluginProtocol() {
return protocol.registerFileProtocol('plugin', (request, callback) => {
const entry = request.url.substr(8)
const url = normalize(storedPluginsPath + entry)
callback({ path: url })
})
return protocol.registerFileProtocol("plugin", (request, callback) => {
const entry = request.url.substr(8);
const url = normalize(storedPluginsPath + entry);
callback({ path: url });
});
}
/**
@ -56,34 +68,38 @@ function registerPluginProtocol() {
* @param {string} pluginsPath Path to the plugins folder. Required if not yet set up.
* @returns {pluginManager} A set of functions used to manage the plugin lifecycle.
*/
export function usePlugins(pluginsPath) {
if (!pluginsPath) throw Error('A path to the plugins folder is required to use Pluggable Electron')
export function usePlugins(pluginsPath: string) {
if (!pluginsPath)
throw Error(
"A path to the plugins folder is required to use Pluggable Electron"
);
// Store the path to the plugins folder
setPluginsPath(pluginsPath)
setPluginsPath(pluginsPath);
// Remove any registered plugins
for (const plugin of getAllPlugins()) {
removePlugin(plugin.name, false)
if (plugin.name) removePlugin(plugin.name, false);
}
// Read plugin list from plugins folder
const plugins = JSON.parse(readFileSync(getPluginsFile()))
const plugins = JSON.parse(readFileSync(getPluginsFile(), "utf-8"));
try {
// Create and store a Plugin instance for each plugin in list
for (const p in plugins) {
loadPlugin(plugins[p])
loadPlugin(plugins[p]);
}
persistPlugins()
persistPlugins();
} catch (error) {
// Throw meaningful error if plugin loading fails
throw new Error('Could not successfully rebuild list of installed plugins.\n'
+ error
+ '\nPlease check the plugins.json file in the plugins folder.')
throw new Error(
"Could not successfully rebuild list of installed plugins.\n" +
error +
"\nPlease check the plugins.json file in the plugins folder."
);
}
// Return the plugin lifecycle functions
return getStore()
return getStore();
}
/**
@ -92,16 +108,24 @@ export function usePlugins(pluginsPath) {
* @private
* @param {Object} plg Plugin info
*/
function loadPlugin(plg) {
function loadPlugin(plg: any) {
// Create new plugin, populate it with plg details and save it to the store
const plugin = new Plugin()
const plugin = new Plugin();
for (const key in plg) {
plugin[key] = plg[key]
if (Object.prototype.hasOwnProperty.call(plg, key)) {
// Use Object.defineProperty to set the properties as writable
Object.defineProperty(plugin, key, {
value: plg[key],
writable: true,
enumerable: true,
configurable: true,
});
}
}
addPlugin(plugin, false)
plugin.subscribe('pe-persist', persistPlugins)
addPlugin(plugin, false);
plugin.subscribe("pe-persist", persistPlugins);
}
/**
@ -110,7 +134,9 @@ function loadPlugin(plg) {
*/
export function getStore() {
if (!storedPluginsPath) {
throw new Error('The plugin path has not yet been set up. Please run usePlugins before accessing the store')
throw new Error(
"The plugin path has not yet been set up. Please run usePlugins before accessing the store"
);
}
return {
@ -119,5 +145,5 @@ export function getStore() {
getAllPlugins,
getActivePlugins,
removePlugin,
}
};
}

View File

@ -1,9 +1,9 @@
import { rmdir } from "fs/promises"
import { resolve, join } from "path"
import { manifest, extract } from "pacote"
import Arborist from '@npmcli/arborist'
import { rmdir } from "fs/promises";
import { resolve, join } from "path";
import { manifest, extract } from "pacote";
import * as Arborist from "@npmcli/arborist";
import { pluginsPath } from "./globals"
import { pluginsPath } from "./globals";
/**
* An NPM package that can be used as a Pluggable Electron plugin.
@ -21,30 +21,39 @@ class Plugin {
* @property {string} description The description of plugin as defined in the manifest.
* @property {string} icon The icon of plugin as defined in the manifest.
*/
origin?: string;
installOptions: any;
name?: string;
url?: string;
version?: string;
activationPoints?: Array<string>;
main?: string;
description?: string;
icon?: string;
/** @private */
_active = false
_active = false;
/**
* @private
* @property {Object.<string, Function>} #listeners A list of callbacks to be executed when the Plugin is updated.
*/
#listeners = {}
listeners: Record<string, (obj: any) => void> = {};
/**
* Set installOptions with defaults for options that have not been provided.
* @param {string} [origin] Original specification provided to fetch the package.
* @param {Object} [options] Options provided to pacote when fetching the manifest.
*/
constructor(origin, options = {}) {
constructor(origin?: string, options = {}) {
const defaultOpts = {
version: false,
fullMetadata: false,
Arborist
}
Arborist,
};
this.origin = origin
this.installOptions = { ...defaultOpts, ...options }
this.origin = origin;
this.installOptions = { ...defaultOpts, ...options };
}
/**
@ -52,7 +61,10 @@ class Plugin {
* @type {string}
*/
get specifier() {
return this.origin + (this.installOptions.version ? '@' + this.installOptions.version : '')
return (
this.origin +
(this.installOptions.version ? "@" + this.installOptions.version : "")
);
}
/**
@ -60,31 +72,34 @@ class Plugin {
* @type {boolean}
*/
get active() {
return this._active
return this._active;
}
/**
* Set Package details based on it's manifest
* @returns {Promise.<Boolean>} Resolves to true when the action completed
*/
async #getManifest() {
async getManifest() {
// Get the package's manifest (package.json object)
try {
const mnf = await manifest(this.specifier, this.installOptions)
const mnf = await manifest(this.specifier, this.installOptions);
// set the Package properties based on the it's manifest
this.name = mnf.name
this.version = mnf.version
this.activationPoints = mnf.activationPoints || null
this.main = mnf.main
this.description = mnf.description
this.icon = mnf.icon
this.name = mnf.name;
this.version = mnf.version;
this.activationPoints = mnf.activationPoints
? (mnf.activationPoints as string[])
: undefined;
this.main = mnf.main;
this.description = mnf.description;
this.icon = mnf.icon as any;
} catch (error) {
throw new Error(`Package ${this.origin} does not contain a valid manifest: ${error}`)
throw new Error(
`Package ${this.origin} does not contain a valid manifest: ${error}`
);
}
return true
return true;
}
/**
@ -95,26 +110,29 @@ class Plugin {
async _install() {
try {
// import the manifest details
await this.#getManifest()
await this.getManifest();
// Install the package in a child folder of the given folder
await extract(this.specifier, join(pluginsPath, this.name), this.installOptions)
await extract(
this.specifier,
join(pluginsPath ?? "", this.name ?? ""),
this.installOptions
);
if (!Array.isArray(this.activationPoints))
throw new Error('The plugin does not contain any activation points')
throw new Error("The plugin does not contain any activation points");
// Set the url using the custom plugins protocol
this.url = `plugin://${this.name}/${this.main}`
this.#emitUpdate()
this.url = `plugin://${this.name}/${this.main}`;
this.emitUpdate();
} catch (err) {
// Ensure the plugin is not stored and the folder is removed if the installation fails
this.setActive(false)
throw err
this.setActive(false);
throw err;
}
return [this]
return [this];
}
/**
@ -122,24 +140,24 @@ class Plugin {
* @param {string} name name of the callback to register
* @param {callback} cb The function to execute on update
*/
subscribe(name, cb) {
this.#listeners[name] = cb
subscribe(name: string, cb: () => void) {
this.listeners[name] = cb;
}
/**
* Remove subscription
* @param {string} name name of the callback to remove
*/
unsubscribe(name) {
delete this.#listeners[name]
unsubscribe(name: string) {
delete this.listeners[name];
}
/**
* Execute listeners
*/
#emitUpdate() {
for (const cb in this.#listeners) {
this.#listeners[cb].call(null, this)
emitUpdate() {
for (const cb in this.listeners) {
this.listeners[cb].call(null, this);
}
}
@ -149,13 +167,13 @@ class Plugin {
* @returns {boolean} Whether an update was performed.
*/
async update(version = false) {
if (this.isUpdateAvailable()) {
this.installOptions.version = version
await this._install(false)
return true
if (await this.isUpdateAvailable()) {
this.installOptions.version = version;
await this._install();
return true;
}
return false
return false;
}
/**
@ -163,8 +181,10 @@ class Plugin {
* @returns the latest available version if a new version is available or false if not.
*/
async isUpdateAvailable() {
const mnf = await manifest(this.origin)
return mnf.version !== this.version ? mnf.version : false
if (this.origin) {
const mnf = await manifest(this.origin);
return mnf.version !== this.version ? mnf.version : false;
}
}
/**
@ -172,10 +192,10 @@ class Plugin {
* @returns {Promise}
*/
async uninstall() {
const plgPath = resolve(pluginsPath, this.name)
await rmdir(plgPath, { recursive: true })
const plgPath = resolve(pluginsPath ?? "", this.name ?? "");
await rmdir(plgPath, { recursive: true });
this.#emitUpdate()
this.emitUpdate();
}
/**
@ -183,11 +203,11 @@ class Plugin {
* @param {boolean} active State to set _active to
* @returns {Plugin} This plugin
*/
setActive(active) {
this._active = active
this.#emitUpdate()
return this
setActive(active: boolean) {
this._active = active;
this.emitUpdate();
return this;
}
}
export default Plugin
export default Plugin;

View File

@ -0,0 +1,97 @@
import { ipcMain, webContents } from "electron";
import {
getPlugin,
getActivePlugins,
installPlugins,
removePlugin,
getAllPlugins,
} from "./store";
import { pluginsPath } from "./globals";
import Plugin from "./plugin";
// Throw an error if pluginsPath has not yet been provided by usePlugins.
const checkPluginsPath = () => {
if (!pluginsPath)
throw Error("Path to plugins folder has not yet been set up.");
};
let active = false;
/**
* Provide the renderer process access to the plugins.
**/
export default function () {
if (active) return;
// Register IPC route to install a plugin
ipcMain.handle("pluggable:install", async (e, plugins) => {
checkPluginsPath();
// Install and activate all provided plugins
const installed = await installPlugins(plugins);
return JSON.parse(JSON.stringify(installed));
});
// Register IPC route to uninstall a plugin
ipcMain.handle("pluggable:uninstall", async (e, plugins, reload) => {
checkPluginsPath();
// Uninstall all provided plugins
for (const plg of plugins) {
const plugin = getPlugin(plg);
await plugin.uninstall();
if (plugin.name) removePlugin(plugin.name);
}
// Reload all renderer pages if needed
reload && webContents.getAllWebContents().forEach((wc) => wc.reload());
return true;
});
// Register IPC route to update a plugin
ipcMain.handle("pluggable:update", async (e, plugins, reload) => {
checkPluginsPath();
// Update all provided plugins
const updated: Plugin[] = [];
for (const plg of plugins) {
const plugin = getPlugin(plg);
const res = await plugin.update();
if (res) updated.push(plugin);
}
// Reload all renderer pages if needed
if (updated.length && reload)
webContents.getAllWebContents().forEach((wc) => wc.reload());
return JSON.parse(JSON.stringify(updated));
});
// Register IPC route to check if updates are available for a plugin
ipcMain.handle("pluggable:updatesAvailable", (e, names) => {
checkPluginsPath();
const plugins = names
? names.map((name: string) => getPlugin(name))
: getAllPlugins();
const updates: Record<string, Plugin> = {};
for (const plugin of plugins) {
updates[plugin.name] = plugin.isUpdateAvailable();
}
return updates;
});
// Register IPC route to get the list of active plugins
ipcMain.handle("pluggable:getActivePlugins", () => {
checkPluginsPath();
return JSON.parse(JSON.stringify(getActivePlugins()));
});
// Register IPC route to toggle the active state of a plugin
ipcMain.handle("pluggable:togglePluginActive", (e, plg, active) => {
checkPluginsPath();
const plugin = getPlugin(plg);
return JSON.parse(JSON.stringify(plugin.setActive(active)));
});
active = true;
}

View File

@ -8,9 +8,9 @@
* @prop {removePlugin} removePlugin
*/
import { writeFileSync } from "fs"
import Plugin from "./Plugin"
import { getPluginsFile } from './globals'
import { writeFileSync } from "fs";
import Plugin from "./plugin";
import { getPluginsFile } from "./globals";
/**
* @module store
@ -21,7 +21,7 @@ import { getPluginsFile } from './globals'
* Register of installed plugins
* @type {Object.<string, Plugin>} plugin - List of installed plugins
*/
const plugins = {}
const plugins: Record<string, Plugin> = {};
/**
* Get a plugin from the stored plugins.
@ -29,12 +29,12 @@ const plugins = {}
* @returns {Plugin} Retrieved plugin
* @alias pluginManager.getPlugin
*/
export function getPlugin(name) {
export function getPlugin(name: string) {
if (!Object.prototype.hasOwnProperty.call(plugins, name)) {
throw new Error(`Plugin ${name} does not exist`)
throw new Error(`Plugin ${name} does not exist`);
}
return plugins[name]
return plugins[name];
}
/**
@ -42,7 +42,9 @@ export function getPlugin(name) {
* @returns {Array.<Plugin>} All plugin objects
* @alias pluginManager.getAllPlugins
*/
export function getAllPlugins() { return Object.values(plugins) }
export function getAllPlugins() {
return Object.values(plugins);
}
/**
* Get list of active plugin objects.
@ -50,7 +52,7 @@ export function getAllPlugins() { return Object.values(plugins) }
* @alias pluginManager.getActivePlugins
*/
export function getActivePlugins() {
return Object.values(plugins).filter(plugin => plugin.active)
return Object.values(plugins).filter((plugin) => plugin.active);
}
/**
@ -60,10 +62,10 @@ export function getActivePlugins() {
* @returns {boolean} Whether the delete was successful
* @alias pluginManager.removePlugin
*/
export function removePlugin(name, persist = true) {
const del = delete plugins[name]
if (persist) persistPlugins()
return del
export function removePlugin(name: string, persist = true) {
const del = delete plugins[name];
if (persist) persistPlugins();
return del;
}
/**
@ -72,11 +74,11 @@ export function removePlugin(name, persist = true) {
* @param {boolean} persist Whether to save the changes to plugins to file
* @returns {void}
*/
export function addPlugin(plugin, persist = true) {
plugins[plugin.name] = plugin
export function addPlugin(plugin: Plugin, persist = true) {
if (plugin.name) plugins[plugin.name] = plugin;
if (persist) {
persistPlugins()
plugin.subscribe('pe-persist', persistPlugins)
persistPlugins();
plugin.subscribe("pe-persist", persistPlugins);
}
}
@ -85,11 +87,11 @@ export function addPlugin(plugin, persist = true) {
* @returns {void}
*/
export function persistPlugins() {
const persistData = {}
const persistData: Record<string, Plugin> = {};
for (const name in plugins) {
persistData[name] = plugins[name]
persistData[name] = plugins[name];
}
writeFileSync(getPluginsFile(), JSON.stringify(persistData), 'utf8')
writeFileSync(getPluginsFile(), JSON.stringify(persistData), "utf8");
}
/**
@ -99,26 +101,26 @@ export function persistPlugins() {
* @returns {Promise.<Array.<Plugin>>} New plugin
* @alias pluginManager.installPlugins
*/
export async function installPlugins(plugins, store = true) {
const installed = []
export async function installPlugins(plugins: any, store = true) {
const installed: Plugin[] = [];
for (const plg of plugins) {
// Set install options and activation based on input type
const isObject = typeof plg === 'object'
const spec = isObject ? [plg.specifier, plg] : [plg]
const activate = isObject ? plg.activate !== false : true
const isObject = typeof plg === "object";
const spec = isObject ? [plg.specifier, plg] : [plg];
const activate = isObject ? plg.activate !== false : true;
// Install and possibly activate plugin
const plugin = new Plugin(...spec)
await plugin._install()
if (activate) plugin.setActive(true)
const plugin = new Plugin(...spec);
await plugin._install();
if (activate) plugin.setActive(true);
// Add plugin to store if needed
if (store) addPlugin(plugin)
installed.push(plugin)
if (store) addPlugin(plugin);
installed.push(plugin);
}
// Return list of all installed plugins
return installed
return installed;
}
/**

View File

@ -9,7 +9,7 @@ import {
import { readdirSync, writeFileSync } from "fs";
import { resolve, join, extname } from "path";
import { rmdir, unlink, createWriteStream } from "fs";
import { init } from "./core/plugin-manager/pluginMgr";
import { init } from "./core/plugin/index";
import { setupMenu } from "./utils/menu";
import { dispose } from "./utils/disposable";
@ -19,7 +19,8 @@ const progress = require("request-progress");
const { autoUpdater } = require("electron-updater");
const Store = require("electron-store");
const requiredModules: Record<string, any> = {};
let requiredModules: Record<string, any> = {};
const networkRequests: Record<string, any> = {};
let mainWindow: BrowserWindow | undefined = undefined;
app
@ -39,32 +40,19 @@ app
});
app.on("window-all-closed", () => {
dispose(requiredModules);
clearImportedModules();
app.quit();
});
app.on("quit", () => {
dispose(requiredModules);
clearImportedModules();
app.quit();
});
ipcMain.handle("setNativeThemeLight", () => {
nativeTheme.themeSource = "light";
});
ipcMain.handle("setNativeThemeDark", () => {
nativeTheme.themeSource = "dark";
});
ipcMain.handle("setNativeThemeSystem", () => {
nativeTheme.themeSource = "system";
});
function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
frame: false,
show: false,
trafficLightPosition: {
x: 16,
@ -130,13 +118,39 @@ function handleAppUpdates() {
});
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
if (process.env.CI !== "e2e") {
autoUpdater.checkForUpdates();
}
}
/**
* Handles various IPC messages from the renderer process.
*/
function handleIPCs() {
/**
* Handles the "setNativeThemeLight" IPC message by setting the native theme source to "light".
* This will change the appearance of the app to the light theme.
*/
ipcMain.handle("setNativeThemeLight", () => {
nativeTheme.themeSource = "light";
});
/**
* Handles the "setNativeThemeDark" IPC message by setting the native theme source to "dark".
* This will change the appearance of the app to the dark theme.
*/
ipcMain.handle("setNativeThemeDark", () => {
nativeTheme.themeSource = "dark";
});
/**
* Handles the "setNativeThemeSystem" IPC message by setting the native theme source to "system".
* This will change the appearance of the app to match the system's current theme.
*/
ipcMain.handle("setNativeThemeSystem", () => {
nativeTheme.themeSource = "system";
});
/**
* Invokes a function from a plugin module in main node process.
* @param _event - The IPC event object.
@ -193,6 +207,15 @@ function handleIPCs() {
return join(app.getPath("userData"), "plugins");
});
/**
* Retrieves the path to the app data directory using the `coreAPI` object.
* If the `coreAPI` object is not available, the function returns `undefined`.
* @returns A Promise that resolves with the path to the app data directory, or `undefined` if the `coreAPI` object is not available.
*/
ipcMain.handle("appDataPath", async (_event) => {
return app.getPath("userData");
});
/**
* Returns the version of the app.
* @param _event - The IPC event object.
@ -202,6 +225,15 @@ function handleIPCs() {
return app.getVersion();
});
/**
* Handles the "openAppDirectory" IPC message by opening the app's user data directory.
* The `shell.openPath` method is used to open the directory in the user's default file explorer.
* @param _event - The IPC event object.
*/
ipcMain.handle("openAppDirectory", async (_event) => {
shell.openPath(app.getPath("userData"));
});
/**
* Opens a URL in the user's default browser.
* @param _event - The IPC event object.
@ -217,7 +249,7 @@ function handleIPCs() {
* @param url - The URL to reload.
*/
ipcMain.handle("relaunch", async (_event, url) => {
dispose(requiredModules);
clearImportedModules();
if (app.isPackaged) {
app.relaunch();
@ -246,7 +278,7 @@ function handleIPCs() {
rmdir(fullPath, { recursive: true }, function (err) {
if (err) console.log(err);
dispose(requiredModules);
clearImportedModules();
// just relaunch if packaged, should launch manually in development mode
if (app.isPackaged) {
@ -300,8 +332,9 @@ function handleIPCs() {
ipcMain.handle("downloadFile", async (_event, url, fileName) => {
const userDataPath = app.getPath("userData");
const destination = resolve(userDataPath, fileName);
const rq = request(url);
progress(request(url), {})
progress(rq, {})
.on("progress", function (state: any) {
mainWindow?.webContents.send("FILE_DOWNLOAD_UPDATE", {
...state,
@ -313,13 +346,54 @@ function handleIPCs() {
fileName,
err,
});
networkRequests[fileName] = undefined;
})
.on("end", function () {
if (networkRequests[fileName]) {
mainWindow?.webContents.send("FILE_DOWNLOAD_COMPLETE", {
fileName,
});
networkRequests[fileName] = undefined;
} else {
mainWindow?.webContents.send("FILE_DOWNLOAD_ERROR", {
fileName,
err: "Download cancelled",
});
}
})
.pipe(createWriteStream(destination));
networkRequests[fileName] = rq;
});
/**
* Handles the "pauseDownload" IPC message by pausing the download associated with the provided fileName.
* @param _event - The IPC event object.
* @param fileName - The name of the file being downloaded.
*/
ipcMain.handle("pauseDownload", async (_event, fileName) => {
networkRequests[fileName]?.pause();
});
/**
* Handles the "resumeDownload" IPC message by resuming the download associated with the provided fileName.
* @param _event - The IPC event object.
* @param fileName - The name of the file being downloaded.
*/
ipcMain.handle("resumeDownload", async (_event, fileName) => {
networkRequests[fileName]?.resume();
});
/**
* Handles the "abortDownload" IPC message by aborting the download associated with the provided fileName.
* The network request associated with the fileName is then removed from the networkRequests object.
* @param _event - The IPC event object.
* @param fileName - The name of the file being downloaded.
*/
ipcMain.handle("abortDownload", async (_event, fileName) => {
const rq = networkRequests[fileName];
networkRequests[fileName] = undefined;
rq?.abort();
});
/**
@ -385,3 +459,8 @@ function setupPlugins() {
pluginsPath: join(app.getPath("userData"), "plugins"),
});
}
function clearImportedModules() {
dispose(requiredModules);
requiredModules = {};
}

View File

@ -53,6 +53,10 @@
"test:e2e": "playwright test --workers=1",
"dev": "tsc -p . && electron .",
"build": "tsc -p . && electron-builder -p never -m",
"build:test": "tsc -p . && electron-builder --dir -p never -m",
"build:test-darwin": "tsc -p . && electron-builder -p never -m --x64 --arm64 --dir",
"build:test-win32": "tsc -p . && electron-builder -p never -w --dir",
"build:test-linux": "tsc -p . && electron-builder -p never -l --dir",
"build:darwin": "tsc -p . && electron-builder -p never -m --x64 --arm64",
"build:win32": "tsc -p . && electron-builder -p never -w",
"build:linux": "tsc -p . && electron-builder -p never --linux deb",
@ -74,6 +78,8 @@
"devDependencies": {
"@electron/notarize": "^2.1.0",
"@playwright/test": "^1.38.1",
"@types/npmcli__arborist": "^5.6.4",
"@types/pacote": "^11.1.7",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3",
"electron": "26.2.1",

View File

@ -1,6 +1,5 @@
// Make Pluggable Electron's facade available to the renderer on window.plugins
//@ts-ignore
const useFacade = require("../core/plugin-manager/facade");
import { useFacade } from "./core/plugin/facade";
useFacade();
//@ts-ignore
const { contextBridge, ipcRenderer } = require("electron");
@ -19,6 +18,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
pluginPath: () => ipcRenderer.invoke("pluginPath"),
appDataPath: () => ipcRenderer.invoke("appDataPath"),
reloadPlugins: () => ipcRenderer.invoke("reloadPlugins"),
appVersion: () => ipcRenderer.invoke("appVersion"),
@ -27,6 +28,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
relaunch: () => ipcRenderer.invoke("relaunch"),
openAppDirectory: () => ipcRenderer.invoke("openAppDirectory"),
deleteFile: (filePath: string) => ipcRenderer.invoke("deleteFile", filePath),
installRemotePlugin: (pluginName: string) =>
@ -35,6 +38,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
downloadFile: (url: string, path: string) =>
ipcRenderer.invoke("downloadFile", url, path),
pauseDownload: (fileName: string) =>
ipcRenderer.invoke("pauseDownload", fileName),
resumeDownload: (fileName: string) =>
ipcRenderer.invoke("resumeDownload", fileName),
abortDownload: (fileName: string) =>
ipcRenderer.invoke("abortDownload", fileName),
onFileDownloadUpdate: (callback: any) =>
ipcRenderer.on("FILE_DOWNLOAD_UPDATE", callback),

View File

@ -40,8 +40,12 @@ test("renders left navigation panel", async () => {
expect(chatSection).toBe(false);
// Home actions
const botBtn = await page.getByTestId("Bot").first().isEnabled();
/* Disable unstable feature tests
** const botBtn = await page.getByTestId("Bot").first().isEnabled();
** Enable back when it is whitelisted
*/
const myModelsBtn = await page.getByTestId("My Models").first().isEnabled();
const settingsBtn = await page.getByTestId("Settings").first().isEnabled();
expect([botBtn, myModelsBtn, settingsBtn].filter((e) => !e).length).toBe(0);
expect([myModelsBtn, settingsBtn].filter((e) => !e).length).toBe(0);
});

View File

@ -4,13 +4,16 @@
"workspaces": {
"packages": [
"electron",
"web"
"web",
"server"
],
"nohoist": [
"electron",
"electron/**",
"web",
"web/**"
"web/**",
"server",
"server/**"
]
},
"scripts": {
@ -19,20 +22,29 @@
"dev:electron": "yarn workspace jan dev",
"dev:web": "yarn workspace jan-web dev",
"dev": "concurrently --kill-others \"yarn dev:web\" \"wait-on http://localhost:3000 && yarn dev:electron\"",
"test-local": "yarn lint && yarn build && yarn test",
"test-local": "yarn lint && yarn build:test && yarn test",
"build:core": "cd core && yarn install && yarn run build",
"build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"",
"build:electron": "yarn workspace jan build",
"build:plugins": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/data-plugin && npm install && npm run postinstall\" \"cd ./plugins/inference-plugin && npm install && npm run postinstall\" \"cd ./plugins/model-management-plugin && npm install && npm run postinstall\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall\" && concurrently --kill-others-on-fail \"cd ./plugins/data-plugin && npm run build:publish\" \"cd ./plugins/inference-plugin && npm run build:publish\" \"cd ./plugins/model-management-plugin && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm run build:publish\"",
"build:plugins-darwin": "rimraf ./electron/core/pre-install/*.tgz && concurrently \"cd ./plugins/data-plugin && npm install && npm run build:deps && npm run postinstall\" \"cd ./plugins/inference-plugin && npm install && npm run postinstall\" \"cd ./plugins/model-management-plugin && npm install && npm run postinstall\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall\" && chmod +x ./.github/scripts/auto-sign.sh && ./.github/scripts/auto-sign.sh && concurrently \"cd ./plugins/data-plugin && npm run build:publish\" \"cd ./plugins/inference-plugin && npm run build:publish\" \"cd ./plugins/model-management-plugin && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm run build:publish\"",
"build:electron:test": "yarn workspace jan build:test",
"build:pull-plugins": "rimraf ./electron/core/pre-install/*.tgz && cd ./electron/core/pre-install && npm pack @janhq/inference-plugin @janhq/data-plugin @janhq/model-management-plugin @janhq/monitoring-plugin",
"build:plugins": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/data-plugin && npm install && npm run postinstall\" \"cd ./plugins/inference-plugin && npm install --ignore-scripts && npm run postinstall:dev\" \"cd ./plugins/model-management-plugin && npm install && npm run postinstall\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall\" && concurrently --kill-others-on-fail \"cd ./plugins/data-plugin && npm run build:publish\" \"cd ./plugins/inference-plugin && npm run build:publish\" \"cd ./plugins/model-management-plugin && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm run build:publish\"",
"build:plugins-web": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/data-plugin && npm install && npm run build:deps && npm run postinstall\" \"cd ./plugins/inference-plugin && npm install && npm run postinstall\" \"cd ./plugins/model-management-plugin && npm install && npm run postinstall\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall\" && concurrently --kill-others-on-fail \"cd ./plugins/data-plugin && npm run build:publish\" \"cd ./plugins/inference-plugin && npm run build:publish\" \"cd ./plugins/model-management-plugin && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm run build:publish\"",
"build": "yarn build:web && yarn build:electron",
"build:test": "yarn build:web && yarn build:electron:test",
"build:test-darwin": "yarn build:web && yarn workspace jan build:test-darwin",
"build:test-win32": "yarn build:web && yarn workspace jan build:test-win32",
"build:test-linux": "yarn build:web && yarn workspace jan build:test-linux",
"build:darwin": "yarn build:web && yarn workspace jan build:darwin",
"build:win32": "yarn build:web && yarn workspace jan build:win32",
"build:linux": "yarn build:web && yarn workspace jan build:linux",
"build:publish": "yarn build:web && yarn workspace jan build:publish",
"build:publish-darwin": "yarn build:web && yarn workspace jan build:publish-darwin",
"build:publish-win32": "yarn build:web && yarn workspace jan build:publish-win32",
"build:publish-linux": "yarn build:web && yarn workspace jan build:publish-linux"
"build:publish-linux": "yarn build:web && yarn workspace jan build:publish-linux",
"build:web-plugins": "yarn build:web && yarn build:plugins-web && mkdir -p \"./web/out/plugins/data-plugin\" && cp \"./plugins/data-plugin/dist/esm/index.js\" \"./web/out/plugins/data-plugin\" && mkdir -p \"./web/out/plugins/inference-plugin\" && cp \"./plugins/inference-plugin/dist/index.js\" \"./web/out/plugins/inference-plugin\" && mkdir -p \"./web/out/plugins/model-management-plugin\" && cp \"./plugins/model-management-plugin/dist/index.js\" \"./web/out/plugins/model-management-plugin\" && mkdir -p \"./web/out/plugins/monitoring-plugin\" && cp \"./plugins/monitoring-plugin/dist/index.js\" \"./web/out/plugins/monitoring-plugin\"",
"server:prod": "yarn workspace server build && yarn build:web-plugins && cpx \"web/out/**\" \"server/build/renderer/\" && mkdir -p ./server/build/@janhq && cp -r ./plugins/* ./server/build/@janhq",
"start:server": "yarn server:prod && node server/build/main.js"
},
"devDependencies": {
"concurrently": "^8.2.1",

View File

@ -1,8 +1,6 @@
## Database handler plugin for Jan App
**Notice**: please only install dependencies and run build using npm and not yarn.
## Jan data handler plugin
- index.ts: Main entry point for the plugin.
- module.ts: Defines the plugin module which would be executed by the main node process.
- package.json: Defines the plugin metadata.
- tsconfig.json: Defines the typescript configuration.
- package.json: Plugin & npm module manifest.

View File

@ -16,7 +16,7 @@ const dbs: Record<string, any> = {};
*/
function createCollection(name: string, schema?: { [key: string]: any }): Promise<void> {
return new Promise<void>((resolve) => {
const dbPath = path.join(app.getPath("userData"), "databases");
const dbPath = path.join(appPath(), "databases");
if (!fs.existsSync(dbPath)) fs.mkdirSync(dbPath);
const db = new PouchDB(`${path.join(dbPath, name)}`);
dbs[name] = db;
@ -226,6 +226,13 @@ function findMany(
.then((data) => data.docs); // Return documents
}
function appPath() {
if (app) {
return app.getPath("userData");
}
return process.env.APPDATA || (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share");
}
module.exports = {
createCollection,
deleteCollection,

View File

@ -1,12 +1,14 @@
{
"name": "@janhq/data-plugin",
"version": "1.0.16",
"version": "1.0.19",
"description": "The Data Connector provides easy access to a data API using the PouchDB engine. It offers accessible data management capabilities.",
"icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/circle-stack.svg",
"main": "dist/esm/index.js",
"module": "dist/cjs/module.js",
"author": "Jan <service@jan.ai>",
"license": "MIT",
"license": "AGPL-3.0",
"supportCloudNative": true,
"url": "/plugins/data-plugin/index.js",
"activationPoints": [
"init"
],

View File

@ -1,3 +1,7 @@
# Jan inference plugin
Created using Jan app example
# Create a Jan Plugin using Typescript
Use this template to bootstrap the creation of a TypeScript Jan plugin. 🚀
@ -71,3 +75,4 @@ There are a few things to keep in mind when writing your plugin code:
[documentation](https://github.com/janhq/jan/blob/main/core/README.md).
So, what are you waiting for? Go ahead and start customizing your plugin!

View File

@ -3,7 +3,7 @@ const { app } = require("electron");
const { spawn } = require("child_process");
const fs = require("fs");
const tcpPortUsed = require("tcp-port-used");
const { killPortProcess } = require("kill-port-process");
const kill = require("kill-port");
const PORT = 3928;
let subprocess = null;
@ -25,23 +25,25 @@ const initModel = (fileName) => {
if (process.platform === "win32") {
// Todo: Need to check for CUDA support to switch between CUDA and non-CUDA binaries
binaryName = "nitro_start_windows.bat";
binaryName = "win-start.bat";
} else if (process.platform === "darwin") {
// Mac OS platform
binaryName =
process.arch === "arm64"
? "nitro_mac_arm64"
: "nitro_mac_intel";
if (process.arch === "arm64") {
binaryFolder = path.join(binaryFolder, "mac-arm64")
} else {
binaryFolder = path.join(binaryFolder, "mac-x64")
}
binaryName = "nitro"
} else {
// Linux
// Todo: Need to check for CUDA support to switch between CUDA and non-CUDA binaries
binaryName = "nitro_start_linux.sh"; // For other platforms
binaryName = "linux-start.sh"; // For other platforms
}
const binaryPath = path.join(binaryFolder, binaryName);
// Execute the binary
subprocess = spawn(binaryPath, { cwd: binaryFolder });
subprocess = spawn(binaryPath,["0.0.0.0", PORT], { cwd: binaryFolder });
// Handle subprocess output
subprocess.stdout.on("data", (data) => {
@ -61,7 +63,7 @@ const initModel = (fileName) => {
})
.then(() => tcpPortUsed.waitUntilUsed(PORT, 300, 30000))
.then(() => {
const llama_model_path = path.join(app.getPath("userData"), fileName);
const llama_model_path = path.join(appPath(), fileName);
const config = {
llama_model_path,
@ -102,11 +104,18 @@ function killSubprocess() {
subprocess = null;
console.log("Subprocess terminated.");
} else {
killPortProcess(PORT);
kill(PORT, "tcp").then(console.log).catch(console.log);
console.error("No subprocess is currently running.");
}
}
function appPath() {
if (app) {
return app.getPath("userData");
}
return process.env.APPDATA || (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share");
}
module.exports = {
initModel,
killSubprocess,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
#!/bin/bash
# Attempt to run the nitro_linux_amd64_cuda file and if it fails, run nitro_linux_amd64
cd linux-cuda
./nitro "$@" || (echo "nitro_linux_amd64_cuda encountered an error, attempting to run nitro_linux_amd64..." && cd ../linux-cpu && ./nitro "$@")

View File

@ -1,6 +0,0 @@
#!/bin/bash
#!/bin/bash
# Attempt to run the nitro_linux_amd64_cuda file and if it fails, run nitro_linux_amd64
./nitro_linux_amd64_cuda || (echo "nitro_linux_amd64_cuda encountered an error, attempting to run nitro_linux_amd64..." && ./nitro_linux_amd64)

View File

@ -0,0 +1 @@
0.1.3

View File

@ -1,10 +1,12 @@
@echo off
rem Attempt to run nitro_windows_amd64_cuda.exe
nitro_windows_amd64_cuda.exe
cd win-cuda
nitro.exe
rem Check the exit code of the previous command
if %errorlevel% neq 0 (
echo nitro_windows_amd64_cuda.exe encountered an error, attempting to run nitro_windows_amd64.exe...
nitro_windows_amd64.exe
cd ..\win-cpu
nitro.exe
)

View File

@ -1,18 +1,27 @@
{
"name": "@janhq/inference-plugin",
"version": "1.0.14",
"version": "1.0.20",
"description": "Inference Plugin, powered by @janhq/nitro, bring a high-performance Llama model inference in pure C++.",
"icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/command-line.svg",
"main": "dist/index.js",
"module": "dist/module.js",
"author": "Jan <service@jan.ai>",
"license": "MIT",
"license": "AGPL-3.0",
"supportCloudNative": true,
"url": "/plugins/inference-plugin/index.js",
"activationPoints": [
"init"
],
"scripts": {
"build": "tsc -b . && webpack --config webpack.config.js",
"postinstall": "rimraf *.tgz --glob && npm run build && rimraf dist/nitro/* && cpx \"nitro/**\" \"dist/nitro\"",
"downloadnitro:linux-cpu": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64.zip -e --strip 1 -o ./nitro/linux-cpu && chmod +x ./nitro/linux-cpu/nitro",
"downloadnitro:linux-cuda": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64-cuda.zip -e --strip 1 -o ./nitro/linux-cuda && chmod +x ./nitro/linux-cuda/nitro",
"downloadnitro:mac-arm64": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-arm64.zip -e --strip 1 -o ./nitro/mac-arm64 && chmod +x ./nitro/mac-arm64/nitro",
"downloadnitro:mac-x64": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-amd64.zip -e --strip 1 -o ./nitro/mac-x64 && chmod +x ./nitro/mac-x64/nitro",
"downloadnitro:win-cpu": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-win-amd64.zip -e --strip 1 -o ./nitro/win-cpu",
"downloadnitro:win-cuda": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-win-amd64-cuda.zip -e --strip 1 -o ./nitro/win-cuda",
"postinstall": "rimraf *.tgz --glob && npm run build && npm run downloadnitro:linux-cpu && npm run downloadnitro:linux-cuda && npm run downloadnitro:mac-arm64 && npm run downloadnitro:mac-x64 && npm run downloadnitro:win-cpu && npm run downloadnitro:win-cuda && rimraf dist/nitro/* && cpx \"nitro/**\" \"dist/nitro\"",
"postinstall:dev": "rimraf *.tgz --glob && npm run build && npm run downloadnitro:mac-arm64 && rimraf dist/nitro/* && cpx \"nitro/**\" \"dist/nitro\"",
"build:publish": "npm pack && cpx *.tgz ../../electron/core/pre-install"
},
"exports": {
@ -27,15 +36,12 @@
},
"dependencies": {
"@janhq/core": "^0.1.6",
"kill-port-process": "^3.2.0",
"download-cli": "^1.1.1",
"kill-port": "^2.0.1",
"rxjs": "^7.8.1",
"tcp-port-used": "^1.0.2",
"ts-loader": "^9.5.0"
},
"bundledDependencies": [
"tcp-port-used",
"kill-port-process"
],
"engines": {
"node": ">=18.0.0"
},
@ -43,5 +49,9 @@
"dist/*",
"package.json",
"README.md"
],
"bundleDependencies": [
"tcp-port-used",
"kill-port"
]
}

View File

@ -19,7 +19,7 @@ module.exports = {
new webpack.DefinePlugin({
PLUGIN_NAME: JSON.stringify(packageJson.name),
MODULE_PATH: JSON.stringify(`${packageJson.name}/${packageJson.module}`),
INFERENCE_URL: JSON.stringify("http://127.0.0.1:3928/inferences/llamacpp/chat_completion"),
INFERENCE_URL: JSON.stringify(process.env.INFERENCE_URL || "http://127.0.0.1:3928/inferences/llamacpp/chat_completion"),
}),
],
output: {
@ -30,5 +30,8 @@ module.exports = {
resolve: {
extensions: [".ts", ".js"],
},
optimization: {
minimize: false,
},
// Add loaders and other configuration as needed for your project
};

View File

@ -0,0 +1,78 @@
# Jan Model Management plugin
Created using Jan app example
# Create a Jan Plugin using Typescript
Use this template to bootstrap the creation of a TypeScript Jan plugin. 🚀
## Create Your Own Plugin
To create your own plugin, you can use this repository as a template! Just follow the below instructions:
1. Click the Use this template button at the top of the repository
2. Select Create a new repository
3. Select an owner and name for your new repository
4. Click Create repository
5. Clone your new repository
## Initial Setup
After you've cloned the repository to your local machine or codespace, you'll need to perform some initial setup steps before you can develop your plugin.
> [!NOTE]
>
> You'll need to have a reasonably modern version of
> [Node.js](https://nodejs.org) handy. If you are using a version manager like
> [`nodenv`](https://github.com/nodenv/nodenv) or
> [`nvm`](https://github.com/nvm-sh/nvm), you can run `nodenv install` in the
> root of your repository to install the version specified in
> [`package.json`](./package.json). Otherwise, 20.x or later should work!
1. :hammer_and_wrench: Install the dependencies
```bash
npm install
```
1. :building_construction: Package the TypeScript for distribution
```bash
npm run bundle
```
1. :white_check_mark: Check your artifact
There will be a tgz file in your plugin directory now
## Update the Plugin Metadata
The [`package.json`](package.json) file defines metadata about your plugin, such as
plugin name, main entry, description and version.
When you copy this repository, update `package.json` with the name, description for your plugin.
## Update the Plugin Code
The [`src/`](./src/) directory is the heart of your plugin! This contains the
source code that will be run when your plugin extension functions are invoked. You can replace the
contents of this directory with your own code.
There are a few things to keep in mind when writing your plugin code:
- Most Jan Plugin Extension functions are processed asynchronously.
In `index.ts`, you will see that the extension function will return a `Promise<any>`.
```typescript
import { core } from "@janhq/core";
function onStart(): Promise<any> {
return core.invokePluginFunc(MODULE_PATH, "run", 0);
}
```
For more information about the Jan Plugin Core module, see the
[documentation](https://github.com/janhq/jan/blob/main/core/README.md).
So, what are you waiting for? Go ahead and start customizing your plugin!

View File

@ -5,11 +5,56 @@ import {
downloadFile,
deleteFile,
store,
EventName,
events
} from "@janhq/core";
import { parseToModel } from "./helper";
const downloadModel = (product) =>
const downloadModel = (product) => {
downloadFile(product.downloadUrl, product.fileName);
checkDownloadProgress(product.fileName);
}
async function checkDownloadProgress(fileName: string) {
if (typeof window !== "undefined" && typeof (window as any).electronAPI === "undefined") {
const intervalId = setInterval(() => {
fetchDownloadProgress(fileName, intervalId);
}, 3000);
}
}
async function fetchDownloadProgress(fileName: string, intervalId: NodeJS.Timeout): Promise<string> {
const response = await fetch("/api/v1/downloadProgress", {
method: 'POST',
body: JSON.stringify({ fileName: fileName }),
headers: { 'Content-Type': 'application/json', 'Authorization': '' }
});
if (!response.ok) {
events.emit(EventName.OnDownloadError, null);
clearInterval(intervalId);
return;
}
const json = await response.json();
if (isEmptyObject(json)) {
if (!fileName && intervalId) {
clearInterval(intervalId);
}
return Promise.resolve("");
}
if (json.success === true) {
events.emit(EventName.OnDownloadSuccess, json);
clearInterval(intervalId);
return Promise.resolve("");
} else {
events.emit(EventName.OnDownloadUpdate, json);
return Promise.resolve(json.fileName);
}
}
function isEmptyObject(ojb: any): boolean {
return Object.keys(ojb).length === 0;
}
const deleteModel = (path) => deleteFile(path);
@ -87,6 +132,9 @@ function getModelById(modelId: string): Promise<any> {
function onStart() {
store.createCollection("models", {});
if (!(window as any)?.electronAPI) {
fetchDownloadProgress(null, null).then((fileName: string) => fileName && checkDownloadProgress(fileName));
}
}
// Register all the above functions and objects with the relevant extension points

View File

@ -1,12 +1,14 @@
{
"name": "@janhq/model-management-plugin",
"version": "1.0.9",
"version": "1.0.13",
"description": "Model Management Plugin provides model exploration and seamless downloads",
"icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/queue-list.svg",
"main": "dist/index.js",
"module": "dist/module.js",
"author": "Jan <service@jan.ai>",
"license": "MIT",
"license": "AGPL-3.0",
"supportCloudNative": true,
"url": "/plugins/model-management-plugin/index.js",
"activationPoints": [
"init"
],

View File

@ -0,0 +1,78 @@
# Jan Monitoring plugin
Created using Jan app example
# Create a Jan Plugin using Typescript
Use this template to bootstrap the creation of a TypeScript Jan plugin. 🚀
## Create Your Own Plugin
To create your own plugin, you can use this repository as a template! Just follow the below instructions:
1. Click the Use this template button at the top of the repository
2. Select Create a new repository
3. Select an owner and name for your new repository
4. Click Create repository
5. Clone your new repository
## Initial Setup
After you've cloned the repository to your local machine or codespace, you'll need to perform some initial setup steps before you can develop your plugin.
> [!NOTE]
>
> You'll need to have a reasonably modern version of
> [Node.js](https://nodejs.org) handy. If you are using a version manager like
> [`nodenv`](https://github.com/nodenv/nodenv) or
> [`nvm`](https://github.com/nvm-sh/nvm), you can run `nodenv install` in the
> root of your repository to install the version specified in
> [`package.json`](./package.json). Otherwise, 20.x or later should work!
1. :hammer_and_wrench: Install the dependencies
```bash
npm install
```
1. :building_construction: Package the TypeScript for distribution
```bash
npm run bundle
```
1. :white_check_mark: Check your artifact
There will be a tgz file in your plugin directory now
## Update the Plugin Metadata
The [`package.json`](package.json) file defines metadata about your plugin, such as
plugin name, main entry, description and version.
When you copy this repository, update `package.json` with the name, description for your plugin.
## Update the Plugin Code
The [`src/`](./src/) directory is the heart of your plugin! This contains the
source code that will be run when your plugin extension functions are invoked. You can replace the
contents of this directory with your own code.
There are a few things to keep in mind when writing your plugin code:
- Most Jan Plugin Extension functions are processed asynchronously.
In `index.ts`, you will see that the extension function will return a `Promise<any>`.
```typescript
import { core } from "@janhq/core";
function onStart(): Promise<any> {
return core.invokePluginFunc(MODULE_PATH, "run", 0);
}
```
For more information about the Jan Plugin Core module, see the
[documentation](https://github.com/janhq/jan/blob/main/core/README.md).
So, what are you waiting for? Go ahead and start customizing your plugin!

View File

@ -1,12 +1,14 @@
{
"name": "@janhq/monitoring-plugin",
"version": "1.0.6",
"version": "1.0.9",
"description": "Utilizing systeminformation, it provides essential System and OS information retrieval",
"icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/cpu-chip.svg",
"main": "dist/index.js",
"module": "dist/module.js",
"author": "Jan <service@jan.ai>",
"license": "MIT",
"license": "AGPL-3.0",
"supportCloudNative": true,
"url": "/plugins/monitoring-plugin/index.js",
"activationPoints": [
"init"
],

View File

@ -29,5 +29,8 @@ module.exports = {
resolve: {
extensions: [".ts", ".js"],
},
optimization: {
minimize: false,
},
// Add loaders and other configuration as needed for your project
};

179
server/main.ts Normal file
View File

@ -0,0 +1,179 @@
import express, { Express, Request, Response, NextFunction } from 'express'
import cors from "cors";
import { resolve } from "path";
const fs = require("fs");
const progress = require("request-progress");
const path = require("path");
const request = require("request");
// Create app dir
const userDataPath = appPath();
if (!fs.existsSync(userDataPath)) fs.mkdirSync(userDataPath);
interface ProgressState {
percent?: number;
speed?: number;
size?: {
total: number;
transferred: number;
};
time?: {
elapsed: number;
remaining: number;
};
success?: boolean | undefined;
fileName: string;
}
const options: cors.CorsOptions = { origin: "*" };
const requiredModules: Record<string, any> = {};
const port = process.env.PORT || 4000;
const dataDir = __dirname;
type DownloadProgress = Record<string, ProgressState>;
const downloadProgress: DownloadProgress = {};
const app: Express = express()
app.use(express.static(dataDir + '/renderer'))
app.use(cors(options))
app.use(express.json());
/**
* Execute a plugin module function via API call
*
* @param modulePath path to module name to import
* @param method function name to execute. The methods "deleteFile" and "downloadFile" will call the server function {@link deleteFile}, {@link downloadFile} instead of the plugin function.
* @param args arguments to pass to the function
* @returns Promise<any>
*
*/
app.post('/api/v1/invokeFunction', (req: Request, res: Response, next: NextFunction): void => {
const method = req.body["method"];
const args = req.body["args"];
switch (method) {
case "deleteFile":
deleteFile(args).then(() => res.json(Object())).catch((err: any) => next(err));
break;
case "downloadFile":
downloadFile(args.downloadUrl, args.fileName).then(() => res.json(Object())).catch((err: any) => next(err));
break;
default:
const result = invokeFunction(req.body["modulePath"], method, args)
if (typeof result === "undefined") {
res.json(Object())
} else {
result?.then((result: any) => {
res.json(result)
}).catch((err: any) => next(err));
}
}
});
app.post('/api/v1/downloadProgress', (req: Request, res: Response): void => {
const fileName = req.body["fileName"];
if (fileName && downloadProgress[fileName]) {
res.json(downloadProgress[fileName])
return;
} else {
const obj = downloadingFile();
if (obj) {
res.json(obj)
return;
}
}
res.json(Object());
});
app.use((err: Error, req: Request, res: Response, next: NextFunction): void => {
console.error("ErrorHandler", req.url, req.body, err);
res.status(500);
res.json({ error: err?.message ?? "Internal Server Error" })
});
app.listen(port, () => console.log(`Application is running on port ${port}`));
async function invokeFunction(modulePath: string, method: string, args: any): Promise<any> {
console.log(modulePath, method, args);
const module = require(/* webpackIgnore: true */ path.join(
dataDir,
"",
modulePath
));
requiredModules[modulePath] = module;
if (typeof module[method] === "function") {
return module[method](...args);
} else {
return Promise.resolve();
}
}
function downloadModel(downloadUrl: string, fileName: string): void {
const userDataPath = appPath();
const destination = resolve(userDataPath, fileName);
console.log("Download file", fileName, "to", destination);
progress(request(downloadUrl), {})
.on("progress", function (state: any) {
downloadProgress[fileName] = {
...state,
fileName,
success: undefined
};
console.log("downloading file", fileName, (state.percent * 100).toFixed(2) + '%');
})
.on("error", function (err: Error) {
downloadProgress[fileName] = {
...downloadProgress[fileName],
success: false,
fileName: fileName,
};
})
.on("end", function () {
downloadProgress[fileName] = {
success: true,
fileName: fileName,
};
})
.pipe(fs.createWriteStream(destination));
}
function deleteFile(filePath: string): Promise<void> {
const userDataPath = appPath();
const fullPath = resolve(userDataPath, filePath);
return new Promise((resolve, reject) => {
fs.unlink(fullPath, function (err: any) {
if (err && err.code === "ENOENT") {
reject(Error(`File does not exist: ${err}`));
} else if (err) {
reject(Error(`File delete error: ${err}`));
} else {
console.log(`Delete file ${filePath} from ${fullPath}`)
resolve();
}
});
})
}
function downloadingFile(): ProgressState | undefined {
const obj = Object.values(downloadProgress).find(obj => obj && typeof obj.success === "undefined")
return obj
}
async function downloadFile(downloadUrl: string, fileName: string): Promise<void> {
return new Promise((resolve, reject) => {
const obj = downloadingFile();
if (obj) {
reject(Error(obj.fileName + " is being downloaded!"))
return;
};
(async () => {
downloadModel(downloadUrl, fileName);
})().catch(e => {
console.error("downloadModel", fileName, e);
});
resolve();
});
}
function appPath(): string {
return process.env.APPDATA || (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share")
}

5
server/nodemon.json Normal file
View File

@ -0,0 +1,5 @@
{
"watch": [
"main.ts"
]
}

26
server/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "server",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"cors": "^2.8.5",
"electron": "^26.2.1",
"express": "^4.18.2",
"request": "^2.88.2",
"request-progress": "^3.0.0"
},
"devDependencies": {
"@types/cors": "^2.8.14",
"@types/express": "^4.17.18",
"@types/node": "^20.8.2",
"nodemon": "^3.0.1",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
},
"scripts": {
"build": "tsc --project ./",
"dev": "nodemon main.ts",
"prod": "node build/main.js"
}
}

19
server/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"noImplicitAny": true,
"sourceMap": true,
"strict": true,
"outDir": "./build",
"rootDir": "./",
"noEmitOnError": true,
"baseUrl": ".",
"allowJs": true,
"paths": { "*": ["node_modules/*"] },
"typeRoots": ["node_modules/@types"],
"esModuleInterop": true
},
"include": ["./**/*.ts"],
"exclude": ["core", "build", "dist", "tests"]
}

14
specs/Makefile Normal file
View File

@ -0,0 +1,14 @@
spec:
@echo "Initiating a Spec..."
@last_number=$$(ls $(CURDIR)/jan-[0-9][0-9][0-9]-* | sort -V | tail -n 1 | cut -d '-' -f 2); \
last_number=$$(echo $$last_number | sed 's/^0*//'); \
next_number=$$(printf "%03d" $$(( $$last_number + 1 ))); \
read -p "Enter Spec title: " title; \
title=$$(echo $$title | tr ' ' '-'); \
cp $(CURDIR)/spec-template.md $(CURDIR)/jan-$$next_number-$$title.md; \
date=$$(date +%Y-%m-%d); \
usernames=$$(git config user.name); \
sed -i '' 's/{SPEC-NUM}/'$$next_number'/g' $(CURDIR)/jan-$$next_number-$$title.md; \
sed -i '' 's/{TITLE}/'$$title'/g' $(CURDIR)/jan-$$next_number-$$title.md; \
sed -i '' 's/{DATE}/'$$date'/g' $(CURDIR)/jan-$$next_number-$$title.md; \
sed -i '' 's/{USERNAMES}/'$$usernames'/g' $(CURDIR)/jan-$$next_number-$$title.md

View File

@ -1,7 +1,8 @@
# Architectural Decision Records (ADR)
# Jan Improvement Proposals
This is a repo of key architecture decisions for Jan. [Read more about ADRs](https://github.com/joelparkerhenderson/architecture-decision-record)
This is a repo of key architecture decisions for Jan.
[Read more about ADRs](https://github.com/joelparkerhenderson/architecture-decision-record)
### Get started:

View File

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

View File

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 179 KiB

View File

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 335 KiB

View File

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 233 KiB

View File

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

View File

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

View File

@ -0,0 +1,101 @@
# jan-001: Application Logs Framework
| Proposal | jan-001 |
| ---------- | ----------------------------------------------------- |
| Title | App Logging |
| Authors | @louis-jan |
| Permalink | |
| Discussion | [issue #528](https://github.com/janhq/jan/issues/528) |
| Status | Idea |
## Changelog
| Date | Author | Changes |
| ------------ | ---------- | ------------- |
| Nov 2nd 2023 | @louis-jan | Initial Draft |
## Summary
This proposal suggests the implementation of an "App logging as file and log window" feature, which aims to address the problem of limited visibility into the operation of a production application. Currently, logs (info, verbose, error) are hidden, making it challenging for both users and developers to debug and support the application. The proposed solution involves logging application-wide activities to a file while also enabling real-time log monitoring through a dedicated log window within the application.
## Motivation
### Problem Description
The lack of proper logging in production applications results in several challenges:
1. Debugging Difficulty: When an issue arises in a production environment, developers have limited access to essential information about what happened, making it challenging to diagnose and resolve problems effectively.
2. Support Challenges: Users often encounter errors or unexpected behavior, and support teams struggle to gather the necessary logs to understand the issue and provide a solution promptly.
3. Lack of Real-time Insights: Real-time monitoring is essential for identifying and responding to critical events. The absence of a log window within the application prevents timely reactions to events.
### Use Case Example
Consider an e-commerce application. In the current state, when a user faces an issue during checkout, there's no easy way for support or development teams to see what went wrong in real time. This results in frustration for the user and a loss of business for the company
```ts
# Current Status (Without the Feature)
try:
# Checkout logic
# ...
except Exception as e:
# Error handling
console.log(err)
# Insufficient logging
```
Without proper logging, it is challenging to diagnose the issue and provide immediate assistance.
## Proposed solution
### High-level overview
The proposed solution introduces the following key changes:
1. Application-wide Logging: Implement a logging mechanism that logs application-wide activities to a designated log file. This ensures that all relevant information is captured for later analysis and debugging.
2. Real-time Log Window: Create a log window within the application that displays log entries in real time. Users and developers can open this window to monitor logs, allowing them to react promptly to events and errors.
```ts
# With the Proposed Feature
try:
# Checkout logic
# ...
except Exception as e:
# Error handling
log.error(f"Error when downloading model: {e}")
# Proper logging
```
![Image](https://github.com/janhq/jan/assets/133622055/b60f6976-8138-438e-aa4f-7e103037e124)
### Specification
- The logging system will support different log levels (e.g., info, verbose, error) to ensure that the right level of detail is captured.
- Log entries will be timestamped and categorized to aid in the analysis and debugging process.
- The log window will provide options for filtering and searching log entries for ease of use.
### Compatibility
This proposal aims to preserve backward compatibility by ensuring that the new logging system does not break existing functionality or affect existing applications negatively. It should not alter the semantics of valid programs.
### Other concerns
- Implementation: Careful consideration should be given to the choice of logging framework and implementation details.
- Security: Access to logs and log window functionality should be restricted to authorized users to prevent potential security risks.
### Open questions
- What will be the default log file location, and how will it be configurable?
- Should log entries be persisted and rotated over time to prevent excessive file size?
## Alternatives
Alternative approaches may involve integrating with existing third-party logging systems or cloud-based log management platforms. However, this proposal focuses on a built-in solution for application-wide logging and real-time monitoring.
## Related work
This proposal is inspired by similar features in various application development frameworks and tools.
## FAQ
No frequently asked questions at this time.

33
specs/spec-template.md Normal file
View File

@ -0,0 +1,33 @@
# jan-{SPEC-NUM}: {TITLE}
| Proposal | jan-{SPEC-NUM} |
| ---------- | -------------- |
| Title | {TITLE} |
| Authors | |
| Permalink | |
| Discussion | |
| Status | Idea |
## Changelog
| Date | Author | Changes |
| ---- | ------ | ------------- |
| | | Initial Draft |
## Abstract
Summary. Please keep it very short.
## Motivation
Why?
## Specification
What, how?
- UX Mockups
- Code Interfaces
## Appendix
Everything else goes here.

View File

@ -0,0 +1,87 @@
import React, { Fragment } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline'
import { PrimitiveAtom, useAtom } from 'jotai'
interface Props {
atom: PrimitiveAtom<boolean>
title: string
description: string
onConfirm: () => void
}
const ConfirmationModal: React.FC<Props> = ({ atom, title, description, onConfirm }) => {
const [show, setShow] = useAtom(atom)
return (
<Transition.Root show={show} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={setShow}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<QuestionMarkCircleIcon
className="h-6 w-6 text-green-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<Dialog.Title
as="h3"
className="text-base font-semibold leading-6 text-gray-900"
>
{title}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">{description}</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
type="button"
className="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto"
onClick={onConfirm}
>
OK
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
onClick={() => setShow(false)}
>
Cancel
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
export default React.memo(ConfirmationModal)

View File

@ -11,6 +11,8 @@ import {
MainViewState,
setMainViewStateAtom,
} from '@helpers/atoms/MainView.atom'
import ConfirmationModal from '../ConfirmationModal'
import { showingCancelDownloadModalAtom } from '@helpers/atoms/Modal.atom'
type Props = {
suitableModel: ModelVersion
@ -31,6 +33,9 @@ const ExploreModelItemHeader: React.FC<Props> = ({
)
const downloadState = useAtomValue(downloadAtom)
const setMainViewState = useSetAtom(setMainViewStateAtom)
const setShowingCancelDownloadModal = useSetAtom(
showingCancelDownloadModalAtom
)
useEffect(() => {
getPerformanceForModel(suitableModel)
@ -70,17 +75,30 @@ const ExploreModelItemHeader: React.FC<Props> = ({
// downloading
downloadButton = (
<Button
disabled
themes="accent"
themes="outline"
onClick={() => {
setMainViewState(MainViewState.MyModel)
setShowingCancelDownloadModal(true)
}}
>
Downloading {formatDownloadPercentage(downloadState.percent)}
Cancel ({formatDownloadPercentage(downloadState.percent)})
</Button>
)
}
let cancelDownloadModal =
downloadState != null ? (
<ConfirmationModal
atom={showingCancelDownloadModalAtom}
title="Cancel Download"
description={`Are you sure you want to cancel the download of ${downloadState?.fileName}?`}
onConfirm={() => {
window.coreAPI?.abortDownload(downloadState?.fileName)
}}
/>
) : (
<></>
)
return (
<div className="flex items-center justify-between rounded-t-md border-b border-border bg-background/50 px-4 py-2">
<div className="flex items-center gap-2">
@ -90,6 +108,7 @@ const ExploreModelItemHeader: React.FC<Props> = ({
)}
</div>
{downloadButton}
{cancelDownloadModal}
</div>
)
}

View File

@ -15,32 +15,33 @@ const HistoryList: React.FC = () => {
useEffect(() => {
getUserConversations()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="flex flex-grow flex-col gap-2">
<div className="flex flex-grow flex-col gap-2 px-4 pb-4">
<ExpandableHeader title="CHAT HISTORY" />
<ul className={twMerge('mt-1 flex flex-col gap-y-3 overflow-y-auto')}>
{conversations.length > 0 ? (
conversations
<ul className={twMerge('mt-1 flex flex-col gap-y-3 overflow-y-auto')}>
{conversations
.filter(
(e) =>
searchText.trim() === '' ||
e.name?.toLowerCase().includes(searchText.toLowerCase().trim())
)
.map((convo) => (
.map((convo, i) => (
<HistoryItem
key={convo._id}
key={i}
conversation={convo}
summary={convo.summary}
name={convo.name || 'Jan'}
updatedAt={convo.updatedAt ?? ''}
/>
))
))}
</ul>
) : (
<SidebarEmptyHistory />
)}
</ul>
</div>
)
}

View File

@ -43,15 +43,7 @@ const InputToolbar: React.FC = () => {
}
if (!activeConvoId) {
return (
<div className="my-3 flex justify-center gap-2">
<SecondaryButton
onClick={onNewConversationClick}
title="New Conversation"
icon={<PlusIcon width={16} height={16} />}
/>
</div>
)
return null
}
if (
(activeConvoId && inputState === 'model-mismatch') ||
@ -86,7 +78,7 @@ const InputToolbar: React.FC = () => {
if (conversations.length > 0)
return (
<div className="sticky bottom-0 w-full bg-background/90 px-5 py-0">
<div className="sticky bottom-0 w-full bg-background/90 px-5 pb-0 pt-2">
{currentConvoState?.error && (
<div className="flex flex-row justify-center">
<span className="mx-5 my-2 text-sm text-red-500">
@ -94,13 +86,13 @@ const InputToolbar: React.FC = () => {
</span>
</div>
)}
<div className="my-3 flex justify-center gap-2">
{/* <div className="my-3 flex justify-center gap-2">
<SecondaryButton
onClick={onNewConversationClick}
title="New Conversation"
icon={<PlusIcon width={16} height={16} />}
/>
</div>
</div> */}
{/* My text input */}
<div className="mb-5 flex items-start space-x-4">
<div className="relative min-w-0 flex-1">

View File

@ -1,23 +1,42 @@
'use client'
import React from 'react'
import React, { useContext } from 'react'
import SecondaryButton from '../SecondaryButton'
import { useSetAtom } from 'jotai'
import { useSetAtom, useAtomValue } from 'jotai'
import {
MainViewState,
setMainViewStateAtom,
} from '@helpers/atoms/MainView.atom'
import { MagnifyingGlassIcon, PlusIcon } from '@heroicons/react/24/outline'
import useCreateConversation from '@hooks/useCreateConversation'
import { useGetDownloadedModels } from '@hooks/useGetDownloadedModels'
import { Button } from '@uikit'
import { activeAssistantModelAtom } from '@helpers/atoms/Model.atom'
import { showingModalNoActiveModel } from '@helpers/atoms/Modal.atom'
import {
FeatureToggleContext,
} from '@helpers/FeatureToggleWrapper'
const LeftHeaderAction: React.FC = () => {
const setMainView = useSetAtom(setMainViewStateAtom)
const { downloadedModels } = useGetDownloadedModels()
const activeModel = useAtomValue(activeAssistantModelAtom)
const { requestCreateConvo } = useCreateConversation()
const setShowModalNoActiveModel = useSetAtom(showingModalNoActiveModel)
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
const onExploreClick = () => {
setMainView(MainViewState.ExploreModel)
}
const onNewConversationClick = () => {
if (activeModel) {
requestCreateConvo(activeModel)
} else {
setShowModalNoActiveModel(true)
}
}
const onCreateBotClicked = () => {
if (downloadedModels.length === 0) {
alert('You need to download at least one model to create a bot.')
@ -27,19 +46,30 @@ const LeftHeaderAction: React.FC = () => {
}
return (
<div className="sticky top-0 mb-4 flex flex-row gap-2">
<div className="sticky top-0 mb-4 bg-background/90 p-4">
<div className="flex flex-row gap-2">
<SecondaryButton
title={'Explore'}
onClick={onExploreClick}
className="flex-1"
className="w-full flex-1"
icon={<MagnifyingGlassIcon width={16} height={16} />}
/>
{/* <SecondaryButton
{experimentalFeatureEnabed && (
<SecondaryButton
title={'Create bot'}
onClick={onCreateBotClicked}
className="flex-1"
className="w-full flex-1"
icon={<PlusIcon width={16} height={16} />}
/> */}
/>
)}
</div>
<Button
onClick={onNewConversationClick}
className="mt-2 flex w-full items-center space-x-2"
>
<PlusIcon width={16} height={16} />
<span>New Conversation</span>
</Button>
</div>
)
}

View File

@ -1,9 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
plugins,
extensionPoints,
} from '@/../../electron/core/plugin-manager/execution/index'
import { plugins, extensionPoints } from '@plugin'
import {
ChartPieIcon,
CommandLineIcon,
@ -13,7 +10,7 @@ import {
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import { DataService, PluginService, preferences } from '@janhq/core'
import { execute } from '../../../electron/core/plugin-manager/execution/extension-manager'
import { execute } from '@plugin/extension-manager'
import LoadingIndicator from './LoadingIndicator'
import { executeSerial } from '@services/pluginService'
@ -33,7 +30,7 @@ export const Preferences = () => {
* Loads the plugin catalog module from a CDN and sets it as the plugin catalog state.
*/
useEffect(() => {
executeSerial(DataService.GetPluginManifest).then((data) => {
executeSerial(DataService.GetPluginManifest).then((data: any) => {
setPluginCatalog(data)
})
}, [])
@ -52,7 +49,7 @@ export const Preferences = () => {
if (extensionPoints.get('experimentComponent')) {
const components = await Promise.all(
extensionPoints.execute('experimentComponent')
extensionPoints.execute('experimentComponent', {})
)
if (components.length > 0) {
setIsTestAvailable(true)
@ -67,7 +64,7 @@ export const Preferences = () => {
if (extensionPoints.get('PluginPreferences')) {
const data = await Promise.all(
extensionPoints.execute('PluginPreferences')
extensionPoints.execute('PluginPreferences', {})
)
setPreferenceItems(Array.isArray(data) ? data : [])
Promise.all(
@ -149,7 +146,7 @@ export const Preferences = () => {
}
if (extensionPoints.get(PluginService.OnPreferencesUpdate))
timeout = setTimeout(
() => execute(PluginService.OnPreferencesUpdate),
() => execute(PluginService.OnPreferencesUpdate, {}),
100
)
}

View File

@ -6,7 +6,7 @@ type Props = {
}
const ProgressBar: React.FC<Props> = ({ used, total }) => (
<div className="flex items-center gap-2.5 p-[10px]">
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-0.5 text-xs leading-[18px]">
<Image src={'icons/app_icon.svg'} width={18} height={18} alt="" />
Updating

View File

@ -16,7 +16,13 @@ const SecondaryButton: React.FC<Props> = ({
className,
icon,
}) => (
<Button size="sm" disabled={disabled} type="button" onClick={onClick}>
<Button
size="sm"
disabled={disabled}
type="button"
onClick={onClick}
className={className}
>
{icon}&nbsp;
{title}
</Button>

View File

@ -6,14 +6,14 @@ const SidebarFooter: React.FC = () => (
<SecondaryButton
title={'Discord'}
onClick={() =>
window.electronAPI?.openExternalUrl('https://discord.gg/AsJ8krTT3N')
window.coreAPI?.openExternalUrl('https://discord.gg/AsJ8krTT3N')
}
className="flex-1"
/>
<SecondaryButton
title={'Twitter'}
onClick={() =>
window.electronAPI?.openExternalUrl('https://twitter.com/janhq_')
window.coreAPI?.openExternalUrl('https://twitter.com/janhq_')
}
className="flex-1"
/>

View File

@ -51,7 +51,7 @@ const SimpleTextMessage: React.FC<Props> = ({
return (
<div
className={`flex items-start gap-x-4 gap-y-2 border-b border-border/50 px-4 py-5 last:border-none`}
className={`flex items-start gap-x-4 gap-y-2 border-b border-border/50 px-4 py-5`}
>
<Image
className="rounded-full"

Some files were not shown because too many files have changed in this diff Show More