feat: permission dialog for tool call requests
This commit is contained in:
parent
1a3cc64a7e
commit
e1995a3ccb
104
web/containers/ToolCallApprovalModal/index.tsx
Normal file
104
web/containers/ToolCallApprovalModal/index.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
|
||||
import { Button, Modal, ModalClose } from '@janhq/joi'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { approvedThreadToolsAtom } from '@/helpers/atoms/Thread.atom'
|
||||
|
||||
export function useTollCallPromiseModal() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const setApprovedToolsAtom = useSetAtom(approvedThreadToolsAtom)
|
||||
const [modalProps, setModalProps] = useState<{
|
||||
toolName: string
|
||||
threadId: string
|
||||
resolveRef: ((value: unknown) => void) | null
|
||||
}>({
|
||||
toolName: '',
|
||||
threadId: '',
|
||||
resolveRef: null,
|
||||
})
|
||||
|
||||
// Function to open the modal and return a Promise
|
||||
const showModal = useCallback((toolName: string, threadId: string) => {
|
||||
return new Promise((resolve) => {
|
||||
setModalProps({
|
||||
toolName,
|
||||
threadId,
|
||||
resolveRef: resolve,
|
||||
})
|
||||
setIsOpen(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const PromiseModal = useCallback(() => {
|
||||
const handleConfirm = () => {
|
||||
setIsOpen(false)
|
||||
if (modalProps.resolveRef) {
|
||||
modalProps.resolveRef(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false)
|
||||
if (modalProps.resolveRef) {
|
||||
modalProps.resolveRef(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<span>Allow tool from {modalProps.toolName} (local)?</span>}
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(!isOpen)
|
||||
if(!open)
|
||||
handleCancel()
|
||||
}}
|
||||
content={
|
||||
<div>
|
||||
<p className="text-[hsla(var(--text-secondary))]">
|
||||
Malicious MCP servers or conversation content could potentially
|
||||
trick Jan into attempting harmful actions through your installed
|
||||
tools. Review each action carefully before approving.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-x-2">
|
||||
<ModalClose asChild>
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() => {
|
||||
setApprovedToolsAtom((prev) => {
|
||||
const newState = { ...prev }
|
||||
if (!newState[modalProps.threadId]) {
|
||||
newState[modalProps.threadId] = []
|
||||
}
|
||||
if (
|
||||
!newState[modalProps.threadId].includes(
|
||||
modalProps.toolName
|
||||
)
|
||||
) {
|
||||
newState[modalProps.threadId].push(modalProps.toolName)
|
||||
}
|
||||
return newState
|
||||
})
|
||||
handleConfirm()
|
||||
}}
|
||||
autoFocus
|
||||
>
|
||||
Allow for this chat
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<ModalClose asChild>
|
||||
<Button theme="primary" onClick={handleConfirm} autoFocus>
|
||||
Allow once
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<ModalClose asChild onClick={handleCancel}>
|
||||
<Button theme="ghost">Deny</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}, [isOpen, modalProps])
|
||||
return { showModal, PromiseModal }
|
||||
}
|
||||
@ -72,6 +72,11 @@ export const threadDataReadyAtom = atomWithStorage<boolean>(
|
||||
*/
|
||||
export const threadModelParamsAtom = atom<Record<string, ModelParams>>({})
|
||||
|
||||
/**
|
||||
* Store the tool call approval thread id
|
||||
*/
|
||||
export const approvedThreadToolsAtom = atom<Record<string, string[]>>({})
|
||||
|
||||
//// End Thread Atom
|
||||
|
||||
/// Active Thread Atom
|
||||
|
||||
@ -55,6 +55,7 @@ import {
|
||||
import { selectedModelAtom } from '@/helpers/atoms/Model.atom'
|
||||
import {
|
||||
activeThreadAtom,
|
||||
approvedThreadToolsAtom,
|
||||
engineParamsUpdateAtom,
|
||||
getActiveThreadModelParamsAtom,
|
||||
isGeneratingResponseAtom,
|
||||
@ -65,7 +66,9 @@ import { ModelTool } from '@/types/model'
|
||||
|
||||
export const reloadModelAtom = atom(false)
|
||||
|
||||
export default function useSendChatMessage() {
|
||||
export default function useSendChatMessage(
|
||||
showModal?: (toolName: string, threadId: string) => Promise<unknown>
|
||||
) {
|
||||
const activeThread = useAtomValue(activeThreadAtom)
|
||||
const activeAssistant = useAtomValue(activeAssistantAtom)
|
||||
const addNewMessage = useSetAtom(addNewMessageAtom)
|
||||
@ -74,6 +77,7 @@ export default function useSendChatMessage() {
|
||||
const setCurrentPrompt = useSetAtom(currentPromptAtom)
|
||||
const deleteMessage = useSetAtom(deleteMessageAtom)
|
||||
const setEditPrompt = useSetAtom(editPromptAtom)
|
||||
const approvedTools = useAtomValue(approvedThreadToolsAtom)
|
||||
|
||||
const currentMessages = useAtomValue(getCurrentChatMessagesAtom)
|
||||
const selectedModel = useAtomValue(selectedModelAtom)
|
||||
@ -480,10 +484,25 @@ export default function useSendChatMessage() {
|
||||
}
|
||||
events.emit(MessageEvent.OnMessageUpdate, message)
|
||||
|
||||
const result = await window.core.api.callTool({
|
||||
toolName: toolCall.function.name,
|
||||
arguments: JSON.parse(toolCall.function.arguments),
|
||||
})
|
||||
const approved =
|
||||
approvedTools[message.thread_id]?.includes(toolCall.function.name) ||
|
||||
(showModal
|
||||
? await showModal(toolCall.function.name, message.thread_id)
|
||||
: true)
|
||||
|
||||
const result = approved
|
||||
? await window.core.api.callTool({
|
||||
toolName: toolCall.function.name,
|
||||
arguments: JSON.parse(toolCall.function.arguments),
|
||||
})
|
||||
: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The user has chosen to disallow the tool call.',
|
||||
},
|
||||
],
|
||||
}
|
||||
if (result.error) break
|
||||
|
||||
message.metadata = {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useCallback, useEffect, useMemo, useRef, ClipboardEvent } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, ClipboardEvent, useContext } from 'react'
|
||||
|
||||
import { MessageStatus } from '@janhq/core'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
@ -28,6 +28,7 @@ import {
|
||||
getActiveThreadIdAtom,
|
||||
activeSettingInputBoxAtom,
|
||||
} from '@/helpers/atoms/Thread.atom'
|
||||
import { ChatContext } from '../../ThreadCenterPanel'
|
||||
|
||||
type CustomElement = {
|
||||
type: 'paragraph' | 'code' | null
|
||||
@ -77,7 +78,8 @@ const RichTextEditor = ({
|
||||
const activeThreadId = useAtomValue(getActiveThreadIdAtom)
|
||||
const activeSettingInputBox = useAtomValue(activeSettingInputBoxAtom)
|
||||
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
||||
const { sendChatMessage } = useSendChatMessage()
|
||||
const { showApprovalModal } = useContext(ChatContext)
|
||||
const { sendChatMessage } = useSendChatMessage(showApprovalModal)
|
||||
const { stopInference } = useActiveModel()
|
||||
const selectedModel = useAtomValue(selectedModelAtom)
|
||||
const largeContentThreshold = 1000
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { InferenceEngine } from '@janhq/core'
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
badgeVariants,
|
||||
Badge,
|
||||
Modal,
|
||||
ModalClose,
|
||||
} from '@janhq/joi'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import {
|
||||
@ -56,6 +55,7 @@ import {
|
||||
} from '@/helpers/atoms/Thread.atom'
|
||||
import { activeTabThreadRightPanelAtom } from '@/helpers/atoms/ThreadRightPanel.atom'
|
||||
import { ModelTool } from '@/types/model'
|
||||
import { ChatContext } from '../../ThreadCenterPanel'
|
||||
|
||||
const ChatInput = () => {
|
||||
const activeThread = useAtomValue(activeThreadAtom)
|
||||
@ -66,7 +66,8 @@ const ChatInput = () => {
|
||||
const [activeSettingInputBox, setActiveSettingInputBox] = useAtom(
|
||||
activeSettingInputBoxAtom
|
||||
)
|
||||
const { sendChatMessage } = useSendChatMessage()
|
||||
const { showApprovalModal } = useContext(ChatContext)
|
||||
const { sendChatMessage } = useSendChatMessage(showApprovalModal)
|
||||
const selectedModel = useAtomValue(selectedModelAtom)
|
||||
|
||||
const activeThreadId = useAtomValue(getActiveThreadIdAtom)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
ConversationalExtension,
|
||||
@ -31,6 +31,7 @@ import {
|
||||
activeThreadAtom,
|
||||
getActiveThreadIdAtom,
|
||||
} from '@/helpers/atoms/Thread.atom'
|
||||
import { ChatContext } from '../../ThreadCenterPanel'
|
||||
|
||||
type Props = {
|
||||
message: ThreadMessage
|
||||
@ -42,7 +43,8 @@ const EditChatInput: React.FC<Props> = ({ message }) => {
|
||||
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
||||
|
||||
const [editPrompt, setEditPrompt] = useAtom(editPromptAtom)
|
||||
const { sendChatMessage } = useSendChatMessage()
|
||||
const { showApprovalModal } = useContext(ChatContext)
|
||||
const { sendChatMessage } = useSendChatMessage(showApprovalModal)
|
||||
const setMessages = useSetAtom(setConvoMessagesAtom)
|
||||
const activeThreadId = useAtomValue(getActiveThreadIdAtom)
|
||||
const spellCheck = useAtomValue(spellCheckAtom)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useContext } from 'react'
|
||||
|
||||
import {
|
||||
MessageStatus,
|
||||
@ -34,13 +34,15 @@ import {
|
||||
updateThreadAtom,
|
||||
updateThreadStateLastMessageAtom,
|
||||
} from '@/helpers/atoms/Thread.atom'
|
||||
import { ChatContext } from '../../ThreadCenterPanel'
|
||||
|
||||
const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
|
||||
const deleteMessage = useSetAtom(deleteMessageAtom)
|
||||
const setEditMessage = useSetAtom(editMessageAtom)
|
||||
const thread = useAtomValue(activeThreadAtom)
|
||||
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
||||
const { resendChatMessage } = useSendChatMessage()
|
||||
const { showApprovalModal } = useContext(ChatContext)
|
||||
const { resendChatMessage } = useSendChatMessage(showApprovalModal)
|
||||
const clipboard = useClipboard({ timeout: 1000 })
|
||||
const updateThreadLastMessage = useSetAtom(updateThreadStateLastMessageAtom)
|
||||
const updateThread = useSetAtom(updateThreadAtom)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { createContext, memo, useEffect, useState } from 'react'
|
||||
|
||||
import { Accept, useDropzone } from 'react-dropzone'
|
||||
|
||||
@ -36,6 +36,7 @@ import {
|
||||
engineParamsUpdateAtom,
|
||||
isGeneratingResponseAtom,
|
||||
} from '@/helpers/atoms/Thread.atom'
|
||||
import { useTollCallPromiseModal } from '@/containers/ToolCallApprovalModal'
|
||||
|
||||
const renderError = (code: string) => {
|
||||
switch (code) {
|
||||
@ -53,6 +54,16 @@ const renderError = (code: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatContextType {
|
||||
showApprovalModal:
|
||||
| ((toolName: string, threadId: string) => Promise<unknown>)
|
||||
| undefined
|
||||
}
|
||||
|
||||
export const ChatContext = createContext<ChatContextType>({
|
||||
showApprovalModal: undefined,
|
||||
})
|
||||
|
||||
const ThreadCenterPanel = () => {
|
||||
const [dragRejected, setDragRejected] = useState({ code: '' })
|
||||
const setFileUpload = useSetAtom(fileUploadAtom)
|
||||
@ -71,6 +82,7 @@ const ThreadCenterPanel = () => {
|
||||
: {
|
||||
'application/pdf': ['.pdf'],
|
||||
}
|
||||
const { showModal, PromiseModal } = useTollCallPromiseModal()
|
||||
|
||||
const { getRootProps, isDragReject } = useDropzone({
|
||||
noClick: true,
|
||||
@ -158,73 +170,82 @@ const ThreadCenterPanel = () => {
|
||||
const isGeneratingResponse = useAtomValue(isGeneratingResponseAtom)
|
||||
|
||||
return (
|
||||
<CenterPanelContainer>
|
||||
<div
|
||||
className="relative flex h-full w-full flex-col outline-none"
|
||||
{...getRootProps()}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="absolute z-50 mx-auto h-full w-full p-8 backdrop-blur-lg">
|
||||
<div
|
||||
className={twMerge(
|
||||
'flex h-full w-full items-center justify-center rounded-lg border border-dashed border-[hsla(var(--primary-bg))]',
|
||||
isDragReject && 'border-[hsla(var(--destructive-bg))]'
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-1/2 text-center">
|
||||
<div className="mx-auto inline-flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<UploadCloudIcon
|
||||
size={24}
|
||||
className="text-[hsla(var(--primary-bg))]"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 text-[hsla(var(--primary-bg))]">
|
||||
<h6 className="font-bold">
|
||||
{isDragReject
|
||||
? `Currently, we only support 1 attachment at the same time with ${
|
||||
activeAssistant?.model.settings?.mmproj
|
||||
? 'PDF, JPEG, JPG, PNG'
|
||||
: 'PDF'
|
||||
} format`
|
||||
: 'Drop file here'}
|
||||
</h6>
|
||||
{!isDragReject && (
|
||||
<p className="mt-2">
|
||||
{activeAssistant?.model.settings?.mmproj
|
||||
? 'PDF, JPEG, JPG, PNG'
|
||||
: 'PDF'}
|
||||
</p>
|
||||
)}
|
||||
<ChatContext.Provider
|
||||
value={{
|
||||
showApprovalModal: showModal,
|
||||
}}
|
||||
>
|
||||
<CenterPanelContainer>
|
||||
<div
|
||||
className="relative flex h-full w-full flex-col outline-none"
|
||||
{...getRootProps()}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="absolute z-50 mx-auto h-full w-full p-8 backdrop-blur-lg">
|
||||
<div
|
||||
className={twMerge(
|
||||
'flex h-full w-full items-center justify-center rounded-lg border border-dashed border-[hsla(var(--primary-bg))]',
|
||||
isDragReject && 'border-[hsla(var(--destructive-bg))]'
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-1/2 text-center">
|
||||
<div className="mx-auto inline-flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<UploadCloudIcon
|
||||
size={24}
|
||||
className="text-[hsla(var(--primary-bg))]"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 text-[hsla(var(--primary-bg))]">
|
||||
<h6 className="font-bold">
|
||||
{isDragReject
|
||||
? `Currently, we only support 1 attachment at the same time with ${
|
||||
activeAssistant?.model.settings?.mmproj
|
||||
? 'PDF, JPEG, JPG, PNG'
|
||||
: 'PDF'
|
||||
} format`
|
||||
: 'Drop file here'}
|
||||
</h6>
|
||||
{!isDragReject && (
|
||||
<p className="mt-2">
|
||||
{activeAssistant?.model.settings?.mmproj
|
||||
? 'PDF, JPEG, JPG, PNG'
|
||||
: 'PDF'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={twMerge('flex h-full w-full flex-col justify-between')}>
|
||||
{activeThread ? (
|
||||
<div className="flex h-full w-full overflow-x-hidden">
|
||||
<ChatBody />
|
||||
</div>
|
||||
) : (
|
||||
<RequestDownloadModel />
|
||||
)}
|
||||
|
||||
{!engineParamsUpdate && <ModelStart />}
|
||||
|
||||
{reloadModel && <ModelReload />}
|
||||
|
||||
{activeModel && isGeneratingResponse && <GenerateResponse />}
|
||||
<div
|
||||
className={twMerge(
|
||||
'mx-auto w-full',
|
||||
chatWidth === 'compact' && 'max-w-[700px]'
|
||||
)}
|
||||
className={twMerge('flex h-full w-full flex-col justify-between')}
|
||||
>
|
||||
<ChatInput />
|
||||
{activeThread ? (
|
||||
<div className="flex h-full w-full overflow-x-hidden">
|
||||
<ChatBody />
|
||||
</div>
|
||||
) : (
|
||||
<RequestDownloadModel />
|
||||
)}
|
||||
|
||||
{!engineParamsUpdate && <ModelStart />}
|
||||
|
||||
{reloadModel && <ModelReload />}
|
||||
|
||||
{activeModel && isGeneratingResponse && <GenerateResponse />}
|
||||
<div
|
||||
className={twMerge(
|
||||
'mx-auto w-full',
|
||||
chatWidth === 'compact' && 'max-w-[700px]'
|
||||
)}
|
||||
>
|
||||
<ChatInput />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CenterPanelContainer>
|
||||
</CenterPanelContainer>
|
||||
<PromiseModal />
|
||||
</ChatContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user