diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index dcb298e49..1a047710a 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -41,6 +41,7 @@ import { listen } from '@tauri-apps/api/event' import { SystemEvent } from '@/types/events' import { CompletionMessagesBuilder } from '@/lib/messages' import { ChatCompletionMessageToolCall } from 'openai/resources' +import { getTools } from '@/services/mcp' type ChatInputProps = { className?: string @@ -93,16 +94,15 @@ const ChatInput = ({ className, showSpeedToken = true }: ChatInputProps) => { }, []) useEffect(() => { - window.core?.api?.getTools().then((data: MCPTool[]) => { - setTools(data) - }) - - let unsubscribe = () => {} - listen(SystemEvent.MCP_UPDATE, () => { - window.core?.api?.getTools().then((data: MCPTool[]) => { + function updateTools() { + getTools().then((data: MCPTool[]) => { setTools(data) }) - }).then((unsub) => { + } + updateTools() + + let unsubscribe = () => {} + listen(SystemEvent.MCP_UPDATE, updateTools).then((unsub) => { // Unsubscribe from the event when the component unmounts unsubscribe = unsub }) @@ -199,8 +199,11 @@ const ChatInput = ({ className, showSpeedToken = true }: ChatInputProps) => { accumulatedText ) builder.addAssistantMessage(accumulatedText, undefined, toolCalls) - const updatedMessage = await postMessageProcessing(toolCalls, builder, finalContent) - console.log(updatedMessage) + const updatedMessage = await postMessageProcessing( + toolCalls, + builder, + finalContent + ) addMessage(updatedMessage ?? finalContent) isCompleted = !toolCalls.length diff --git a/web-app/src/containers/DownloadManegement.tsx b/web-app/src/containers/DownloadManegement.tsx index dbeb7251b..79d90aeed 100644 --- a/web-app/src/containers/DownloadManegement.tsx +++ b/web-app/src/containers/DownloadManegement.tsx @@ -4,25 +4,122 @@ import { PopoverTrigger, } from '@/components/ui/popover' import { Progress } from '@/components/ui/progress' +import { useDownloadStore } from '@/hooks/useDownloadStore' +import { abortDownload } from '@/services/models' +import { DownloadEvent, DownloadState, events } from '@janhq/core' import { IconPlayerPauseFilled, IconX } from '@tabler/icons-react' +import { useCallback, useEffect, useMemo } from 'react' export function DownloadManagement() { + const { downloads, updateProgress, removeDownload } = useDownloadStore() + const downloadCount = useMemo( + () => Object.keys(downloads).length, + [downloads] + ) + const downloadProcesses = useMemo( + () => + Object.values(downloads).map((download) => ({ + id: download.id, + name: download.name, + progress: download.progress, + current: download.current, + total: download.total, + })), + [downloads] + ) + + const overallProgress = useMemo(() => { + const total = downloadProcesses.reduce((acc, download) => { + return acc + download.total + }, 0) + const current = downloadProcesses.reduce((acc, download) => { + return acc + download.current + }, 0) + return total > 0 ? current / total : 0 + }, [downloadProcesses]) + + const onFileDownloadUpdate = useCallback( + async (state: DownloadState) => { + console.debug('onFileDownloadUpdate', state) + updateProgress( + state.modelId, + state.percent, + state.modelId, + state.size?.transferred, + state.size?.total + ) + }, + [updateProgress] + ) + + const onFileDownloadError = useCallback( + (state: DownloadState) => { + console.debug('onFileDownloadError', state) + removeDownload(state.modelId) + }, + [removeDownload] + ) + + const onFileDownloadStopped = useCallback( + (state: DownloadState) => { + console.debug('onFileDownloadError', state) + removeDownload(state.modelId) + }, + [removeDownload] + ) + + const onFileDownloadSuccess = useCallback( + async (state: DownloadState) => { + console.debug('onFileDownloadSuccess', state) + removeDownload(state.modelId) + }, + [removeDownload] + ) + + useEffect(() => { + console.debug('DownloadListener: registering event listeners...') + events.on(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate) + events.on(DownloadEvent.onFileDownloadError, onFileDownloadError) + events.on(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess) + events.on(DownloadEvent.onFileDownloadStopped, onFileDownloadStopped) + + return () => { + console.debug('DownloadListener: unregistering event listeners...') + events.off(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate) + events.off(DownloadEvent.onFileDownloadError, onFileDownloadError) + events.off(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess) + events.off(DownloadEvent.onFileDownloadStopped, onFileDownloadStopped) + } + }, [ + onFileDownloadUpdate, + onFileDownloadError, + onFileDownloadSuccess, + onFileDownloadStopped, + ]) + + function renderGB(bytes: number): string { + const gb = bytes / 1024 ** 3 + return ((gb * 100) / 100).toFixed(2) + } + return ( - -
-
- 2 + {downloadCount > 0 && ( + +
+
+ {downloadCount} +
+

Downloads

+
+ + + {overallProgress.toFixed(2)}% + +
-

Downloads

-
- - - 20% - -
-
- + + )} Downloading

-
-
-

llama3.2:1b

-
- - + {downloadProcesses.map((download) => ( +
+
+

+ {download.name} +

+
+ {/* */} + abortDownload(download.name)} + /> +
-
- -

- 1065.28 MB/4.13 GB (25%) -

-
- -
-
-

- deepseek-r1:1.5b + +

+ {`${renderGB(download.current)} / ${renderGB(download.total)}`}{' '} + GB ({download.progress.toFixed(2)}%)

-
- - -
- -

- 1065.28 MB/4.13 GB (80%) -

-
+ ))}
diff --git a/web-app/src/hooks/useDownloadStore.ts b/web-app/src/hooks/useDownloadStore.ts new file mode 100644 index 000000000..8a0e6ac19 --- /dev/null +++ b/web-app/src/hooks/useDownloadStore.ts @@ -0,0 +1,49 @@ +import { create } from 'zustand' + +export interface DownloadProgressProps { + id: string + progress: number + name: string + current: number + total: number +} + +// Zustand store for thinking block state +export type DownloadState = { + downloads: { [id: string]: DownloadProgressProps } + removeDownload: (id: string) => void + updateProgress: ( + id: string, + progress: number, + name?: string, + current?: number, + total?: number + ) => void +} + +/** + * This store is used to manage the download progress of files. + */ +export const useDownloadStore = create((set) => ({ + downloads: {}, + removeDownload: (id: string) => + set((state) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [id]: _, ...rest } = state.downloads + return { downloads: rest } + }), + + updateProgress: (id, progress, name, current, total) => + set((state) => ({ + downloads: { + ...state.downloads, + [id]: { + ...state.downloads[id], + name: name || state.downloads[id]?.name || '', + progress, + current: current || state.downloads[id]?.current || 0, + total: total || state.downloads[id]?.total || 0, + }, + }, + })), +})) diff --git a/web-app/src/lib/completion.ts b/web-app/src/lib/completion.ts index b771a6d08..8e7d4bdd2 100644 --- a/web-app/src/lib/completion.ts +++ b/web-app/src/lib/completion.ts @@ -20,6 +20,7 @@ import { normalizeProvider } from './models' import { MCPTool } from '@/types/completion' import { CompletionMessagesBuilder } from './messages' import { ChatCompletionMessageToolCall } from 'openai/resources' +import { callTool } from '@/services/mcp' /** * @fileoverview Helper functions for creating thread content. @@ -224,8 +225,7 @@ export const extractToolCall = ( } if (deltaToolCalls[0]?.function?.arguments) { - currentCall!.function.arguments += - deltaToolCalls[0].function.arguments + currentCall!.function.arguments += deltaToolCalls[0].function.arguments } } } @@ -268,7 +268,7 @@ export const postMessageProcessing = async ( ], } - const result = await window.core.api.callTool({ + const result = await callTool({ toolName: toolCall.function.name, arguments: toolCall.function.arguments.length ? JSON.parse(toolCall.function.arguments) diff --git a/web-app/src/lib/models.ts b/web-app/src/lib/models.ts index 2eddc5d40..250a3a9b5 100644 --- a/web-app/src/lib/models.ts +++ b/web-app/src/lib/models.ts @@ -65,5 +65,5 @@ export const extractModelRepo = (model?: string) => { */ export const normalizeProvider = (provider: string) => { // TODO: After migrating to the new provider extension, remove this function - return provider === 'llama.cpp' ? 'llama-cpp' : provider + return provider === 'llama.cpp' ? 'cortex' : provider } diff --git a/web-app/src/providers/ExtensionProvider.tsx b/web-app/src/providers/ExtensionProvider.tsx index e71f87d47..209c4daad 100644 --- a/web-app/src/providers/ExtensionProvider.tsx +++ b/web-app/src/providers/ExtensionProvider.tsx @@ -1,5 +1,6 @@ import { ExtensionManager } from '@/lib/extension' import { APIs } from '@/lib/service' +import { EventEmitter } from '@/services/eventsService' import { EngineManager, ModelManager } from '@janhq/core' import { PropsWithChildren, useCallback, useEffect, useState } from 'react' @@ -10,6 +11,7 @@ export function ExtensionProvider({ children }: PropsWithChildren) { api: APIs, } + window.core.events = new EventEmitter() window.core.extensionManager = new ExtensionManager() window.core.engineManager = new EngineManager() window.core.modelManager = new ModelManager() diff --git a/web-app/src/routes/hub.tsx b/web-app/src/routes/hub.tsx index ab0ecc3dd..b5b004fe1 100644 --- a/web-app/src/routes/hub.tsx +++ b/web-app/src/routes/hub.tsx @@ -15,6 +15,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { downloadModel } from '@/services/models' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.hub as any)({ @@ -153,7 +154,13 @@ function Hub() { {toGigabytes(model.models?.[0]?.size)} - +
} @@ -232,6 +239,9 @@ function Hub() {
+ downloadModel(variant.id) + } > + + constructor() { + this.handlers = new Map() + } + + public on(eventName: string, handler: Function): void { + if (!this.handlers.has(eventName)) { + this.handlers.set(eventName, []) + } + + this.handlers.get(eventName)?.push(handler) + } + + public off(eventName: string, handler: Function): void { + if (!this.handlers.has(eventName)) { + return + } + + const handlers = this.handlers.get(eventName) + const index = handlers?.indexOf(handler) + + if (index !== undefined && index !== -1) { + handlers?.splice(index, 1) + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public emit(eventName: string, args: any): void { + if (!this.handlers.has(eventName)) { + return + } + + const handlers = this.handlers.get(eventName) + + handlers?.forEach((handler) => { + handler(args) + }) + } +} diff --git a/web-app/src/services/mcp.ts b/web-app/src/services/mcp.ts index 701718b7f..44e7c34fa 100644 --- a/web-app/src/services/mcp.ts +++ b/web-app/src/services/mcp.ts @@ -1,10 +1,32 @@ +import { MCPTool } from '@/types/completion' /** * @description This file contains the functions to interact with the MCP API. * It includes functions to get and update the MCP configuration. - * @param configs + * @param configs */ export const updateMCPConfig = async (configs: string) => { await window.core?.api?.saveMcpConfigs({ configs }) await window.core?.api?.restartMcpServers() } + +/** + * @description This function gets the MCP configuration. + * @returns {Promise} The MCP configuration. + */ +export const getTools = (): Promise => { + return window.core?.api?.getTools() +} + +/** + * @description This function invoke an MCP tool + * @param tool + * @param params + * @returns + */ +export const callTool = (args: { + toolName: string + arguments: object +}): Promise => { + return window.core?.api?.callTool(args) +} diff --git a/web-app/src/services/models.ts b/web-app/src/services/models.ts index ecff6742a..a6420f8e4 100644 --- a/web-app/src/services/models.ts +++ b/web-app/src/services/models.ts @@ -97,3 +97,43 @@ export const updateModel = async ( throw error } } + +/** + * Downloads a model. + * @param model The model to download. + * @returns A promise that resolves when the model download task is created. + */ +export const downloadModel = async (id: string) => { + const extension = ExtensionManager.getInstance().get( + ExtensionTypeEnum.Model + ) + + if (!extension) throw new Error('Model extension not found') + + try { + return await extension.pullModel(id) + } catch (error) { + console.error('Failed to download model:', error) + throw error + } +} + +/** + * Aborts a model download. + * @param id + * @returns + */ +export const abortDownload = async (id: string) => { + const extension = ExtensionManager.getInstance().get( + ExtensionTypeEnum.Model + ) + + if (!extension) throw new Error('Model extension not found') + + try { + return await extension.cancelModelPull(id) + } catch (error) { + console.error('Failed to abort model download:', error) + throw error + } +}