mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-02 18:55:25 -05:00
Compare commits
1 Commits
sim-614
...
fix/traces
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7386d227eb |
@@ -6,11 +6,11 @@ import Link from 'next/link'
|
||||
import { List, type RowComponentProps, useListRef } from 'react-window'
|
||||
import { Badge, buttonVariants } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import {
|
||||
DELETED_WORKFLOW_COLOR,
|
||||
DELETED_WORKFLOW_LABEL,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
getDisplayStatus,
|
||||
LOG_COLUMNS,
|
||||
StatusBadge,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
@@ -362,47 +363,14 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration for display in logs UI
|
||||
* If duration is under 1 second, displays as milliseconds (e.g., "500ms")
|
||||
* If duration is 1 second or more, displays as seconds (e.g., "1.23s")
|
||||
* @param duration - Duration string (e.g., "500ms") or null
|
||||
* @returns Formatted duration string or null
|
||||
*/
|
||||
export function formatDuration(duration: string | null): string | null {
|
||||
if (!duration) return null
|
||||
|
||||
// Extract numeric value from duration string (e.g., "500ms" -> 500)
|
||||
const ms = Number.parseInt(duration.replace(/[^0-9]/g, ''), 10)
|
||||
|
||||
if (!Number.isFinite(ms)) return duration
|
||||
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
// Convert to seconds with up to 2 decimal places
|
||||
const seconds = ms / 1000
|
||||
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format latency value for display in dashboard UI
|
||||
* If latency is under 1 second, displays as milliseconds (e.g., "500ms")
|
||||
* If latency is 1 second or more, displays as seconds (e.g., "1.23s")
|
||||
* @param ms - Latency in milliseconds (number)
|
||||
* @returns Formatted latency string
|
||||
*/
|
||||
export function formatLatency(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
// Convert to seconds with up to 2 decimal places
|
||||
const seconds = ms / 1000
|
||||
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
|
||||
return formatDuration(ms, { precision: 2 })
|
||||
}
|
||||
|
||||
export const formatDate = (dateString: string) => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { CopilotMarkdownRenderer } from '../markdown-renderer'
|
||||
|
||||
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
|
||||
@@ -241,15 +242,9 @@ export function ThinkingBlock({
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [isStreaming, isExpanded, userHasScrolledAway])
|
||||
|
||||
/** Formats duration in milliseconds to seconds (minimum 1s) */
|
||||
const formatDuration = (ms: number) => {
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const hasContent = cleanContent.length > 0
|
||||
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
|
||||
const durationText = `${label} for ${formatDuration(duration)}`
|
||||
const durationText = `${label} for ${formatDuration(Math.max(1000, duration))}`
|
||||
|
||||
const getStreamingLabel = (lbl: string) => {
|
||||
if (lbl === 'Thought') return 'Thinking'
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
hasInterrupt as hasInterruptFromConfig,
|
||||
isSpecialTool as isSpecialToolFromConfig,
|
||||
} from '@/lib/copilot/tools/client/ui-config'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
|
||||
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
|
||||
@@ -848,13 +849,8 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
(allParsed.options && Object.keys(allParsed.options).length > 0)
|
||||
)
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const outerLabel = getSubagentCompletionLabel(toolCall.name)
|
||||
const durationText = `${outerLabel} for ${formatDuration(duration)}`
|
||||
const durationText = `${outerLabel} for ${formatDuration(Math.max(1000, duration))}`
|
||||
|
||||
const renderCollapsibleContent = () => (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type React from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ChevronRight, Loader2, ServerIcon, WrenchIcon, XIcon } from 'lucide-react'
|
||||
import { Loader2, WrenchIcon, XIcon } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
@@ -111,33 +111,18 @@ interface ToolInputProps {
|
||||
* Represents a tool selected and configured in the workflow
|
||||
*
|
||||
* @remarks
|
||||
* Valid types include:
|
||||
* - Standard block types (e.g., 'api', 'search', 'function')
|
||||
* - 'custom-tool': User-defined tools with custom code
|
||||
* - 'mcp': Individual MCP tool from a connected server
|
||||
* - 'mcp-server': All tools from an MCP server (agent discovery mode).
|
||||
* At execution time, this expands into individual tool definitions for
|
||||
* all tools available on the server.
|
||||
*
|
||||
* For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded.
|
||||
* Everything else (title, schema, code) is loaded dynamically from the database.
|
||||
* Legacy custom tools with inline schema/code are still supported for backwards compatibility.
|
||||
*/
|
||||
interface StoredTool {
|
||||
/**
|
||||
* Block type identifier.
|
||||
* 'mcp-server' enables server-level selection where all tools from
|
||||
* the server are made available to the LLM at execution time.
|
||||
*/
|
||||
/** Block type identifier */
|
||||
type: string
|
||||
/** Display title for the tool (optional for new custom tool format) */
|
||||
title?: string
|
||||
/** Direct tool ID for execution (optional for new custom tool format) */
|
||||
toolId?: string
|
||||
/**
|
||||
* Parameter values configured by the user.
|
||||
* For 'mcp-server' type, includes: serverId, serverUrl, serverName, toolCount
|
||||
*/
|
||||
/** Parameter values configured by the user (optional for new custom tool format) */
|
||||
params?: Record<string, string>
|
||||
/** Whether the tool details are expanded in UI */
|
||||
isExpanded?: boolean
|
||||
@@ -1022,7 +1007,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState<number | null>(null)
|
||||
const [expandedMcpServers, setExpandedMcpServers] = useState<Set<string>>(new Set())
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
@@ -1252,18 +1236,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an MCP server is already selected (all tools mode).
|
||||
*
|
||||
* @param serverId - The MCP server identifier to check
|
||||
* @returns `true` if the MCP server is already selected
|
||||
*/
|
||||
const isMcpServerAlreadySelected = (serverId: string): boolean => {
|
||||
return selectedTools.some(
|
||||
(tool) => tool.type === 'mcp-server' && tool.params?.serverId === serverId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a custom tool is already selected.
|
||||
*
|
||||
@@ -1288,37 +1260,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups MCP tools by their parent server.
|
||||
*
|
||||
* @returns Map of serverId to array of tools
|
||||
*/
|
||||
const mcpToolsByServer = useMemo(() => {
|
||||
const grouped = new Map<string, typeof availableMcpTools>()
|
||||
for (const tool of availableMcpTools) {
|
||||
if (!grouped.has(tool.serverId)) {
|
||||
grouped.set(tool.serverId, [])
|
||||
}
|
||||
grouped.get(tool.serverId)!.push(tool)
|
||||
}
|
||||
return grouped
|
||||
}, [availableMcpTools])
|
||||
|
||||
/**
|
||||
* Toggles the expanded state of an MCP server in the dropdown.
|
||||
*/
|
||||
const toggleMcpServerExpanded = useCallback((serverId: string) => {
|
||||
setExpandedMcpServers((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(serverId)) {
|
||||
next.delete(serverId)
|
||||
} else {
|
||||
next.add(serverId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Checks if a block supports multiple operations.
|
||||
*
|
||||
@@ -1864,125 +1805,41 @@ export const ToolInput = memo(function ToolInput({
|
||||
})
|
||||
}
|
||||
|
||||
// MCP Servers section - grouped by server with expandable folders
|
||||
if (!permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) {
|
||||
// Create items for each server (as expandable folders)
|
||||
const serverItems: ComboboxOption[] = []
|
||||
|
||||
for (const [serverId, tools] of mcpToolsByServer) {
|
||||
const server = mcpServers.find((s) => s.id === serverId)
|
||||
const serverName = tools[0]?.serverName || server?.name || 'Unknown Server'
|
||||
const isExpanded = expandedMcpServers.has(serverId)
|
||||
const serverAlreadySelected = isMcpServerAlreadySelected(serverId)
|
||||
const toolCount = tools.length
|
||||
|
||||
// Server folder header (clickable to expand/collapse)
|
||||
serverItems.push({
|
||||
label: serverName,
|
||||
value: `mcp-server-folder-${serverId}`,
|
||||
iconElement: (
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-[12px] w-[12px] text-[var(--text-tertiary)] transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: '#6366F1' }}
|
||||
>
|
||||
<ServerIcon className='h-[10px] w-[10px] text-white' />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onSelect: () => {
|
||||
toggleMcpServerExpanded(serverId)
|
||||
},
|
||||
disabled: false,
|
||||
keepOpen: true, // Keep dropdown open when toggling folder expansion
|
||||
})
|
||||
|
||||
// If expanded, show "Use all tools" option and individual tools
|
||||
if (isExpanded) {
|
||||
// "Use all tools from server" option
|
||||
serverItems.push({
|
||||
label: `Use all ${toolCount} tools`,
|
||||
value: `mcp-server-all-${serverId}`,
|
||||
iconElement: (
|
||||
<div className='ml-[20px] flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#6366F1]'>
|
||||
<McpIcon className='h-[10px] w-[10px] text-white' />
|
||||
</div>
|
||||
),
|
||||
onSelect: () => {
|
||||
if (serverAlreadySelected) return
|
||||
// Remove any individual tools from this server that were previously selected
|
||||
const filteredTools = selectedTools.filter(
|
||||
(tool) => !(tool.type === 'mcp' && tool.params?.serverId === serverId)
|
||||
)
|
||||
const newTool: StoredTool = {
|
||||
type: 'mcp-server',
|
||||
title: `${serverName} (all tools)`,
|
||||
toolId: `mcp-server-${serverId}`,
|
||||
params: {
|
||||
serverId,
|
||||
...(server?.url && { serverUrl: server.url }),
|
||||
serverName,
|
||||
toolCount: String(toolCount),
|
||||
},
|
||||
isExpanded: false,
|
||||
usageControl: 'auto',
|
||||
}
|
||||
setStoreValue([
|
||||
...filteredTools.map((tool) => ({ ...tool, isExpanded: false })),
|
||||
newTool,
|
||||
])
|
||||
setOpen(false)
|
||||
},
|
||||
disabled: isPreview || disabled || serverAlreadySelected,
|
||||
})
|
||||
|
||||
// Individual tools from this server
|
||||
for (const mcpTool of tools) {
|
||||
const alreadySelected = isMcpToolAlreadySelected(mcpTool.id) || serverAlreadySelected
|
||||
serverItems.push({
|
||||
label: mcpTool.name,
|
||||
value: `mcp-${mcpTool.id}`,
|
||||
iconElement: (
|
||||
<div className='ml-[20px]'>
|
||||
{createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon)}
|
||||
</div>
|
||||
),
|
||||
onSelect: () => {
|
||||
if (alreadySelected) return
|
||||
const newTool: StoredTool = {
|
||||
type: 'mcp',
|
||||
title: mcpTool.name,
|
||||
toolId: mcpTool.id,
|
||||
params: {
|
||||
serverId: mcpTool.serverId,
|
||||
...(server?.url && { serverUrl: server.url }),
|
||||
toolName: mcpTool.name,
|
||||
serverName: mcpTool.serverName,
|
||||
},
|
||||
isExpanded: true,
|
||||
usageControl: 'auto',
|
||||
schema: {
|
||||
...mcpTool.inputSchema,
|
||||
description: mcpTool.description,
|
||||
},
|
||||
}
|
||||
handleMcpToolSelect(newTool, true)
|
||||
},
|
||||
disabled: isPreview || disabled || alreadySelected,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MCP Tools section
|
||||
if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) {
|
||||
groups.push({
|
||||
section: 'MCP Servers',
|
||||
items: serverItems,
|
||||
section: 'MCP Tools',
|
||||
items: availableMcpTools.map((mcpTool) => {
|
||||
const server = mcpServers.find((s) => s.id === mcpTool.serverId)
|
||||
const alreadySelected = isMcpToolAlreadySelected(mcpTool.id)
|
||||
return {
|
||||
label: mcpTool.name,
|
||||
value: `mcp-${mcpTool.id}`,
|
||||
iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon),
|
||||
onSelect: () => {
|
||||
if (alreadySelected) return
|
||||
const newTool: StoredTool = {
|
||||
type: 'mcp',
|
||||
title: mcpTool.name,
|
||||
toolId: mcpTool.id,
|
||||
params: {
|
||||
serverId: mcpTool.serverId,
|
||||
...(server?.url && { serverUrl: server.url }),
|
||||
toolName: mcpTool.name,
|
||||
serverName: mcpTool.serverName,
|
||||
},
|
||||
isExpanded: true,
|
||||
usageControl: 'auto',
|
||||
schema: {
|
||||
...mcpTool.inputSchema,
|
||||
description: mcpTool.description,
|
||||
},
|
||||
}
|
||||
handleMcpToolSelect(newTool, true)
|
||||
},
|
||||
disabled: isPreview || disabled || alreadySelected,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2065,8 +1922,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
customTools,
|
||||
availableMcpTools,
|
||||
mcpServers,
|
||||
mcpToolsByServer,
|
||||
expandedMcpServers,
|
||||
toolBlocks,
|
||||
isPreview,
|
||||
disabled,
|
||||
@@ -2080,10 +1935,8 @@ export const ToolInput = memo(function ToolInput({
|
||||
getToolIdForOperation,
|
||||
isToolAlreadySelected,
|
||||
isMcpToolAlreadySelected,
|
||||
isMcpServerAlreadySelected,
|
||||
isCustomToolAlreadySelected,
|
||||
isWorkflowAlreadySelected,
|
||||
toggleMcpServerExpanded,
|
||||
])
|
||||
|
||||
const toolRequiresOAuth = (toolId: string): boolean => {
|
||||
@@ -2510,25 +2363,24 @@ export const ToolInput = memo(function ToolInput({
|
||||
{/* Selected Tools List */}
|
||||
{selectedTools.length > 0 &&
|
||||
selectedTools.map((tool, toolIndex) => {
|
||||
// Handle custom tools, MCP tools, MCP servers, and workflow tools differently
|
||||
// Handle custom tools, MCP tools, and workflow tools differently
|
||||
const isCustomTool = tool.type === 'custom-tool'
|
||||
const isMcpTool = tool.type === 'mcp'
|
||||
const isMcpServer = tool.type === 'mcp-server'
|
||||
const isWorkflowTool = tool.type === 'workflow'
|
||||
const toolBlock =
|
||||
!isCustomTool && !isMcpTool && !isMcpServer
|
||||
!isCustomTool && !isMcpTool
|
||||
? toolBlocks.find((block) => block.type === tool.type)
|
||||
: null
|
||||
|
||||
// Get the current tool ID (may change based on operation)
|
||||
const currentToolId =
|
||||
!isCustomTool && !isMcpTool && !isMcpServer
|
||||
!isCustomTool && !isMcpTool
|
||||
? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || ''
|
||||
: tool.toolId || ''
|
||||
|
||||
// Get tool parameters using the new utility with block type for UI components
|
||||
const toolParams =
|
||||
!isCustomTool && !isMcpTool && !isMcpServer && currentToolId
|
||||
!isCustomTool && !isMcpTool && currentToolId
|
||||
? getToolParametersConfig(currentToolId, tool.type, {
|
||||
operation: tool.operation,
|
||||
...tool.params,
|
||||
@@ -2597,32 +2449,21 @@ export const ToolInput = memo(function ToolInput({
|
||||
? customToolParams
|
||||
: isMcpTool
|
||||
? mcpToolParams
|
||||
: isMcpServer
|
||||
? [] // MCP servers have no user-configurable params
|
||||
: toolParams?.userInputParameters || []
|
||||
: toolParams?.userInputParameters || []
|
||||
|
||||
// Check if tool requires OAuth
|
||||
const requiresOAuth =
|
||||
!isCustomTool &&
|
||||
!isMcpTool &&
|
||||
!isMcpServer &&
|
||||
currentToolId &&
|
||||
toolRequiresOAuth(currentToolId)
|
||||
!isCustomTool && !isMcpTool && currentToolId && toolRequiresOAuth(currentToolId)
|
||||
const oauthConfig =
|
||||
!isCustomTool && !isMcpTool && !isMcpServer && currentToolId
|
||||
? getToolOAuthConfig(currentToolId)
|
||||
: null
|
||||
!isCustomTool && !isMcpTool && currentToolId ? getToolOAuthConfig(currentToolId) : null
|
||||
|
||||
// Determine if tool has expandable body content
|
||||
const hasOperations =
|
||||
!isCustomTool && !isMcpTool && !isMcpServer && hasMultipleOperations(tool.type)
|
||||
const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type)
|
||||
const filteredDisplayParams = displayParams.filter((param) =>
|
||||
evaluateParameterCondition(param, tool)
|
||||
)
|
||||
// MCP servers are expandable to show tool list
|
||||
const hasToolBody = isMcpServer
|
||||
? true
|
||||
: hasOperations || (requiresOAuth && oauthConfig) || filteredDisplayParams.length > 0
|
||||
const hasToolBody =
|
||||
hasOperations || (requiresOAuth && oauthConfig) || filteredDisplayParams.length > 0
|
||||
|
||||
// Only show expansion if tool has body content
|
||||
const isExpandedForDisplay = hasToolBody
|
||||
@@ -2631,11 +2472,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
: !!tool.isExpanded
|
||||
: false
|
||||
|
||||
// For MCP servers, get the list of tools for display
|
||||
const mcpServerTools = isMcpServer
|
||||
? availableMcpTools.filter((t) => t.serverId === tool.params?.serverId)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${tool.customToolId || tool.toolId || toolIndex}-${toolIndex}`}
|
||||
@@ -2672,7 +2508,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
style={{
|
||||
backgroundColor: isCustomTool
|
||||
? '#3B82F6'
|
||||
: isMcpTool || isMcpServer
|
||||
: isMcpTool
|
||||
? mcpTool?.bgColor || '#6366F1'
|
||||
: isWorkflowTool
|
||||
? '#6366F1'
|
||||
@@ -2683,8 +2519,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
<WrenchIcon className='h-[10px] w-[10px] text-white' />
|
||||
) : isMcpTool ? (
|
||||
<IconComponent icon={McpIcon} className='h-[10px] w-[10px] text-white' />
|
||||
) : isMcpServer ? (
|
||||
<ServerIcon className='h-[10px] w-[10px] text-white' />
|
||||
) : isWorkflowTool ? (
|
||||
<IconComponent icon={WorkflowIcon} className='h-[10px] w-[10px] text-white' />
|
||||
) : (
|
||||
@@ -2697,11 +2531,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{isCustomTool ? customToolTitle : tool.title}
|
||||
</span>
|
||||
{isMcpServer && (
|
||||
<Badge variant='default' size='sm'>
|
||||
{tool.params?.toolCount || mcpServerTools.length} tools
|
||||
</Badge>
|
||||
)}
|
||||
{isMcpTool &&
|
||||
!mcpDataLoading &&
|
||||
(() => {
|
||||
@@ -2807,53 +2636,31 @@ export const ToolInput = memo(function ToolInput({
|
||||
|
||||
{!isCustomTool && isExpandedForDisplay && (
|
||||
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t px-[8px] py-[8px]'>
|
||||
{/* MCP Server tool list (read-only) */}
|
||||
{isMcpServer && mcpServerTools.length > 0 && (
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Available tools:
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-[4px]'>
|
||||
{mcpServerTools.map((serverTool) => (
|
||||
<Badge
|
||||
key={serverTool.id}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='text-[11px]'
|
||||
>
|
||||
{serverTool.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Operation dropdown for tools with multiple operations */}
|
||||
{!isMcpServer &&
|
||||
(() => {
|
||||
const hasOperations = hasMultipleOperations(tool.type)
|
||||
const operationOptions = hasOperations ? getOperationOptions(tool.type) : []
|
||||
{(() => {
|
||||
const hasOperations = hasMultipleOperations(tool.type)
|
||||
const operationOptions = hasOperations ? getOperationOptions(tool.type) : []
|
||||
|
||||
return hasOperations && operationOptions.length > 0 ? (
|
||||
<div className='relative space-y-[6px]'>
|
||||
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Operation
|
||||
</div>
|
||||
<Combobox
|
||||
options={operationOptions
|
||||
.filter((option) => option.id !== '')
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
}))}
|
||||
value={tool.operation || operationOptions[0].id}
|
||||
onChange={(value) => handleOperationChange(toolIndex, value)}
|
||||
placeholder='Select operation'
|
||||
disabled={disabled}
|
||||
/>
|
||||
return hasOperations && operationOptions.length > 0 ? (
|
||||
<div className='relative space-y-[6px]'>
|
||||
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Operation
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
<Combobox
|
||||
options={operationOptions
|
||||
.filter((option) => option.id !== '')
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
}))}
|
||||
value={tool.operation || operationOptions[0].id}
|
||||
onChange={(value) => handleOperationChange(toolIndex, value)}
|
||||
placeholder='Select operation'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{/* OAuth credential selector if required */}
|
||||
{requiresOAuth && oauthConfig && (
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import {
|
||||
@@ -43,7 +44,6 @@ import {
|
||||
type EntryNode,
|
||||
type ExecutionGroup,
|
||||
flattenBlockEntriesOnly,
|
||||
formatDuration,
|
||||
getBlockColor,
|
||||
getBlockIcon,
|
||||
groupEntriesByExecution,
|
||||
|
||||
@@ -53,17 +53,6 @@ export function getBlockColor(blockType: string): string {
|
||||
return '#6b7280'
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration from milliseconds to readable format
|
||||
*/
|
||||
export function formatDuration(ms?: number): string {
|
||||
if (ms === undefined || ms === null) return '-'
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a keyboard event originated from a text-editable element
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
@@ -575,7 +576,9 @@ export function TrainingModal() {
|
||||
<span className='text-[var(--text-muted)]'>Duration:</span>{' '}
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
{dataset.metadata?.duration
|
||||
? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
|
||||
? formatDuration(dataset.metadata.duration, {
|
||||
precision: 1,
|
||||
})
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -227,12 +228,6 @@ async function deliverWebhook(
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
return `${(ms / 60000).toFixed(1)}m`
|
||||
}
|
||||
|
||||
function formatCost(cost?: Record<string, unknown>): string {
|
||||
if (!cost?.total) return 'N/A'
|
||||
const total = cost.total as number
|
||||
|
||||
@@ -52,8 +52,6 @@ export type ComboboxOption = {
|
||||
onSelect?: () => void
|
||||
/** Whether this option is disabled */
|
||||
disabled?: boolean
|
||||
/** When true, keep the dropdown open after selecting this option */
|
||||
keepOpen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,15 +252,13 @@ const Combobox = memo(
|
||||
* Handles selection of an option
|
||||
*/
|
||||
const handleSelect = useCallback(
|
||||
(selectedValue: string, customOnSelect?: () => void, keepOpen?: boolean) => {
|
||||
(selectedValue: string, customOnSelect?: () => void) => {
|
||||
// If option has custom onSelect, use it instead
|
||||
if (customOnSelect) {
|
||||
customOnSelect()
|
||||
if (!keepOpen) {
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
setSearchQuery('')
|
||||
}
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
setSearchQuery('')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -274,13 +270,11 @@ const Combobox = memo(
|
||||
onMultiSelectChange(newValues)
|
||||
} else {
|
||||
onChange?.(selectedValue)
|
||||
if (!keepOpen) {
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
setSearchQuery('')
|
||||
if (editable && inputRef.current) {
|
||||
inputRef.current.blur()
|
||||
}
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
setSearchQuery('')
|
||||
if (editable && inputRef.current) {
|
||||
inputRef.current.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -349,7 +343,7 @@ const Combobox = memo(
|
||||
e.preventDefault()
|
||||
const selectedOption = filteredOptions[highlightedIndex]
|
||||
if (selectedOption && !selectedOption.disabled) {
|
||||
handleSelect(selectedOption.value, selectedOption.onSelect, selectedOption.keepOpen)
|
||||
handleSelect(selectedOption.value, selectedOption.onSelect)
|
||||
}
|
||||
} else if (!editable) {
|
||||
e.preventDefault()
|
||||
@@ -674,7 +668,7 @@ const Combobox = memo(
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!option.disabled) {
|
||||
handleSelect(option.value, option.onSelect, option.keepOpen)
|
||||
handleSelect(option.value, option.onSelect)
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
@@ -749,7 +743,7 @@ const Combobox = memo(
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!option.disabled) {
|
||||
handleSelect(option.value, option.onSelect, option.keepOpen)
|
||||
handleSelect(option.value, option.onSelect)
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => !option.disabled && setHighlightedIndex(index)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
|
||||
interface ToolCallProps {
|
||||
toolCall: ToolCallState
|
||||
@@ -225,11 +226,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
|
||||
const isError = toolCall.state === 'error'
|
||||
const isAborted = toolCall.state === 'aborted'
|
||||
|
||||
const formatDuration = (duration?: number) => {
|
||||
if (!duration) return ''
|
||||
return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -143,7 +143,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise<void> {
|
||||
if (!Array.isArray(tools) || tools.length === 0) return
|
||||
|
||||
const hasMcpTools = tools.some((t) => t.type === 'mcp' || t.type === 'mcp-server')
|
||||
const hasMcpTools = tools.some((t) => t.type === 'mcp')
|
||||
const hasCustomTools = tools.some((t) => t.type === 'custom-tool')
|
||||
|
||||
if (hasMcpTools) {
|
||||
@@ -161,7 +161,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
): Promise<ToolInput[]> {
|
||||
if (!Array.isArray(tools) || tools.length === 0) return tools
|
||||
|
||||
const mcpTools = tools.filter((t) => t.type === 'mcp' || t.type === 'mcp-server')
|
||||
const mcpTools = tools.filter((t) => t.type === 'mcp')
|
||||
if (mcpTools.length === 0) return tools
|
||||
|
||||
const serverIds = [...new Set(mcpTools.map((t) => t.params?.serverId).filter(Boolean))]
|
||||
@@ -195,7 +195,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
return tools.filter((tool) => {
|
||||
if (tool.type !== 'mcp' && tool.type !== 'mcp-server') return true
|
||||
if (tool.type !== 'mcp') return true
|
||||
const serverId = tool.params?.serverId
|
||||
if (!serverId) return false
|
||||
return availableServerIds.has(serverId)
|
||||
@@ -211,14 +211,11 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
})
|
||||
|
||||
const mcpTools: ToolInput[] = []
|
||||
const mcpServers: ToolInput[] = []
|
||||
const otherTools: ToolInput[] = []
|
||||
|
||||
for (const tool of filtered) {
|
||||
if (tool.type === 'mcp') {
|
||||
mcpTools.push(tool)
|
||||
} else if (tool.type === 'mcp-server') {
|
||||
mcpServers.push(tool)
|
||||
} else {
|
||||
otherTools.push(tool)
|
||||
}
|
||||
@@ -227,12 +224,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
const otherResults = await Promise.all(
|
||||
otherTools.map(async (tool) => {
|
||||
try {
|
||||
if (
|
||||
tool.type &&
|
||||
tool.type !== 'custom-tool' &&
|
||||
tool.type !== 'mcp' &&
|
||||
tool.type !== 'mcp-server'
|
||||
) {
|
||||
if (tool.type && tool.type !== 'custom-tool' && tool.type !== 'mcp') {
|
||||
await validateBlockType(ctx.userId, tool.type, ctx)
|
||||
}
|
||||
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) {
|
||||
@@ -248,133 +240,12 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
|
||||
const mcpResults = await this.processMcpToolsBatched(ctx, mcpTools)
|
||||
|
||||
// Process MCP servers (all tools from server mode)
|
||||
const mcpServerResults = await this.processMcpServerSelections(ctx, mcpServers)
|
||||
|
||||
const allTools = [...otherResults, ...mcpResults, ...mcpServerResults]
|
||||
const allTools = [...otherResults, ...mcpResults]
|
||||
return allTools.filter(
|
||||
(tool): tool is NonNullable<typeof tool> => tool !== null && tool !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process MCP server selections by discovering and formatting all tools from each server.
|
||||
* This enables "agent discovery" mode where the LLM can call any tool from the server.
|
||||
*/
|
||||
private async processMcpServerSelections(
|
||||
ctx: ExecutionContext,
|
||||
mcpServerSelections: ToolInput[]
|
||||
): Promise<any[]> {
|
||||
if (mcpServerSelections.length === 0) return []
|
||||
|
||||
const results: any[] = []
|
||||
|
||||
for (const serverSelection of mcpServerSelections) {
|
||||
const serverId = serverSelection.params?.serverId
|
||||
const serverName = serverSelection.params?.serverName
|
||||
const usageControl = serverSelection.usageControl || 'auto'
|
||||
|
||||
if (!serverId) {
|
||||
logger.error('MCP server selection missing serverId:', serverSelection)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// Discover all tools from this server
|
||||
const discoveredTools = await this.discoverMcpToolsForServer(ctx, serverId)
|
||||
|
||||
// Create tool definitions for each discovered tool
|
||||
for (const mcpTool of discoveredTools) {
|
||||
const created = await this.createMcpToolFromDiscoveredServerTool(
|
||||
ctx,
|
||||
mcpTool,
|
||||
serverId,
|
||||
serverName || serverId,
|
||||
usageControl
|
||||
)
|
||||
if (created) results.push(created)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[AgentHandler] Expanded MCP server ${serverName} into ${discoveredTools.length} tools`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[AgentHandler] Failed to process MCP server selection:`, { serverId, error })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an MCP tool from server discovery for the "all tools" mode.
|
||||
*/
|
||||
private async createMcpToolFromDiscoveredServerTool(
|
||||
ctx: ExecutionContext,
|
||||
mcpTool: any,
|
||||
serverId: string,
|
||||
serverName: string,
|
||||
usageControl: string
|
||||
): Promise<any> {
|
||||
const toolName = mcpTool.name
|
||||
|
||||
const { filterSchemaForLLM } = await import('@/tools/params')
|
||||
const filteredSchema = filterSchemaForLLM(
|
||||
mcpTool.inputSchema || { type: 'object', properties: {} },
|
||||
{}
|
||||
)
|
||||
|
||||
const toolId = createMcpToolId(serverId, toolName)
|
||||
|
||||
return {
|
||||
id: toolId,
|
||||
name: toolName,
|
||||
description: mcpTool.description || `MCP tool ${toolName} from ${serverName}`,
|
||||
parameters: filteredSchema,
|
||||
params: {},
|
||||
usageControl,
|
||||
executeFunction: async (callParams: Record<string, any>) => {
|
||||
const headers = await buildAuthHeaders()
|
||||
const execUrl = buildAPIUrl('/api/mcp/tools/execute')
|
||||
|
||||
const execResponse = await fetch(execUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: stringifyJSON({
|
||||
serverId,
|
||||
toolName,
|
||||
arguments: callParams,
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
toolSchema: mcpTool.inputSchema,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!execResponse.ok) {
|
||||
throw new Error(
|
||||
`MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await execResponse.json()
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'MCP tool execution failed')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: result.data.output || {},
|
||||
metadata: {
|
||||
source: 'mcp-server',
|
||||
serverId,
|
||||
serverName,
|
||||
toolName,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private async createCustomTool(ctx: ExecutionContext, tool: ToolInput): Promise<any> {
|
||||
const userProvidedParams = tool.params || {}
|
||||
|
||||
|
||||
@@ -29,36 +29,11 @@ export interface AgentInputs {
|
||||
verbosity?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a tool input for the agent block.
|
||||
*
|
||||
* @remarks
|
||||
* Valid types include:
|
||||
* - Standard block types (e.g., 'api', 'search', 'function')
|
||||
* - 'custom-tool': User-defined tools with custom code
|
||||
* - 'mcp': Individual MCP tool from a connected server
|
||||
* - 'mcp-server': All tools from an MCP server (agent discovery mode).
|
||||
* At execution time, this is expanded into individual tool definitions
|
||||
* for all tools available on the server. This enables dynamic capability
|
||||
* discovery where the LLM can call any tool from the server.
|
||||
*/
|
||||
export interface ToolInput {
|
||||
/**
|
||||
* Tool type identifier.
|
||||
* 'mcp-server' enables server-level selection where all tools from
|
||||
* the server are made available to the LLM at execution time.
|
||||
*/
|
||||
type?: string
|
||||
schema?: any
|
||||
title?: string
|
||||
code?: string
|
||||
/**
|
||||
* Tool parameters. For 'mcp-server' type, includes:
|
||||
* - serverId: The MCP server ID
|
||||
* - serverUrl: The server URL (optional)
|
||||
* - serverName: Human-readable server name
|
||||
* - toolCount: Number of tools available (for display)
|
||||
*/
|
||||
params?: Record<string, any>
|
||||
timeout?: number
|
||||
usageControl?: 'auto' | 'force' | 'none'
|
||||
|
||||
@@ -153,20 +153,44 @@ export function formatCompactTimestamp(iso: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in milliseconds to a human-readable format
|
||||
* @param durationMs - The duration in milliseconds
|
||||
* Format a duration to a human-readable format
|
||||
* @param duration - Duration in milliseconds (number) or as string (e.g., "500ms")
|
||||
* @param options - Optional formatting options
|
||||
* @param options.precision - Number of decimal places for seconds (default: 0)
|
||||
* @returns A formatted duration string
|
||||
*/
|
||||
export function formatDuration(durationMs: number, options?: { precision?: number }): string {
|
||||
const precision = options?.precision ?? 0
|
||||
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs}ms`
|
||||
export function formatDuration(
|
||||
duration: number | string | undefined | null,
|
||||
options?: { precision?: number }
|
||||
): string {
|
||||
if (duration === undefined || duration === null) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const seconds = durationMs / 1000
|
||||
// Parse string durations (e.g., "500ms", "1234")
|
||||
let ms: number
|
||||
if (typeof duration === 'string') {
|
||||
ms = Number.parseInt(duration.replace(/[^0-9.-]/g, ''), 10)
|
||||
if (!Number.isFinite(ms)) {
|
||||
return duration
|
||||
}
|
||||
} else {
|
||||
ms = duration
|
||||
}
|
||||
|
||||
const precision = options?.precision ?? 0
|
||||
|
||||
if (ms < 1) {
|
||||
// Sub-millisecond: show with 2 decimal places
|
||||
return `${ms.toFixed(2)}ms`
|
||||
}
|
||||
|
||||
if (ms < 1000) {
|
||||
// Milliseconds: round to integer
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
const seconds = ms / 1000
|
||||
if (seconds < 60) {
|
||||
return precision > 0 ? `${seconds.toFixed(precision)}s` : `${Math.floor(seconds)}s`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user