diff --git a/.github/workflows/jan-plugins.yml b/.github/workflows/jan-plugins.yml index 4b36bdead..5f36edf17 100644 --- a/.github/workflows/jan-plugins.yml +++ b/.github/workflows/jan-plugins.yml @@ -88,7 +88,11 @@ jobs: do echo $dir cd $dir - npm install && npm run postinstall && ../../.github/scripts/auto-sign.sh + npm install + if [[ $dir == 'data-plugin' ]]; then + npm run build:deps + fi + npm run postinstall && ../../.github/scripts/auto-sign.sh if [[ $GITHUB_EVENT_NAME == 'push' && $GITHUB_EVENT_PULL_REQUEST_HEAD_REPO_FULL_NAME != $GITHUB_REPOSITORY ]]; then npm publish --access public fi diff --git a/core/events.ts b/core/events.ts index 224d6dc67..ae52f297f 100644 --- a/core/events.ts +++ b/core/events.ts @@ -6,6 +6,7 @@ export enum EventName { OnNewMessageRequest = "onNewMessageRequest", OnNewMessageResponse = "onNewMessageResponse", OnMessageResponseUpdate = "onMessageResponseUpdate", + OnMessageResponseFinished = "OnMessageResponseFinished", OnDownloadUpdate = "onDownloadUpdate", OnDownloadSuccess = "onDownloadSuccess", OnDownloadError = "onDownloadError", diff --git a/core/package.json b/core/package.json index 9550cf3d6..c74f87800 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@janhq/core", - "version": "0.1.7", + "version": "0.1.8", "description": "Plugin core lib", "keywords": [ "jan", diff --git a/electron/tests/my-models.e2e.spec.ts b/electron/tests/my-models.e2e.spec.ts index 627612ea8..788b80fc0 100644 --- a/electron/tests/my-models.e2e.spec.ts +++ b/electron/tests/my-models.e2e.spec.ts @@ -36,11 +36,6 @@ test.afterAll(async () => { test("shows my models", async () => { await page.getByTestId("My Models").first().click(); - const header = await page - .getByRole("heading") - .filter({ hasText: "My Models" }) - .first() - .isVisible(); - expect(header).toBe(false); + await page.getByTestId("testid-mymodels-header").isVisible(); // More test cases here... }); diff --git a/electron/tsconfig.json b/electron/tsconfig.json index 1880eed7c..8276542b2 100644 --- a/electron/tsconfig.json +++ b/electron/tsconfig.json @@ -10,9 +10,10 @@ "noEmitOnError": true, "baseUrl": ".", "allowJs": true, + "skipLibCheck": true, "paths": { "*": ["node_modules/*"] }, "typeRoots": ["node_modules/@types"] }, "include": ["./**/*.ts"], - "exclude": ["core", "build", "dist", "tests"] + "exclude": ["core", "build", "dist", "tests", "node_modules"] } diff --git a/package.json b/package.json index f3147b042..53c046c72 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "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 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: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": "yarn build:web && yarn build:electron", "build:darwin": "yarn build:web && yarn workspace jan build:darwin", "build:win32": "yarn build:web && yarn workspace jan build:win32", diff --git a/plugins/data-plugin/README.md b/plugins/data-plugin/README.md index 2197d9ad2..c2fd319d2 100644 --- a/plugins/data-plugin/README.md +++ b/plugins/data-plugin/README.md @@ -6,3 +6,4 @@ - 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. + diff --git a/plugins/data-plugin/package.json b/plugins/data-plugin/package.json index 68192c5f4..8ac3136f4 100644 --- a/plugins/data-plugin/package.json +++ b/plugins/data-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@janhq/data-plugin", - "version": "1.0.14", + "version": "1.0.15", "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", @@ -12,7 +12,8 @@ ], "scripts": { "build": "tsc -b ./config/tsconfig.esm.json && tsc -b ./config/tsconfig.cjs.json && webpack --config webpack.config.js", - "postinstall": "electron-rebuild -f -w leveldown@5.6.0 --arch=arm64 -v 26.2.1 && rimraf *.tgz --glob && npm run build", + "build:deps": "electron-rebuild -f -w leveldown@5.6.0 --arch=arm64 -v 26.2.1 && electron-rebuild -f -w leveldown@5.6.0 --arch=x64 -v 26.2.1", + "postinstall": "rimraf *.tgz --glob && npm run build", "build:publish": "npm pack && cpx *.tgz ../../electron/core/pre-install" }, "exports": { @@ -30,10 +31,6 @@ "webpack": "^5.88.2", "webpack-cli": "^5.1.4" }, - "bundledDependencies": [ - "pouchdb-node", - "pouchdb-find" - ], "files": [ "dist/**", "package.json", diff --git a/plugins/inference-plugin/index.ts b/plugins/inference-plugin/index.ts index 525e7e009..4f5ba2b4c 100644 --- a/plugins/inference-plugin/index.ts +++ b/plugins/inference-plugin/index.ts @@ -140,6 +140,8 @@ async function handleMessageRequest(data: NewMessageRequest) { message.message = message.message.trim(); // TODO: Common collections should be able to access via core functions instead of store await store.updateOne("messages", message._id, message); + events.emit("OnMessageResponseFinished", message); + // events.emit(EventName.OnMessageResponseFinished, message); }, error: async (err) => { message.message = diff --git a/plugins/openai-plugin/index.ts b/plugins/openai-plugin/index.ts index 83579f44d..040d3d08a 100644 --- a/plugins/openai-plugin/index.ts +++ b/plugins/openai-plugin/index.ts @@ -85,6 +85,7 @@ const registerListener = () => { events.on(EventName.OnNewMessageRequest, handleMessageRequest); }; +// Preference update - reconfigure OpenAI const onPreferencesUpdate = () => { setup(); }; diff --git a/plugins/openai-plugin/package.json b/plugins/openai-plugin/package.json index efc456536..3f5505f24 100644 --- a/plugins/openai-plugin/package.json +++ b/plugins/openai-plugin/package.json @@ -1,11 +1,12 @@ { "name": "@janhq/azure-openai-plugin", - "version": "1.0.6", + "version": "1.0.7", "description": "Inference plugin for Azure OpenAI", "icon": "https://static-assets.jan.ai/openai-icon.jpg", "main": "dist/index.js", "module": "dist/module.js", "author": "Jan ", + "requiredVersion": "^0.3.1", "license": "MIT", "activationPoints": [ "init" diff --git a/plugins/retrieval-plugin/README.md b/plugins/retrieval-plugin/README.md index 0449b8926..5f4303159 100644 --- a/plugins/retrieval-plugin/README.md +++ b/plugins/retrieval-plugin/README.md @@ -72,3 +72,4 @@ There are a few things to keep in mind when writing your plugin code: So, what are you waiting for? Go ahead and start customizing your plugin! + diff --git a/plugins/retrieval-plugin/package.json b/plugins/retrieval-plugin/package.json index 7eca7a792..b58ea3e11 100644 --- a/plugins/retrieval-plugin/package.json +++ b/plugins/retrieval-plugin/package.json @@ -1,10 +1,11 @@ { "name": "retrieval-plugin", - "version": "1.0.2", - "description": "Retrieval plugin for Jan app", + "version": "1.0.3", + "description": "Retrieval plugin for Jan app (experimental)", "icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/circle-stack.svg", "main": "dist/index.js", "module": "dist/module.js", + "requiredVersion": "^0.3.1", "author": "Jan ", "license": "MIT", "activationPoints": [ diff --git a/web/app/_components/ExploreModelItemHeader/index.tsx b/web/app/_components/ExploreModelItemHeader/index.tsx index 5d668770c..55786dbc4 100644 --- a/web/app/_components/ExploreModelItemHeader/index.tsx +++ b/web/app/_components/ExploreModelItemHeader/index.tsx @@ -1,7 +1,5 @@ import SimpleTag from '../SimpleTag' -import PrimaryButton from '../PrimaryButton' import { formatDownloadPercentage, toGigabytes } from '@utils/converter' -import SecondaryButton from '../SecondaryButton' import { useCallback, useEffect, useMemo } from 'react' import useGetPerformanceTag from '@hooks/useGetPerformanceTag' import useDownloadModel from '@hooks/useDownloadModel' @@ -58,7 +56,6 @@ const ExploreModelItemHeader: React.FC = ({ if (isDownloaded) { downloadButton = ( ) } return ( -
+
{exploreModel.name} {performanceTag && ( diff --git a/web/app/_components/HistoryItem/index.tsx b/web/app/_components/HistoryItem/index.tsx index ebf0a4f84..19c95dd14 100644 --- a/web/app/_components/HistoryItem/index.tsx +++ b/web/app/_components/HistoryItem/index.tsx @@ -1,10 +1,8 @@ import React from 'react' import { useAtomValue, useSetAtom } from 'jotai' -import { ModelManagementService } from '@janhq/core' import { getActiveConvoIdAtom, setActiveConvoIdAtom, - updateConversationWaitingForResponseAtom, } from '@helpers/atoms/Conversation.atom' import { setMainViewStateAtom, @@ -12,11 +10,12 @@ import { } from '@helpers/atoms/MainView.atom' import { displayDate } from '@utils/datetime' import { twMerge } from 'tailwind-merge' -import { executeSerial } from '@services/pluginService' +import { activeAssistantModelAtom } from '@helpers/atoms/Model.atom' +import useStartStopModel from '@hooks/useStartStopModel' +import useGetModelById from '@hooks/useGetModelById' type Props = { conversation: Conversation - avatarUrl?: string name: string summary?: string updatedAt?: string @@ -24,24 +23,38 @@ type Props = { const HistoryItem: React.FC = ({ conversation, - avatarUrl, name, summary, updatedAt, }) => { - const setMainViewState = useSetAtom(setMainViewStateAtom) const activeConvoId = useAtomValue(getActiveConvoIdAtom) - const setActiveConvoId = useSetAtom(setActiveConvoIdAtom) - const updateConvWaiting = useSetAtom(updateConversationWaitingForResponseAtom) const isSelected = activeConvoId === conversation._id + const activeModel = useAtomValue(activeAssistantModelAtom) + const { startModel } = useStartStopModel() + const { getModelById } = useGetModelById() + + const setMainViewState = useSetAtom(setMainViewStateAtom) + const setActiveConvoId = useSetAtom(setActiveConvoIdAtom) const onClick = async () => { - const model = await executeSerial( - ModelManagementService.GetModelById, - conversation.modelId - ) + if (conversation.modelId == null) { + console.debug('modelId is undefined') + return + } - if (conversation._id) updateConvWaiting(conversation._id, true) + const model = await getModelById(conversation.modelId) + if (model != null) { + if (activeModel == null) { + // if there's no active model, we simply load conversation's model + startModel(model._id) + } else if (activeModel._id !== model._id) { + // display confirmation modal + // TODO: temporarily disabled + // setConfirmationModalProps({ + // replacingModel: model, + // }) + } + } if (activeConvoId !== conversation._id) { setMainViewState(MainViewState.Conversation) diff --git a/web/app/_components/HistoryList/index.tsx b/web/app/_components/HistoryList/index.tsx index 91847b03a..3cbb482f6 100644 --- a/web/app/_components/HistoryList/index.tsx +++ b/web/app/_components/HistoryList/index.tsx @@ -1,5 +1,5 @@ import HistoryItem from '../HistoryItem' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import ExpandableHeader from '../ExpandableHeader' import { useAtomValue } from 'jotai' import { searchAtom } from '@helpers/JotaiWrapper' @@ -33,7 +33,6 @@ const HistoryList: React.FC = () => { key={convo._id} conversation={convo} summary={convo.summary} - avatarUrl={convo.image} name={convo.name || 'Jan'} updatedAt={convo.updatedAt ?? ''} /> diff --git a/web/app/_components/InputToolbar/index.tsx b/web/app/_components/InputToolbar/index.tsx index 8ef8d0392..4895d90cf 100644 --- a/web/app/_components/InputToolbar/index.tsx +++ b/web/app/_components/InputToolbar/index.tsx @@ -5,91 +5,45 @@ import BasicPromptInput from '../BasicPromptInput' import BasicPromptAccessories from '../BasicPromptAccessories' import { useAtomValue, useSetAtom } from 'jotai' import SecondaryButton from '../SecondaryButton' -import { useEffect, useState } from 'react' import { PlusIcon } from '@heroicons/react/24/outline' import useCreateConversation from '@hooks/useCreateConversation' -import { activeAssistantModelAtom } from '@helpers/atoms/Model.atom' +import { activeAssistantModelAtom, stateModel } from '@helpers/atoms/Model.atom' import { - currentConversationAtom, currentConvoStateAtom, + getActiveConvoIdAtom, } from '@helpers/atoms/Conversation.atom' -import useGetBots from '@hooks/useGetBots' -import { activeBotAtom } from '@helpers/atoms/Bot.atom' -import { useGetDownloadedModels } from '@hooks/useGetDownloadedModels' +import useGetInputState from '@hooks/useGetInputState' +import useStartStopModel from '@hooks/useStartStopModel' +import { userConversationsAtom } from '@helpers/atoms/Conversation.atom' +import { showingModalNoActiveModel } from '@helpers/atoms/Modal.atom' const InputToolbar: React.FC = () => { const activeModel = useAtomValue(activeAssistantModelAtom) - const { requestCreateConvo } = useCreateConversation() const currentConvoState = useAtomValue(currentConvoStateAtom) - const currentConvo = useAtomValue(currentConversationAtom) - - const setActiveBot = useSetAtom(activeBotAtom) - const { getBotById } = useGetBots() - const [inputState, setInputState] = useState< - 'available' | 'disabled' | 'loading' - >() - const [error, setError] = useState() - const { downloadedModels } = useGetDownloadedModels() - - useEffect(() => { - const getReplyState = async () => { - setInputState('loading') - if (currentConvo && currentConvo.botId && currentConvo.botId.length > 0) { - // if botId is set, check if bot is available - const bot = await getBotById(currentConvo.botId) - console.debug('Found bot', JSON.stringify(bot, null, 2)) - if (bot) { - setActiveBot(bot) - } - setInputState(bot ? 'available' : 'disabled') - setError( - bot - ? undefined - : `Bot ${currentConvo.botId} has been deleted by its creator. Your chat history is saved but you won't be able to send new messages.` - ) - } else { - const model = downloadedModels.find( - (model) => model._id === activeModel?._id - ) - - setInputState(model ? 'available' : 'disabled') - setError( - model - ? undefined - : `Model ${activeModel?._id} cannot be found. Your chat history is saved but you won't be able to send new messages.` - ) - } - } - getReplyState() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentConvo]) + const { inputState, currentConvo } = useGetInputState() + const { requestCreateConvo } = useCreateConversation() + const { startModel } = useStartStopModel() + const { loading } = useAtomValue(stateModel) + const conversations = useAtomValue(userConversationsAtom) + const activeConvoId = useAtomValue(getActiveConvoIdAtom) + const setShowModalNoActiveModel = useSetAtom(showingModalNoActiveModel) const onNewConversationClick = () => { if (activeModel) { requestCreateConvo(activeModel) + } else { + setShowModalNoActiveModel(true) } } - if (inputState === 'loading') return
Loading..
+ const onStartModelClick = () => { + const modelId = currentConvo?.modelId + if (!modelId) return + startModel(modelId) + } - if (inputState === 'disabled') + if (!activeConvoId) { return ( -
-

- {error} -

-
- ) - - return ( -
- {currentConvoState?.error && ( -
- - {currentConvoState?.error?.toString()} - -
- )}
{ icon={} />
- {/* My text input */} -
-
- - + ) + } + if ( + (activeConvoId && inputState === 'model-mismatch') || + inputState === 'loading' + ) { + // const message = inputState === 'loading' ? 'Loading..' : 'Model mismatch!' + return ( +
+
+ {/*

+ {message} +

*/} +
-
- ) + ) + } + + if (inputState === 'model-not-found') { + return ( +
+

+ Model {currentConvo?.modelId} not found! Please re-download the model + first. +

+
+ ) + } + + if (conversations.length > 0) + return ( +
+ {currentConvoState?.error && ( +
+ + {currentConvoState?.error?.toString()} + +
+ )} +
+ } + /> +
+ {/* My text input */} +
+
+ + +
+
+
+ ) } export default InputToolbar diff --git a/web/app/_components/LeftHeaderAction/index.tsx b/web/app/_components/LeftHeaderAction/index.tsx index c506cb139..9c99e3260 100644 --- a/web/app/_components/LeftHeaderAction/index.tsx +++ b/web/app/_components/LeftHeaderAction/index.tsx @@ -34,12 +34,12 @@ const LeftHeaderAction: React.FC = () => { className="flex-1" icon={} /> - } - /> + /> */}
) } diff --git a/web/app/_components/ModalNoActiveModel/index.tsx b/web/app/_components/ModalNoActiveModel/index.tsx new file mode 100644 index 000000000..4af0a44a9 --- /dev/null +++ b/web/app/_components/ModalNoActiveModel/index.tsx @@ -0,0 +1,73 @@ +import React, { Fragment } from 'react' +import { Dialog, Transition } from '@headlessui/react' +import { useAtom, useSetAtom } from 'jotai' +import { showingModalNoActiveModel } from '@helpers/atoms/Modal.atom' +import { + MainViewState, + setMainViewStateAtom, +} from '@helpers/atoms/MainView.atom' + +const ModalNoActiveModel: React.FC = () => { + const [show, setShow] = useAtom(showingModalNoActiveModel) + const setMainView = useSetAtom(setMainViewStateAtom) + + return ( + + + +
+ + +
+
+ + +

+ You don’t have any actively running models. Please start a + downloaded model in My Models page to use this feature. +

+
+ + +
+
+
+
+
+
+
+ ) +} + +export default React.memo(ModalNoActiveModel) diff --git a/web/app/_components/ModelTable/index.tsx b/web/app/_components/ModelTable/index.tsx index c3f0d96cf..0f3646509 100644 --- a/web/app/_components/ModelTable/index.tsx +++ b/web/app/_components/ModelTable/index.tsx @@ -10,7 +10,7 @@ const tableHeaders = ['MODEL', 'FORMAT', 'SIZE', 'STATUS', 'ACTIONS'] const ModelTable: React.FC = ({ models }) => ( <> -
+
diff --git a/web/app/_components/SidebarEmptyHistory/index.tsx b/web/app/_components/SidebarEmptyHistory/index.tsx index 57aa5436c..1cb4e3a6c 100644 --- a/web/app/_components/SidebarEmptyHistory/index.tsx +++ b/web/app/_components/SidebarEmptyHistory/index.tsx @@ -9,6 +9,7 @@ import { activeAssistantModelAtom } from '@helpers/atoms/Model.atom' import { useGetDownloadedModels } from '@hooks/useGetDownloadedModels' import { Button } from '@uikit' import { MessageCircle } from 'lucide-react' +import { showingModalNoActiveModel } from '@helpers/atoms/Modal.atom' enum ActionButton { DownloadModel = 'Download a Model', @@ -21,6 +22,7 @@ const SidebarEmptyHistory: React.FC = () => { const setMainView = useSetAtom(setMainViewStateAtom) const { requestCreateConvo } = useCreateConversation() const [action, setAction] = useState(ActionButton.DownloadModel) + const modalNoActiveModel = useSetAtom(showingModalNoActiveModel) useEffect(() => { if (downloadedModels.length > 0) { @@ -35,7 +37,7 @@ const SidebarEmptyHistory: React.FC = () => { setMainView(MainViewState.ExploreModel) } else { if (!activeModel) { - setMainView(MainViewState.ConversationEmptyModel) + modalNoActiveModel(true) } else { await requestCreateConvo(activeModel) } @@ -44,10 +46,10 @@ const SidebarEmptyHistory: React.FC = () => { return (
- -
+ +
No Chat History
-

+

Get started by creating a new chat.

+
+
+
+
+
+ + Switching model + +
+

+ Selected conversation is using model{' '} + + {props?.replacingModel.name} + + , but the active model is using{' '} + + {activeModel?.name} + + . +

+
+

+ Switch to + + {' '} + {props?.replacingModel.name}? + +

+
+
+
+
+ + +
+ + +
+
+ + + ) +} + +export default SwitchingModelConfirmationModal diff --git a/web/containers/BottomBar/index.tsx b/web/containers/BottomBar/index.tsx index e10c14d29..3b927c44e 100644 --- a/web/containers/BottomBar/index.tsx +++ b/web/containers/BottomBar/index.tsx @@ -19,8 +19,6 @@ const BottomBar = () => { downloadStates.push(value) } - console.log(stateModelStartStop) - return (
@@ -53,7 +51,7 @@ const BottomBar = () => {
-

Jan {appVersion?.version ?? ''}

+

Jan v{appVersion?.version ?? ''}

) diff --git a/web/containers/Providers/index.tsx b/web/containers/Providers/index.tsx index e0d28a8b8..623ef9a5e 100644 --- a/web/containers/Providers/index.tsx +++ b/web/containers/Providers/index.tsx @@ -69,17 +69,17 @@ const Providers = (props: PropsWithChildren) => { return ( {setupCore && ( - - - {activated ? ( + + {activated ? ( + {children} - ) : ( -
- -
- )} -
-
+ + ) : ( +
+ +
+ )} + )}
) diff --git a/web/containers/Sidebar/Left.tsx b/web/containers/Sidebar/Left.tsx index e837058f3..5510538db 100644 --- a/web/containers/Sidebar/Left.tsx +++ b/web/containers/Sidebar/Left.tsx @@ -88,11 +88,11 @@ export const SidebarLeft = () => { icon: , state: MainViewState.MyModel, }, - { - name: 'Bot', - icon: , - state: MainViewState.CreateBot, - }, + // { + // name: 'Bot', + // icon: , + // state: MainViewState.CreateBot, + // }, { name: 'Settings', icon: , diff --git a/web/helpers/EventHandler.tsx b/web/helpers/EventHandler.tsx index 50a1be598..b155cdbbc 100644 --- a/web/helpers/EventHandler.tsx +++ b/web/helpers/EventHandler.tsx @@ -1,16 +1,49 @@ import { addNewMessageAtom, updateMessageAtom } from './atoms/ChatMessage.atom' import { toChatMessage } from '@models/ChatMessage' -import { events, EventName, NewMessageResponse } from '@janhq/core' +import { events, EventName, NewMessageResponse, DataService } from '@janhq/core' import { useSetAtom } from 'jotai' import { ReactNode, useEffect } from 'react' +import useGetBots from '@hooks/useGetBots' +import useGetUserConversations from '@hooks/useGetUserConversations' +import { + updateConversationAtom, + updateConversationWaitingForResponseAtom, +} from './atoms/Conversation.atom' +import { executeSerial } from '../../electron/core/plugin-manager/execution/extension-manager' +import { debounce } from 'lodash' + +let currentConversation: Conversation | undefined = undefined + +const debouncedUpdateConversation = debounce( + async (updatedConv: Conversation) => { + await executeSerial(DataService.UpdateConversation, updatedConv) + }, + 1000 +) export default function EventHandler({ children }: { children: ReactNode }) { const addNewMessage = useSetAtom(addNewMessageAtom) const updateMessage = useSetAtom(updateMessageAtom) + const updateConversation = useSetAtom(updateConversationAtom) + const { getBotById } = useGetBots() + const { getConversationById } = useGetUserConversations() - function handleNewMessageResponse(message: NewMessageResponse) { - const newResponse = toChatMessage(message) - addNewMessage(newResponse) + const updateConvWaiting = useSetAtom(updateConversationWaitingForResponseAtom) + + async function handleNewMessageResponse(message: NewMessageResponse) { + if (message.conversationId) { + const convo = await getConversationById(message.conversationId) + const botId = convo?.botId + console.debug('botId', botId) + if (botId) { + const bot = await getBotById(botId) + const newResponse = toChatMessage(message, bot) + addNewMessage(newResponse) + } else { + const newResponse = toChatMessage(message) + addNewMessage(newResponse) + } + } } async function handleMessageResponseUpdate( messageResponse: NewMessageResponse @@ -19,18 +52,51 @@ export default function EventHandler({ children }: { children: ReactNode }) { messageResponse.conversationId && messageResponse._id && messageResponse.message - ) + ) { updateMessage( messageResponse._id, messageResponse.conversationId, messageResponse.message ) + } + + if (messageResponse.conversationId) { + if ( + !currentConversation || + currentConversation._id !== messageResponse.conversationId + ) { + currentConversation = await getConversationById( + messageResponse.conversationId + ) + } + + const updatedConv: Conversation = { + ...currentConversation, + lastMessage: messageResponse.message, + } + + updateConversation(updatedConv) + debouncedUpdateConversation(updatedConv) + } + } + + async function handleMessageResponseFinished( + messageResponse: NewMessageResponse + ) { + if (!messageResponse.conversationId) return + console.debug('handleMessageResponseFinished', messageResponse) + updateConvWaiting(messageResponse.conversationId, false) } useEffect(() => { if (window.corePlugin.events) { events.on(EventName.OnNewMessageResponse, handleNewMessageResponse) events.on(EventName.OnMessageResponseUpdate, handleMessageResponseUpdate) + events.on( + "OnMessageResponseFinished", + // EventName.OnMessageResponseFinished, + handleMessageResponseFinished + ) } }, []) @@ -38,7 +104,12 @@ export default function EventHandler({ children }: { children: ReactNode }) { return () => { events.off(EventName.OnNewMessageResponse, handleNewMessageResponse) events.off(EventName.OnMessageResponseUpdate, handleMessageResponseUpdate) + events.off( + "OnMessageResponseFinished", + // EventName.OnMessageResponseFinished, + handleMessageResponseFinished + ) } }, []) - return <> {children} + return <>{children} } diff --git a/web/helpers/ModalWrapper.tsx b/web/helpers/ModalWrapper.tsx index 5e7846c05..d5cb05971 100644 --- a/web/helpers/ModalWrapper.tsx +++ b/web/helpers/ModalWrapper.tsx @@ -5,6 +5,8 @@ import ConfirmDeleteConversationModal from '@/_components/ConfirmDeleteConversat import ConfirmDeleteModelModal from '@/_components/ConfirmDeleteModelModal' import ConfirmSignOutModal from '@/_components/ConfirmSignOutModal' import MobileMenuPane from '@/_components/MobileMenuPane' +import SwitchingModelConfirmationModal from '@/_components/SwitchingModelConfirmationModal' +import ModalNoActiveModel from '@/_components/ModalNoActiveModel' import { ReactNode } from 'react' type Props = { @@ -18,6 +20,8 @@ export const ModalWrapper: React.FC = ({ children }) => ( + + {children} ) diff --git a/web/helpers/atoms/Modal.atom.ts b/web/helpers/atoms/Modal.atom.ts index d371801c6..b1608089d 100644 --- a/web/helpers/atoms/Modal.atom.ts +++ b/web/helpers/atoms/Modal.atom.ts @@ -1,3 +1,4 @@ +import { SwitchingModelConfirmationModalProps } from '@/_components/SwitchingModelConfirmationModal' import { atom } from 'jotai' export const showConfirmDeleteConversationModalAtom = atom(false) @@ -7,3 +8,8 @@ export const showingAdvancedPromptAtom = atom(false) export const showingProductDetailAtom = atom(false) export const showingMobilePaneAtom = atom(false) export const showingBotListModalAtom = atom(false) + +export const switchingModelConfirmationModalPropsAtom = atom< + SwitchingModelConfirmationModalProps | undefined +>(undefined) +export const showingModalNoActiveModel = atom(false) diff --git a/web/hooks/useChatMessages.ts b/web/hooks/useChatMessages.ts index a2174c61e..28dfb21f9 100644 --- a/web/hooks/useChatMessages.ts +++ b/web/hooks/useChatMessages.ts @@ -3,11 +3,15 @@ import { executeSerial } from '@services/pluginService' import { useAtomValue, useSetAtom } from 'jotai' import { useEffect } from 'react' import { DataService } from '@janhq/core' -import { getActiveConvoIdAtom } from '@helpers/atoms/Conversation.atom' +import { + getActiveConvoIdAtom, + userConversationsAtom, +} from '@helpers/atoms/Conversation.atom' import { getCurrentChatMessagesAtom, setCurrentChatMessagesAtom, } from '@helpers/atoms/ChatMessage.atom' +import useGetBots from './useGetBots' /** * Custom hooks to get chat messages for current(active) conversation @@ -16,6 +20,8 @@ const useChatMessages = () => { const setMessages = useSetAtom(setCurrentChatMessagesAtom) const messages = useAtomValue(getCurrentChatMessagesAtom) const activeConvoId = useAtomValue(getActiveConvoIdAtom) + const userConversations = useAtomValue(userConversationsAtom) + const { getBotById } = useGetBots() const getMessages = async (convoId: string) => { const data: any = await executeSerial( @@ -26,6 +32,12 @@ const useChatMessages = () => { return [] } + const convo = userConversations.find((c) => c._id === convoId) + if (convo && convo.botId) { + const bot = await getBotById(convo.botId) + return parseMessages(data, bot) + } + return parseMessages(data) } @@ -47,10 +59,10 @@ const useChatMessages = () => { return { messages } } -function parseMessages(messages: RawMessage[]): ChatMessage[] { +function parseMessages(messages: RawMessage[], bot?: Bot): ChatMessage[] { const newMessages: ChatMessage[] = [] for (const m of messages) { - const chatMessage = toChatMessage(m) + const chatMessage = toChatMessage(m, bot) newMessages.push(chatMessage) } return newMessages diff --git a/web/hooks/useCreateConversation.ts b/web/hooks/useCreateConversation.ts index 8dde6665e..54f233c83 100644 --- a/web/hooks/useCreateConversation.ts +++ b/web/hooks/useCreateConversation.ts @@ -6,19 +6,18 @@ import { setActiveConvoIdAtom, addNewConversationStateAtom, } from '@helpers/atoms/Conversation.atom' +import useGetModelById from './useGetModelById' const useCreateConversation = () => { const [userConversations, setUserConversations] = useAtom( userConversationsAtom ) + const { getModelById } = useGetModelById() const setActiveConvoId = useSetAtom(setActiveConvoIdAtom) const addNewConvoState = useSetAtom(addNewConversationStateAtom) const createConvoByBot = async (bot: Bot) => { - const model = await executeSerial( - ModelManagementService.GetModelById, - bot.modelId - ) + const model = await getModelById(bot.modelId) if (!model) { alert( diff --git a/web/hooks/useDownloadModel.ts b/web/hooks/useDownloadModel.ts index 07fe5ef0d..78a82aed9 100644 --- a/web/hooks/useDownloadModel.ts +++ b/web/hooks/useDownloadModel.ts @@ -1,7 +1,11 @@ import { executeSerial } from '@services/pluginService' -import { DataService, ModelManagementService } from '@janhq/core' +import { ModelManagementService } from '@janhq/core' +import { useSetAtom } from 'jotai' +import { setDownloadStateAtom } from '@helpers/atoms/DownloadState.atom' export default function useDownloadModel() { + const setDownloadState = useSetAtom(setDownloadStateAtom) + const assistanModel = ( model: Product, modelVersion: ModelVersion @@ -37,6 +41,22 @@ export default function useDownloadModel() { } const downloadModel = async (model: Product, modelVersion: ModelVersion) => { + // set an initial download state + setDownloadState({ + modelId: modelVersion._id, + time: { + elapsed: 0, + remaining: 0, + }, + speed: 0, + percent: 0, + size: { + total: 0, + transferred: 0, + }, + fileName: modelVersion._id, + }) + modelVersion.startDownloadAt = Date.now() const assistantModel = assistanModel(model, modelVersion) await executeSerial(ModelManagementService.StoreModel, assistantModel) diff --git a/web/hooks/useGetInputState.ts b/web/hooks/useGetInputState.ts new file mode 100644 index 000000000..d11225597 --- /dev/null +++ b/web/hooks/useGetInputState.ts @@ -0,0 +1,52 @@ +import { currentConversationAtom } from '@helpers/atoms/Conversation.atom' +import { activeAssistantModelAtom } from '@helpers/atoms/Model.atom' +import { useAtomValue } from 'jotai' +import { useEffect, useState } from 'react' +import { useGetDownloadedModels } from './useGetDownloadedModels' + +export default function useGetInputState() { + const [inputState, setInputState] = useState('loading') + const currentConvo = useAtomValue(currentConversationAtom) + const activeModel = useAtomValue(activeAssistantModelAtom) + const { downloadedModels } = useGetDownloadedModels() + + const handleInputState = ( + convo: Conversation | undefined, + currentModel: AssistantModel | undefined, + models: AssistantModel[] + ) => { + if (convo == null) return + if (currentModel == null) { + setInputState('loading') + return + } + + // check if convo model id is in downloaded models + const isModelAvailable = downloadedModels.some( + (model) => model._id === convo.modelId + ) + + if (!isModelAvailable) { + // can't find model in downloaded models + setInputState('model-not-found') + return + } + + if (convo.modelId !== currentModel._id) { + // in case convo model and active model is different, + // ask user to init the required model + setInputState('model-mismatch') + return + } + + setInputState('available') + } + + useEffect(() => { + handleInputState(currentConvo, activeModel, downloadedModels) + }, [currentConvo, activeModel, downloadedModels]) + + return { inputState, currentConvo } +} + +type InputType = 'available' | 'loading' | 'model-mismatch' | 'model-not-found' diff --git a/web/hooks/useGetModelById.ts b/web/hooks/useGetModelById.ts new file mode 100644 index 000000000..d97abdc18 --- /dev/null +++ b/web/hooks/useGetModelById.ts @@ -0,0 +1,23 @@ +import { ModelManagementService } from '@janhq/core' +import { executeSerial } from '../../electron/core/plugin-manager/execution/extension-manager' + +export default function useGetModelById() { + const getModelById = async ( + modelId: string + ): Promise => { + return queryModelById(modelId) + } + + return { getModelById } +} + +const queryModelById = async ( + modelId: string +): Promise => { + const model = await executeSerial( + ModelManagementService.GetModelById, + modelId + ) + + return model +} diff --git a/web/hooks/useGetUserConversations.ts b/web/hooks/useGetUserConversations.ts index 88557cb6f..6055b6962 100644 --- a/web/hooks/useGetUserConversations.ts +++ b/web/hooks/useGetUserConversations.ts @@ -29,7 +29,14 @@ const useGetUserConversations = () => { } } + const getConversationById = async ( + id: string + ): Promise => { + return await executeSerial(DataService.GetConversationById, id) + } + return { + getConversationById, getUserConversations, } } diff --git a/web/hooks/useSendChatMessage.ts b/web/hooks/useSendChatMessage.ts index 8de79808e..3b8b4fcef 100644 --- a/web/hooks/useSendChatMessage.ts +++ b/web/hooks/useSendChatMessage.ts @@ -13,13 +13,14 @@ import { addNewMessageAtom } from '@helpers/atoms/ChatMessage.atom' import { currentConversationAtom, updateConversationAtom, + updateConversationWaitingForResponseAtom, } from '@helpers/atoms/Conversation.atom' export default function useSendChatMessage() { const currentConvo = useAtomValue(currentConversationAtom) const addNewMessage = useSetAtom(addNewMessageAtom) const updateConversation = useSetAtom(updateConversationAtom) - + const updateConvWaiting = useSetAtom(updateConversationWaitingForResponseAtom) const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom) let timeout: any | undefined = undefined @@ -33,18 +34,20 @@ export default function useSendChatMessage() { if ( !currentConvo?.summary || currentConvo.summary === '' || - currentConvo.summary.startsWith('User request:') + currentConvo.summary.startsWith('Prompt:') ) { // Request convo summary setTimeout(async () => { - newMessage.message = 'summary this conversation in 5 words' + newMessage.message = + 'summary this conversation in 5 words, the response should just include the summary' const result = await executeSerial( InferenceService.InferenceRequest, newMessage ) + if ( result?.message && - result.message.split(' ').length <= 7 && + result.message.split(' ').length <= 10 && conv?._id ) { const updatedConv = { @@ -60,10 +63,15 @@ export default function useSendChatMessage() { } const sendChatMessage = async () => { + const convoId = currentConvo?._id + + if (!convoId) return setCurrentPrompt('') + updateConvWaiting(convoId, true) + const prompt = currentPrompt.trim() const newMessage: RawMessage = { - conversationId: currentConvo?._id, + conversationId: convoId, message: prompt, user: 'user', createdAt: new Date().toISOString(), @@ -77,10 +85,20 @@ export default function useSendChatMessage() { events.emit(EventName.OnNewMessageRequest, newMessage) if (!currentConvo?.summary && currentConvo) { - const updatedConv = { + const updatedConv: Conversation = { ...currentConvo, + lastMessage: prompt, summary: `Prompt: ${prompt}`, } + + updateConversation(updatedConv) + await executeSerial(DataService.UpdateConversation, updatedConv) + } else { + const updatedConv: Conversation = { + ...currentConvo, + lastMessage: prompt, + } + updateConversation(updatedConv) await executeSerial(DataService.UpdateConversation, updatedConv) } diff --git a/web/hooks/useStartStopModel.ts b/web/hooks/useStartStopModel.ts index 743d8ca6d..574e000fc 100644 --- a/web/hooks/useStartStopModel.ts +++ b/web/hooks/useStartStopModel.ts @@ -1,11 +1,12 @@ import { executeSerial } from '@services/pluginService' -import { ModelManagementService, InferenceService } from '@janhq/core' +import { InferenceService } from '@janhq/core' import { useAtom, useSetAtom } from 'jotai' import { activeAssistantModelAtom, stateModel } from '@helpers/atoms/Model.atom' -import { useState } from 'react' +import useGetModelById from './useGetModelById' export default function useStartStopModel() { const [activeModel, setActiveModel] = useAtom(activeAssistantModelAtom) + const { getModelById } = useGetModelById() const setStateModel = useSetAtom(stateModel) const startModel = async (modelId: string) => { @@ -16,25 +17,27 @@ export default function useStartStopModel() { setStateModel({ state: 'start', loading: true, model: modelId }) - const model = await executeSerial( - ModelManagementService.GetModelById, - modelId - ) + const model = await getModelById(modelId) + if (!model) { alert(`Model ${modelId} not found! Please re-download the model first.`) setStateModel((prev) => ({ ...prev, loading: false })) + return } + const currentTime = Date.now() console.debug('Init model: ', model._id) - const res = await executeSerial(InferenceService.InitModel, model._id) + const res = await initModel(model._id) if (res?.error) { const errorMessage = `Failed to init model: ${res.error}` console.error(errorMessage) alert(errorMessage) } else { console.debug( - `Init model successfully!, take ${Date.now() - currentTime}ms` + `Init model ${modelId} successfully!, take ${ + Date.now() - currentTime + }ms` ) setActiveModel(model) } @@ -52,3 +55,7 @@ export default function useStartStopModel() { return { startModel, stopModel } } + +const initModel = async (modelId: string): Promise => { + return executeSerial(InferenceService.InitModel, modelId) +} diff --git a/web/models/ChatMessage.ts b/web/models/ChatMessage.ts index e074a011c..e5763c984 100644 --- a/web/models/ChatMessage.ts +++ b/web/models/ChatMessage.ts @@ -41,7 +41,8 @@ export interface RawMessage { } export const toChatMessage = ( - m: RawMessage | NewMessageResponse + m: RawMessage | NewMessageResponse, + bot?: Bot ): ChatMessage => { const createdAt = new Date(m.createdAt ?? '').getTime() const imageUrls: string[] = [] @@ -56,18 +57,18 @@ export const toChatMessage = ( const content = m.message ?? '' + let senderName = m.user === 'user' ? 'You' : 'Assistant' + if (senderName === 'Assistant' && bot) { + senderName = bot.name + } + return { id: (m._id ?? 0).toString(), conversationId: (m.conversationId ?? 0).toString(), messageType: messageType, messageSenderType: messageSenderType, senderUid: m.user?.toString() || '0', - senderName: - m.user === 'user' - ? 'You' - : m.user && m.user !== 'ai' && m.user !== 'assistant' - ? m.user - : 'Assistant', + senderName: senderName, senderAvatarUrl: m.avatar ? m.avatar : m.user === 'user' diff --git a/web/package.json b/web/package.json index 6f83b327a..030c7b303 100644 --- a/web/package.json +++ b/web/package.json @@ -28,10 +28,10 @@ "eslint-config-next": "13.4.10", "framer-motion": "^10.16.4", "highlight.js": "^11.9.0", - "react-intersection-observer": "^9.5.2", "jotai": "^2.4.0", "jotai-optics": "^0.3.1", "jwt-decode": "^3.1.2", + "lodash": "^4.17.21", "lucide-react": "^0.288.0", "marked": "^9.1.2", "marked-highlight": "^2.0.6", @@ -42,6 +42,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.45.4", + "react-intersection-observer": "^9.5.2", "sass": "^1.69.4", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", @@ -50,6 +51,7 @@ }, "devDependencies": { "@tailwindcss/forms": "^0.5.4", + "@types/lodash": "^4.14.200", "@types/node": "20.6.5", "@types/uuid": "^9.0.6", "encoding": "^0.1.13", diff --git a/web/screens/Chat/index.tsx b/web/screens/Chat/index.tsx index adc99c4ca..fe3f0217c 100644 --- a/web/screens/Chat/index.tsx +++ b/web/screens/Chat/index.tsx @@ -9,13 +9,13 @@ import LeftHeaderAction from '@/_components/LeftHeaderAction' const ChatScreen = () => { return (
-
+
-
+
diff --git a/web/screens/MyModels/index.tsx b/web/screens/MyModels/index.tsx index 22b6fe2d4..19ccf7607 100644 --- a/web/screens/MyModels/index.tsx +++ b/web/screens/MyModels/index.tsx @@ -65,7 +65,7 @@ const MyModelsScreen = () => { return (
-

My Models

+

My Models

You have {downloadedModels.length} models downloaded

diff --git a/web/screens/Settings/CorePlugins/PluginsCatalog.tsx b/web/screens/Settings/CorePlugins/PluginsCatalog.tsx index 53e2d5ff5..97af6864b 100644 --- a/web/screens/Settings/CorePlugins/PluginsCatalog.tsx +++ b/web/screens/Settings/CorePlugins/PluginsCatalog.tsx @@ -11,6 +11,7 @@ import { } from '@/../../electron/core/plugin-manager/execution/index' import { executeSerial } from '@services/pluginService' import { DataService } from '@janhq/core' +import useGetAppVersion from '@hooks/useGetAppVersion' const PluginCatalog = () => { // const [search, setSearch] = useState('') @@ -20,23 +21,41 @@ const PluginCatalog = () => { const [isLoading, setIsLoading] = useState(false) const experimentRef = useRef(null) const fileInputRef = useRef(null) - + const { version } = useGetAppVersion() /** * Loads the plugin catalog module from a CDN and sets it as the plugin catalog state. */ useEffect(() => { + if (!version) return + // Load plugin manifest from plugin if any if (extensionPoints.get(DataService.GetPluginManifest)) { executeSerial(DataService.GetPluginManifest).then((data) => { - setPluginCatalog(data) + setPluginCatalog( + data.filter( + (e: any) => + !e.requiredVersion || + e.requiredVersion.replace(/[.^]/g, '') <= + version.replaceAll('.', '') + ) + ) }) } else { // Fallback to app default manifest import( /* webpackIgnore: true */ PLUGIN_CATALOG + `?t=${Date.now()}` - ).then((data) => setPluginCatalog(data.default)) + ).then((data) => + setPluginCatalog( + data.default.filter( + (e: any) => + !e.requiredVersion || + e.requiredVersion.replace(/[.^]/g, '') <= + version.replaceAll('.', '') + ) + ) + ) } - }, []) + }, [version]) /** * Fetches the active plugins and their preferences from the `plugins` and `preferences` modules. @@ -141,11 +160,16 @@ const PluginCatalog = () => { ) .map((item, i) => { const isActivePlugin = activePlugins.some((x) => x.name === item.name) + const installedPlugin = activePlugins.filter( + (p) => p.name === item.name + )[0] const updateVersionPlugins = Number( - activePlugins - .filter((p) => p.name === item.name)[0] - ?.version.replaceAll('.', '') + installedPlugin?.version.replaceAll('.', '') ) + + const hasUpdateVersionPlugins = + item.version.replaceAll('.', '') > updateVersionPlugins + return (
{

{item.description}

- {isActivePlugin && - item.version.replaceAll('.', '') < updateVersionPlugins && ( - - )} + {isActivePlugin && ( +

+ Installed{' '} + {hasUpdateVersionPlugins + ? `v${installedPlugin.version}` + : 'the latest version'} +

+ )} + {isActivePlugin && hasUpdateVersionPlugins && ( + + )}
{
- + {preferencePlugins.length > 0 && ( + + )}
{preferencePlugins.map((menu, i) => { const isActive = activePreferencePlugin === menu diff --git a/web/styles/code-block.scss b/web/styles/code-block.scss index 0c7f68f9f..0bcdc0db7 100644 --- a/web/styles/code-block.scss +++ b/web/styles/code-block.scss @@ -61,6 +61,7 @@ border-radius: 0.4rem; margin-top: 1rem; margin-bottom: 1rem; + white-space: pre-wrap; } .hljs-emphasis { diff --git a/web/styles/main.scss b/web/styles/main.scss index 010695926..f79fa6e7b 100644 --- a/web/styles/main.scss +++ b/web/styles/main.scss @@ -7,3 +7,4 @@ @import './global.scss'; @import './code-block.scss'; @import './loader.scss'; +@import './message.scss'; diff --git a/web/styles/message.scss b/web/styles/message.scss new file mode 100644 index 000000000..d98734284 --- /dev/null +++ b/web/styles/message.scss @@ -0,0 +1,7 @@ +.message { + ul, + ol { + list-style: auto; + padding-left: 24px; + } +}