diff --git a/web-app/src/components/ui/button.tsx b/web-app/src/components/ui/button.tsx index 2027ac7b6..ea53b16cd 100644 --- a/web-app/src/components/ui/button.tsx +++ b/web-app/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[0px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer focus:outline-none", { variants: { variant: { diff --git a/web-app/src/components/ui/dialog.tsx b/web-app/src/components/ui/dialog.tsx index cb2391c81..9fa6d9158 100644 --- a/web-app/src/components/ui/dialog.tsx +++ b/web-app/src/components/ui/dialog.tsx @@ -44,11 +44,18 @@ function DialogOverlay({ ) } +type DialogContentProps = React.ComponentProps< + typeof DialogPrimitive.Content +> & { + showCloseButton?: boolean +} + function DialogContent({ + showCloseButton = true, className, children, ...props -}: React.ComponentProps) { +}: DialogContentProps) { return ( @@ -61,10 +68,12 @@ function DialogContent({ {...props} > {children} - - - Close - + {showCloseButton && ( + + + Close + + )} ) diff --git a/web-app/src/constants/localStorage.ts b/web-app/src/constants/localStorage.ts index 4fa95ca2c..5eef965cd 100644 --- a/web-app/src/constants/localStorage.ts +++ b/web-app/src/constants/localStorage.ts @@ -2,16 +2,17 @@ export const localStorageKey = { LeftPanel: 'left-panel', threads: 'threads', messages: 'messages', - assistant: 'assistant', theme: 'theme', modelProvider: 'model-provider', settingAppearance: 'setting-appearance', settingGeneral: 'setting-general', settingCodeBlock: 'setting-code-block', - settingMCPSevers: 'setting-mcp-servers', settingLocalApiServer: 'setting-local-api-server', settingProxyConfig: 'setting-proxy-config', settingHardware: 'setting-hardware', productAnalyticPrompt: 'productAnalyticPrompt', productAnalytic: 'productAnalytic', + toolApproval: 'tool-approval', + toolAvailability: 'tool-availability', + mcpGlobalPermissions: 'mcp-global-permissions', } diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index a9daf6204..86b74997d 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -27,22 +27,27 @@ import { MovingBorder } from './MovingBorder' import { useChat } from '@/hooks/useChat' import DropdownModelProvider from '@/containers/DropdownModelProvider' import { ModelLoader } from '@/containers/loaders/ModelLoader' +import DropdownToolsAvailable from '@/containers/DropdownToolsAvailable' +import { getConnectedServers } from '@/services/mcp' type ChatInputProps = { className?: string showSpeedToken?: boolean model?: ThreadModel + initialMessage?: boolean } const ChatInput = ({ model, className, showSpeedToken = true, + initialMessage, }: ChatInputProps) => { const textareaRef = useRef(null) const [isFocused, setIsFocused] = useState(false) const [rows, setRows] = useState(1) - const { streamingContent, abortControllers, loadingModel } = useAppState() + const { streamingContent, abortControllers, loadingModel, tools } = + useAppState() const { prompt, setPrompt } = usePrompt() const { currentThreadId } = useThreads() const { t } = useTranslation() @@ -62,6 +67,30 @@ const ChatInput = ({ dataUrl: string }> >([]) + const [connectedServers, setConnectedServers] = useState([]) + + // Check for connected MCP servers + useEffect(() => { + const checkConnectedServers = async () => { + try { + const servers = await getConnectedServers() + setConnectedServers(servers) + } catch (error) { + console.error('Failed to get connected servers:', error) + setConnectedServers([]) + } + } + + checkConnectedServers() + + // Poll for connected servers every 3 seconds + const intervalId = setInterval(checkConnectedServers, 3000) + + return () => clearInterval(intervalId) + }, []) + + // Check if there are active MCP servers + const hasActiveMCPServers = connectedServers.length > 0 || tools.length > 0 const handleSendMesage = (prompt: string) => { if (!selectedModel) { @@ -404,11 +433,31 @@ const ChatInput = ({ )} - {selectedModel?.capabilities?.includes('tools') && ( -
- -
- )} + {selectedModel?.capabilities?.includes('tools') && + hasActiveMCPServers && ( + + {(isOpen, toolsCount) => ( +
+ + {toolsCount > 0 && ( +
+ + {toolsCount > 99 ? '99+' : toolsCount} + +
+ )} +
+ )} +
+ )} {selectedModel?.capabilities?.includes('web_search') && (
diff --git a/web-app/src/containers/DropdownToolsAvailable.tsx b/web-app/src/containers/DropdownToolsAvailable.tsx new file mode 100644 index 000000000..da9b05c88 --- /dev/null +++ b/web-app/src/containers/DropdownToolsAvailable.tsx @@ -0,0 +1,176 @@ +import { useEffect, useState } from 'react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Switch } from '@/components/ui/switch' +import { getTools } from '@/services/mcp' +import { MCPTool } from '@/types/completion' + +import { useThreads } from '@/hooks/useThreads' +import { useToolAvailable } from '@/hooks/useToolAvailable' + +import React from 'react' + +interface DropdownToolsAvailableProps { + children: (isOpen: boolean, toolsCount: number) => React.ReactNode + initialMessage?: boolean +} + +export default function DropdownToolsAvailable({ + children, + initialMessage = false, +}: DropdownToolsAvailableProps) { + const [tools, setTools] = useState([]) + const [isOpen, setIsOpen] = useState(false) + const { getCurrentThread } = useThreads() + const { + isToolAvailable, + setToolAvailableForThread, + setDefaultAvailableTools, + initializeThreadTools, + getAvailableToolsForThread, + getDefaultAvailableTools, + } = useToolAvailable() + + const currentThread = getCurrentThread() + + useEffect(() => { + const fetchTools = async () => { + try { + const availableTools = await getTools() + setTools(availableTools) + + // If this is for the initial message (index page) and no defaults are set, + // initialize with all tools as default + if ( + initialMessage && + getDefaultAvailableTools().length === 0 && + availableTools.length > 0 + ) { + setDefaultAvailableTools(availableTools.map((tool) => tool.name)) + } + } catch (error) { + console.error('Failed to fetch tools:', error) + setTools([]) + } + } + + // Only fetch tools once when component mounts + fetchTools() + }, [initialMessage, setDefaultAvailableTools, getDefaultAvailableTools]) + + // Separate effect for thread initialization - only when we have tools and a new thread + useEffect(() => { + if (tools.length > 0 && currentThread?.id) { + initializeThreadTools(currentThread.id, tools) + } + }, [currentThread?.id, tools, initializeThreadTools]) + + const handleToolToggle = (toolName: string, checked: boolean) => { + if (initialMessage) { + // Update default tools for new threads/index page + const currentDefaults = getDefaultAvailableTools() + if (checked) { + if (!currentDefaults.includes(toolName)) { + setDefaultAvailableTools([...currentDefaults, toolName]) + } + } else { + setDefaultAvailableTools( + currentDefaults.filter((name) => name !== toolName) + ) + } + } else if (currentThread?.id) { + // Update tools for specific thread + setToolAvailableForThread(currentThread.id, toolName, checked) + } + } + + const isToolChecked = (toolName: string): boolean => { + if (initialMessage) { + // Use default tools for index page + return getDefaultAvailableTools().includes(toolName) + } else if (currentThread?.id) { + // Use thread-specific tools + return isToolAvailable(currentThread.id, toolName) + } + return false + } + + const getEnabledToolsCount = (): number => { + if (initialMessage) { + return getDefaultAvailableTools().length + } else if (currentThread?.id) { + return getAvailableToolsForThread(currentThread.id).length + } + return 0 + } + + const renderTrigger = () => children(isOpen, getEnabledToolsCount()) + + if (tools.length === 0) { + return ( + + {renderTrigger()} + + No tools available + + + ) + } + + return ( + + {renderTrigger()} + + + + Available Tools + + +
+ {tools.map((tool) => { + const isChecked = isToolChecked(tool.name) + return ( +
+
+
+
+
+

+ {tool.name} +

+ {tool.description && ( +

+ {tool.description} +

+ )} +
+ + handleToolToggle(tool.name, checked) + } + /> +
+
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/web-app/src/containers/RenderMarkdown.tsx b/web-app/src/containers/RenderMarkdown.tsx index b8c211506..77ffe149b 100644 --- a/web-app/src/containers/RenderMarkdown.tsx +++ b/web-app/src/containers/RenderMarkdown.tsx @@ -166,7 +166,13 @@ function RenderMarkdownComponent({ // Render the markdown content return ( -
+
{
diff --git a/web-app/src/containers/dialogs/ToolApproval.tsx b/web-app/src/containers/dialogs/ToolApproval.tsx new file mode 100644 index 000000000..162733274 --- /dev/null +++ b/web-app/src/containers/dialogs/ToolApproval.tsx @@ -0,0 +1,77 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useToolApproval } from '@/hooks/useToolApproval' +import { AlertTriangle } from 'lucide-react' + +export default function ToolApproval() { + const { isModalOpen, modalProps, setModalOpen } = useToolApproval() + + if (!modalProps) { + return null + } + + const { toolName, onApprove, onDeny } = modalProps + + const handleAllowOnce = () => { + onApprove(true) // true = allow once only + } + + const handleAllow = () => { + onApprove(false) // false = remember for this thread + } + + const handleDeny = () => { + onDeny() + } + + return ( + + + +
+
+ +
+
+ Tool Call Request + + The assistant wants to use the tool: {toolName} + +
+
+
+ +
+

+ Security Notice: Malicious tools or conversation + content could potentially trick the assistant into attempting + harmful actions. Review each tool call carefully before approving. +

+
+ + + + + + +
+
+ ) +} diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index f2ccfe022..7237f1b60 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -26,6 +26,9 @@ import { listen } from '@tauri-apps/api/event' import { SystemEvent } from '@/types/events' import { stopModel, startModel } from '@/services/models' +import { useToolApproval } from '@/hooks/useToolApproval' +import { useToolAvailable } from '@/hooks/useToolAvailable' + export const useChat = () => { const { prompt, setPrompt } = usePrompt() const { @@ -39,6 +42,10 @@ export const useChat = () => { } = useAppState() const { currentAssistant } = useAssistant() + const { approvedTools, showApprovalModal, allowAllMCPPermissions } = + useToolApproval() + const { getAvailableToolsForThread } = useToolAvailable() + const { getProviderByName, selectedModel, selectedProvider } = useModelProvider() @@ -123,9 +130,16 @@ export const useChat = () => { let isCompleted = false + // Filter tools based on model capabilities and available tools for this thread let availableTools = selectedModel?.capabilities?.includes('tools') - ? tools + ? tools.filter((tool) => { + const availableToolNames = getAvailableToolsForThread( + activeThread.id + ) + return availableToolNames.includes(tool.name) + }) : [] + // TODO: Later replaced by Agent setup? const followUpWithToolUse = true while (!isCompleted && !abortController.signal.aborted) { @@ -193,7 +207,10 @@ export const useChat = () => { toolCalls, builder, finalContent, - abortController + abortController, + approvedTools, + allowAllMCPPermissions ? undefined : showApprovalModal, + allowAllMCPPermissions ) addMessage(updatedMessage ?? finalContent) @@ -225,6 +242,10 @@ export const useChat = () => { tools, updateLoadingModel, updateTokenSpeed, + approvedTools, + showApprovalModal, + getAvailableToolsForThread, + allowAllMCPPermissions, ] ) diff --git a/web-app/src/hooks/useToolApproval.ts b/web-app/src/hooks/useToolApproval.ts new file mode 100644 index 000000000..5af280bee --- /dev/null +++ b/web-app/src/hooks/useToolApproval.ts @@ -0,0 +1,107 @@ +import { create } from 'zustand' +import { persist, createJSONStorage } from 'zustand/middleware' +import { localStorageKey } from '@/constants/localStorage' + +export type ToolApprovalModalProps = { + toolName: string + threadId: string + onApprove: (allowOnce: boolean) => void + onDeny: () => void +} + +type ToolApprovalState = { + // Track approved tools per thread + approvedTools: Record // threadId -> toolNames[] + // Global MCP permission toggle + allowAllMCPPermissions: boolean + // Modal state + isModalOpen: boolean + modalProps: ToolApprovalModalProps | null + + // Actions + approveToolForThread: (threadId: string, toolName: string) => void + isToolApproved: (threadId: string, toolName: string) => boolean + showApprovalModal: (toolName: string, threadId: string) => Promise + closeModal: () => void + setModalOpen: (open: boolean) => void + setAllowAllMCPPermissions: (allow: boolean) => void +} + +export const useToolApproval = create()( + persist( + (set, get) => ({ + approvedTools: {}, + allowAllMCPPermissions: false, + isModalOpen: false, + modalProps: null, + + approveToolForThread: (threadId: string, toolName: string) => { + set((state) => ({ + approvedTools: { + ...state.approvedTools, + [threadId]: [ + ...(state.approvedTools[threadId] || []), + toolName, + ].filter((tool, index, arr) => arr.indexOf(tool) === index), // Remove duplicates + }, + })) + }, + + isToolApproved: (threadId: string, toolName: string) => { + const state = get() + return state.approvedTools[threadId]?.includes(toolName) || false + }, + + showApprovalModal: (toolName: string, threadId: string) => { + return new Promise((resolve) => { + set({ + isModalOpen: true, + modalProps: { + toolName, + threadId, + onApprove: (allowOnce: boolean) => { + if (!allowOnce) { + // If not "allow once", add to approved tools for this thread + get().approveToolForThread(threadId, toolName) + } + get().closeModal() + resolve(true) + }, + onDeny: () => { + get().closeModal() + resolve(false) + }, + }, + }) + }) + }, + + closeModal: () => { + set({ + isModalOpen: false, + modalProps: null, + }) + }, + + setModalOpen: (open: boolean) => { + set({ isModalOpen: open }) + if (!open) { + get().closeModal() + } + }, + + setAllowAllMCPPermissions: (allow: boolean) => { + set({ allowAllMCPPermissions: allow }) + }, + }), + { + name: localStorageKey.toolApproval, + storage: createJSONStorage(() => localStorage), + // Only persist approved tools and global permission setting, not modal state + partialize: (state) => ({ + approvedTools: state.approvedTools, + allowAllMCPPermissions: state.allowAllMCPPermissions, + }), + } + ) +) diff --git a/web-app/src/hooks/useToolAvailable.ts b/web-app/src/hooks/useToolAvailable.ts new file mode 100644 index 000000000..e7acaf32c --- /dev/null +++ b/web-app/src/hooks/useToolAvailable.ts @@ -0,0 +1,117 @@ +import { create } from 'zustand' +import { persist, createJSONStorage } from 'zustand/middleware' +import { localStorageKey } from '@/constants/localStorage' +import { MCPTool } from '@/types/completion' + +type ToolAvailableState = { + // Track available tools per thread + availableTools: Record // threadId -> toolNames[] + // Global default available tools (for new threads/index page) + defaultAvailableTools: string[] + + // Actions + setToolAvailableForThread: ( + threadId: string, + toolName: string, + available: boolean + ) => void + isToolAvailable: (threadId: string, toolName: string) => boolean + getAvailableToolsForThread: (threadId: string) => string[] + setDefaultAvailableTools: (toolNames: string[]) => void + getDefaultAvailableTools: () => string[] + // Initialize thread tools from default or existing thread settings + initializeThreadTools: (threadId: string, allTools: MCPTool[]) => void +} + +export const useToolAvailable = create()( + persist( + (set, get) => ({ + availableTools: {}, + defaultAvailableTools: [], + + setToolAvailableForThread: ( + threadId: string, + toolName: string, + available: boolean + ) => { + set((state) => { + const currentTools = state.availableTools[threadId] || [] + let updatedTools: string[] + + if (available) { + // Add tool if not already present + updatedTools = currentTools.includes(toolName) + ? currentTools + : [...currentTools, toolName] + } else { + // Remove tool + updatedTools = currentTools.filter((tool) => tool !== toolName) + } + + return { + availableTools: { + ...state.availableTools, + [threadId]: updatedTools, + }, + } + }) + }, + + isToolAvailable: (threadId: string, toolName: string) => { + const state = get() + // If no thread-specific settings, use default + if (!state.availableTools[threadId]) { + return state.defaultAvailableTools.includes(toolName) + } + return state.availableTools[threadId]?.includes(toolName) || false + }, + + getAvailableToolsForThread: (threadId: string) => { + const state = get() + // If no thread-specific settings, use default + if (!state.availableTools[threadId]) { + return state.defaultAvailableTools + } + return state.availableTools[threadId] || [] + }, + + setDefaultAvailableTools: (toolNames: string[]) => { + set({ defaultAvailableTools: toolNames }) + }, + + getDefaultAvailableTools: () => { + return get().defaultAvailableTools + }, + + initializeThreadTools: (threadId: string, allTools: MCPTool[]) => { + const state = get() + // If thread already has settings, don't override + if (state.availableTools[threadId]) { + return + } + + // Initialize with default tools only + // Don't auto-enable all tools if defaults are explicitly empty + const initialTools = state.defaultAvailableTools.filter((toolName) => + allTools.some((tool) => tool.name === toolName) + ) + + set((currentState) => ({ + availableTools: { + ...currentState.availableTools, + [threadId]: initialTools, + }, + })) + }, + }), + { + name: localStorageKey.toolAvailability, + storage: createJSONStorage(() => localStorage), + // Persist all state + partialize: (state) => ({ + availableTools: state.availableTools, + defaultAvailableTools: state.defaultAvailableTools, + }), + } + ) +) diff --git a/web-app/src/lib/completion.ts b/web-app/src/lib/completion.ts index c436ef956..927a38669 100644 --- a/web-app/src/lib/completion.ts +++ b/web-app/src/lib/completion.ts @@ -252,12 +252,18 @@ export const extractToolCall = ( * @param builder * @param message * @param content + * @param approvedTools - Record of approved tools per thread + * @param showModal - Function to show approval modal, returns true if approved + * @param allowAllMCPPermissions - Global setting to allow all MCP permissions without modal */ export const postMessageProcessing = async ( calls: ChatCompletionMessageToolCall[], builder: CompletionMessagesBuilder, message: ThreadMessage, - abortController: AbortController + abortController: AbortController, + approvedTools: Record = {}, + showModal?: (toolName: string, threadId: string) => Promise, + allowAllMCPPermissions: boolean = false ) => { // Handle completed tool calls if (calls.length) { @@ -284,12 +290,30 @@ export const postMessageProcessing = async ( ], } - const result = await callTool({ - toolName: toolCall.function.name, - arguments: toolCall.function.arguments.length - ? JSON.parse(toolCall.function.arguments) - : {}, - }) + // Check if tool is approved or show modal for approval + const approved = + allowAllMCPPermissions || + approvedTools[message.thread_id]?.includes(toolCall.function.name) || + (showModal + ? await showModal(toolCall.function.name, message.thread_id) + : true) + + const result = approved + ? await callTool({ + toolName: toolCall.function.name, + arguments: toolCall.function.arguments.length + ? JSON.parse(toolCall.function.arguments) + : {}, + }) + : { + content: [ + { + type: 'text', + text: 'The user has chosen to disallow the tool call.', + }, + ], + } + if ('error' in result && result.error) break message.metadata = { diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index 6eca0e533..e6512f447 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -17,6 +17,7 @@ import { PromptAnalytic } from '@/containers/analytics/PromptAnalytic' import { AnalyticProvider } from '@/providers/AnalyticProvider' import { useLeftPanel } from '@/hooks/useLeftPanel' import { cn } from '@/lib/utils' +import ToolApproval from '@/containers/dialogs/ToolApproval' export const Route = createRootRoute({ component: RootLayout, @@ -92,6 +93,7 @@ function RootLayout() { {isLocalAPIServerLogsRoute ? : } {/* */} + ) } diff --git a/web-app/src/routes/index.tsx b/web-app/src/routes/index.tsx index 8297c1022..f599e1c1c 100644 --- a/web-app/src/routes/index.tsx +++ b/web-app/src/routes/index.tsx @@ -64,7 +64,11 @@ function Index() {

- +
diff --git a/web-app/src/routes/settings/mcp-servers.tsx b/web-app/src/routes/settings/mcp-servers.tsx index 953f586ab..71c653348 100644 --- a/web-app/src/routes/settings/mcp-servers.tsx +++ b/web-app/src/routes/settings/mcp-servers.tsx @@ -18,6 +18,7 @@ import EditJsonMCPserver from '@/containers/dialogs/EditJsonMCPserver' import { Switch } from '@/components/ui/switch' import { twMerge } from 'tailwind-merge' import { getConnectedServers } from '@/services/mcp' +import { useToolApproval } from '@/hooks/useToolApproval' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.settings.mcp_servers as any)({ @@ -26,6 +27,8 @@ export const Route = createFileRoute(route.settings.mcp_servers as any)({ function MCPServers() { const { mcpServers, addServer, editServer, deleteServer } = useMCPServers() + const { allowAllMCPPermissions, setAllowAllMCPPermissions } = + useToolApproval() const [open, setOpen] = useState(false) const [editingKey, setEditingKey] = useState(null) @@ -195,6 +198,35 @@ function MCPServers() {
} /> + + {/* Global MCP Permission Toggle */} + +
+

+ Allow All MCP Tool Permissions +

+

+ When enabled, all MCP tool calls will be automatically + approved without showing permission dialogs. + + {' '} + Use with caution + {' '} + - only enable this if you trust all your MCP servers. +

+
+
+ +
+
+ } + /> + {Object.keys(mcpServers).length === 0 ? (
No MCP servers found diff --git a/web-app/src/styles/markdown.css b/web-app/src/styles/markdown.css index c999486bc..a3f0c2a9c 100644 --- a/web-app/src/styles/markdown.css +++ b/web-app/src/styles/markdown.css @@ -1,6 +1,16 @@ .markdown { @apply text-inherit; + &.is-user { + p { + line-height: 1.6; + margin-bottom: 1em; + &:first-child { + margin-bottom: 0; + } + } + } + /* Headings */ :is(h1, h2, h3, h4, h5, h6) { font-weight: 600; @@ -41,10 +51,6 @@ p { line-height: 1.6; margin-bottom: 1em; - - &:last-child { - margin-bottom: 0; - } } strong { @@ -199,3 +205,6 @@ margin: 2em 0; } } +[data-tool-call-block] + [data-tool-call-block] { + margin-top: 16px; +}