mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(trace): normalize keyed tool names and show credits in trace view (#4344)
* fix(trace): normalize keyed tool names and show credits in trace view - Export normalizeToolId from tools/index.ts so trace-view can reuse it - Strip resource-id suffixes (knowledge_search_<uuid>, workflow_executor_<uuid>, table_*_<id>) from tool span names at display time so icons resolve and names are readable - Replace raw dollar formatting with credits in trace header and agent detail panel * fix(trace): fix sub-credit display, locale, and pluralization in formatCostAmount * fix(trace): extract normalizeToolId to client-safe module, consolidate log-details formatting utils * refactor(trace): consolidate shared span utilities into log-details/utils * fix(tools): import normalizeToolId for internal use after extracting to normalize.ts * style(trace-spans): replace inline transform style with Tailwind rotate-180 on disclosure chevron * fix(trace-spans): show credits instead of dollars for cost display * refactor(trace): move formatCostAmount and getDisplayName to shared utils, normalize tool names in trace-spans * fix(trace-spans): simplify formatCostSummary to show total credits only
This commit is contained in:
+18
-180
@@ -19,26 +19,26 @@ import {
|
||||
Search as SearchIcon,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getBlock, getBlockByToolName } from '@/blocks'
|
||||
import {
|
||||
formatCostAmount,
|
||||
formatTokensSummary,
|
||||
formatTps,
|
||||
formatTtft,
|
||||
getBlockIconAndColor,
|
||||
getDisplayName,
|
||||
hasErrorInTree,
|
||||
hasUnhandledErrorInTree,
|
||||
isIterationType,
|
||||
parseTime,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
|
||||
interface TraceSpansProps {
|
||||
traceSpans?: TraceSpan[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a span type is a loop or parallel iteration
|
||||
*/
|
||||
function isIterationType(type: string): boolean {
|
||||
const lower = type?.toLowerCase() || ''
|
||||
return lower === 'loop-iteration' || lower === 'parallel-iteration'
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a toggle handler for Set-based state
|
||||
*/
|
||||
@@ -59,124 +59,9 @@ function useSetToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a token count with locale-aware thousands separators.
|
||||
* Returns `undefined` for missing or non-positive counts so callers can
|
||||
* filter them out before rendering.
|
||||
*/
|
||||
function formatTokenCount(value: number | undefined): string | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a compact, dot-separated token summary for a span:
|
||||
* `"1,234 in · 567 out · 1,801 total"` with cache/reasoning appended when
|
||||
* present. Returns `undefined` when the span has no meaningful token data.
|
||||
*/
|
||||
function formatTokensSummary(tokens: TraceSpan['tokens']): string | undefined {
|
||||
if (!tokens) return undefined
|
||||
const parts: string[] = []
|
||||
const input = formatTokenCount(tokens.input)
|
||||
const output = formatTokenCount(tokens.output)
|
||||
const total = formatTokenCount(tokens.total)
|
||||
const cacheRead = formatTokenCount(tokens.cacheRead)
|
||||
const cacheWrite = formatTokenCount(tokens.cacheWrite)
|
||||
const reasoning = formatTokenCount(tokens.reasoning)
|
||||
if (input) parts.push(`${input} in`)
|
||||
if (cacheRead) parts.push(`${cacheRead} cached`)
|
||||
if (cacheWrite) parts.push(`${cacheWrite} cache write`)
|
||||
if (output) parts.push(`${output} out`)
|
||||
if (reasoning) parts.push(`${reasoning} reasoning`)
|
||||
if (total) parts.push(`${total} total`)
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a USD cost value for display. Shows `<$0.0001` for non-zero sub-cent
|
||||
* amounts so the user sees it was counted.
|
||||
*/
|
||||
function formatCostAmount(value: number | undefined): string | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
if (value < 0.0001) return '<$0.0001'
|
||||
return `$${value.toFixed(4)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a compact cost summary: `"$0.0023 · $0.0001 in · $0.0022 out"`.
|
||||
* Falls back to whichever parts are present.
|
||||
*/
|
||||
function formatCostSummary(cost: TraceSpan['cost']): string | undefined {
|
||||
if (!cost) return undefined
|
||||
const parts: string[] = []
|
||||
const total = formatCostAmount(cost.total)
|
||||
const input = formatCostAmount(cost.input)
|
||||
const output = formatCostAmount(cost.output)
|
||||
if (total) parts.push(total)
|
||||
if (input) parts.push(`${input} in`)
|
||||
if (output) parts.push(`${output} out`)
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives tokens-per-second from output tokens over segment duration.
|
||||
* Returns `undefined` when inputs are missing or non-positive.
|
||||
*/
|
||||
function formatTps(outputTokens: number | undefined, durationMs: number): string | undefined {
|
||||
if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined
|
||||
if (!(durationMs > 0)) return undefined
|
||||
const tps = Math.round(outputTokens / (durationMs / 1000))
|
||||
if (!(tps > 0)) return undefined
|
||||
return `${tps.toLocaleString('en-US')} tok/s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats time-to-first-token. Uses `ms` below 1000, `s` above.
|
||||
*/
|
||||
function formatTtft(ms: number | undefined): string | undefined {
|
||||
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a time value to milliseconds
|
||||
*/
|
||||
function parseTime(value?: string | number | null): number {
|
||||
if (!value) return 0
|
||||
const ms = typeof value === 'number' ? value : new Date(value).getTime()
|
||||
return Number.isFinite(ms) ? ms : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a span or any of its descendants has an error (any error).
|
||||
*/
|
||||
function hasErrorInTree(span: TraceSpan): boolean {
|
||||
if (span.status === 'error') return true
|
||||
if (span.children && span.children.length > 0) {
|
||||
return span.children.some((child) => hasErrorInTree(child))
|
||||
}
|
||||
if (span.toolCalls && span.toolCalls.length > 0) {
|
||||
return span.toolCalls.some((tc) => tc.error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a span or any of its descendants has an unhandled error.
|
||||
* Spans with errorHandled: true (including containers that propagate it)
|
||||
* are skipped. Used only for the root workflow span to match the actual
|
||||
* workflow status.
|
||||
*/
|
||||
function hasUnhandledErrorInTree(span: TraceSpan): boolean {
|
||||
if (span.status === 'error' && !span.errorHandled) return true
|
||||
if (span.children && span.children.length > 0) {
|
||||
return span.children.some((child) => hasUnhandledErrorInTree(child))
|
||||
}
|
||||
if (span.toolCalls && span.toolCalls.length > 0 && !span.errorHandled) {
|
||||
return span.toolCalls.some((tc) => tc.error)
|
||||
}
|
||||
return false
|
||||
return formatCostAmount(cost.total)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,53 +86,6 @@ function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] {
|
||||
})
|
||||
}
|
||||
|
||||
const DEFAULT_BLOCK_COLOR = '#6b7280'
|
||||
|
||||
/**
|
||||
* Gets icon and color for a span type using block config
|
||||
*/
|
||||
function getBlockIconAndColor(
|
||||
type: string,
|
||||
toolName?: string
|
||||
): {
|
||||
icon: React.ComponentType<{ className?: string }> | null
|
||||
bgColor: string
|
||||
} {
|
||||
const lowerType = type.toLowerCase()
|
||||
|
||||
// Check for tool by name first (most specific)
|
||||
if (lowerType === 'tool' && toolName) {
|
||||
// Handle load_skill tool with the AgentSkillsIcon
|
||||
if (toolName === 'load_skill') {
|
||||
return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' }
|
||||
}
|
||||
const toolBlock = getBlockByToolName(toolName)
|
||||
if (toolBlock) {
|
||||
return { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
|
||||
}
|
||||
}
|
||||
|
||||
// Special types not in block registry
|
||||
if (lowerType === 'loop' || lowerType === 'loop-iteration') {
|
||||
return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
|
||||
}
|
||||
if (lowerType === 'parallel' || lowerType === 'parallel-iteration') {
|
||||
return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
|
||||
}
|
||||
if (lowerType === 'workflow') {
|
||||
return { icon: WorkflowIcon, bgColor: '#6366F1' }
|
||||
}
|
||||
|
||||
// Look up from block registry (model maps to agent)
|
||||
const blockType = lowerType === 'model' ? 'agent' : lowerType
|
||||
const blockConfig = getBlock(blockType)
|
||||
if (blockConfig) {
|
||||
return { icon: blockConfig.icon, bgColor: blockConfig.bgColor }
|
||||
}
|
||||
|
||||
return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the progress bar showing execution timeline
|
||||
*/
|
||||
@@ -403,10 +241,10 @@ function InputOutputSection({
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className='h-[8px] w-[8px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
|
||||
style={{
|
||||
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
className={cn(
|
||||
'h-[8px] w-[8px] text-[var(--text-tertiary)] transition-colors transition-transform duration-100 group-hover:text-[var(--text-primary)]',
|
||||
isExpanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
@@ -684,7 +522,7 @@ const TraceSpanNode = memo(function TraceSpanNode({
|
||||
className='min-w-0 max-w-[180px] truncate font-medium text-caption'
|
||||
style={{ color: showErrorStyle ? 'var(--text-error)' : 'var(--text-secondary)' }}
|
||||
>
|
||||
{span.name}
|
||||
{getDisplayName(span)}
|
||||
</span>
|
||||
{isToggleable && (
|
||||
<ChevronDown
|
||||
|
||||
+18
-106
@@ -29,16 +29,22 @@ import {
|
||||
Search as SearchIcon,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getBlock, getBlockByToolName } from '@/blocks'
|
||||
import {
|
||||
formatCostAmount,
|
||||
formatTokenCount,
|
||||
formatTps,
|
||||
formatTtft,
|
||||
getBlockIconAndColor,
|
||||
getDisplayName,
|
||||
hasErrorInTree,
|
||||
hasUnhandledErrorInTree,
|
||||
isIterationType,
|
||||
parseTime,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
|
||||
const DEFAULT_BLOCK_COLOR = '#6b7280'
|
||||
const DEFAULT_TREE_PANE_WIDTH = 360
|
||||
const MIN_TREE_PANE_WIDTH = 200
|
||||
const MAX_TREE_PANE_WIDTH = 600
|
||||
@@ -57,28 +63,6 @@ interface FlatSpanEntry {
|
||||
parentDuration?: number
|
||||
}
|
||||
|
||||
interface BlockAppearance {
|
||||
icon: React.ComponentType<{ className?: string }> | null
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a timestamp or numeric ms into milliseconds since epoch.
|
||||
*/
|
||||
function parseTime(value?: string | number | null): number {
|
||||
if (!value) return 0
|
||||
const ms = typeof value === 'number' ? value : new Date(value).getTime()
|
||||
return Number.isFinite(ms) ? ms : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a span type represents a loop or parallel iteration container.
|
||||
*/
|
||||
function isIterationType(type: string): boolean {
|
||||
const lower = type?.toLowerCase() || ''
|
||||
return lower === 'loop-iteration' || lower === 'parallel-iteration'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stable id for a span, synthesized when absent.
|
||||
*/
|
||||
@@ -86,27 +70,6 @@ function getSpanId(span: TraceSpan): string {
|
||||
return span.id || `span-${span.name}-${span.startTime}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a span's descendants to determine if any error exists in the subtree.
|
||||
*/
|
||||
function hasErrorInTree(span: TraceSpan): boolean {
|
||||
if (span.status === 'error') return true
|
||||
if (span.children?.length) return span.children.some(hasErrorInTree)
|
||||
if (span.toolCalls?.length) return span.toolCalls.some((tc) => tc.error)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `hasErrorInTree` but only counts errors that were not handled by an
|
||||
* error-handler path. Used for the root workflow status color.
|
||||
*/
|
||||
function hasUnhandledErrorInTree(span: TraceSpan): boolean {
|
||||
if (span.status === 'error' && !span.errorHandled) return true
|
||||
if (span.children?.length) return span.children.some(hasUnhandledErrorInTree)
|
||||
if (span.toolCalls?.length && !span.errorHandled) return span.toolCalls.some((tc) => tc.error)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes and sorts a tree of spans by start time.
|
||||
*/
|
||||
@@ -147,33 +110,6 @@ function getDisplayChildren(span: TraceSpan): TraceSpan[] {
|
||||
return kids
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the block icon and accent color for a trace span type.
|
||||
*/
|
||||
function getBlockAppearance(type: string, toolName?: string, provider?: string): BlockAppearance {
|
||||
const lowerType = type.toLowerCase()
|
||||
if (lowerType === 'tool' && toolName) {
|
||||
if (toolName === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' }
|
||||
const toolBlock = getBlockByToolName(toolName)
|
||||
if (toolBlock) return { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
|
||||
}
|
||||
if (lowerType === 'loop' || lowerType === 'loop-iteration')
|
||||
return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
|
||||
if (lowerType === 'parallel' || lowerType === 'parallel-iteration')
|
||||
return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
|
||||
if (lowerType === 'workflow') return { icon: WorkflowIcon, bgColor: '#6366F1' }
|
||||
if (lowerType === 'model' && provider) {
|
||||
const providerDef = PROVIDER_DEFINITIONS[provider]
|
||||
if (providerDef?.icon) {
|
||||
return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_COLOR }
|
||||
}
|
||||
}
|
||||
const blockType = lowerType === 'model' ? 'agent' : lowerType
|
||||
const blockConfig = getBlock(blockType)
|
||||
if (blockConfig) return { icon: blockConfig.icon, bgColor: blockConfig.bgColor }
|
||||
return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
|
||||
}
|
||||
|
||||
/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
|
||||
function iconColorClass(bgColor: string): string {
|
||||
const hex = bgColor.replace('#', '')
|
||||
@@ -184,30 +120,6 @@ function iconColorClass(bgColor: string): string {
|
||||
return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white'
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number | undefined): string | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
function formatCostAmount(value: number | undefined): string | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
if (value < 0.0001) return '<$0.0001'
|
||||
return `$${value.toFixed(4)}`
|
||||
}
|
||||
|
||||
function formatTtft(ms: number | undefined): string | undefined {
|
||||
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
function formatTps(outputTokens: number | undefined, durationMs: number): string | undefined {
|
||||
if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined
|
||||
if (!(durationMs > 0)) return undefined
|
||||
const tps = Math.round(outputTokens / (durationMs / 1000))
|
||||
return tps > 0 ? `${tps.toLocaleString('en-US')} tok/s` : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the visible (expanded) span tree into a linear list for keyboard
|
||||
* navigation, carrying depth, the chain of parent ids for indent drawing, and
|
||||
@@ -294,7 +206,7 @@ function findSpan(spans: TraceSpan[], id: string | null): TraceSpan | null {
|
||||
*/
|
||||
function spanMatchesQuery(span: TraceSpan, query: string): boolean {
|
||||
if (!query) return true
|
||||
return (span.name ?? '').toLowerCase().includes(query.toLowerCase())
|
||||
return getDisplayName(span).toLowerCase().includes(query.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,7 +268,7 @@ const TraceTreeRow = memo(function TraceTreeRow({
|
||||
const duration = span.duration || endMs - startMs
|
||||
const isRootWorkflow = depth === 0 && span.type?.toLowerCase() === 'workflow'
|
||||
const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span)
|
||||
const { icon: BlockIcon, bgColor } = getBlockAppearance(span.type, span.name, span.provider)
|
||||
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name, span.provider)
|
||||
const nameMatches = !!matchQuery && spanMatchesQuery(span, matchQuery)
|
||||
|
||||
const offsetMs = runStartMs > 0 ? Math.max(0, startMs - runStartMs) : 0
|
||||
@@ -431,12 +343,12 @@ const TraceTreeRow = memo(function TraceTreeRow({
|
||||
nameMatches && 'text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{span.name}
|
||||
{getDisplayName(span)}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='right' className='max-w-[320px]'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='font-medium'>{span.name}</span>
|
||||
<span className='font-medium'>{getDisplayName(span)}</span>
|
||||
<span className='text-[var(--text-tertiary)] text-caption'>
|
||||
{formatDuration(duration, { precision: 2 }) || '—'}
|
||||
{offsetMs > 0 && ` · +${formatDuration(offsetMs, { precision: 2 })}`}
|
||||
@@ -738,7 +650,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
|
||||
}
|
||||
|
||||
const duration = span.duration || parseTime(span.endTime) - parseTime(span.startTime)
|
||||
const { icon: BlockIcon, bgColor } = getBlockAppearance(span.type, span.name, span.provider)
|
||||
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name, span.provider)
|
||||
const isRootWorkflow = span.type?.toLowerCase() === 'workflow'
|
||||
const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span)
|
||||
const isDirectError = span.status === 'error'
|
||||
@@ -797,7 +709,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
|
||||
hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{span.name}
|
||||
{getDisplayName(span)}
|
||||
</h3>
|
||||
<div className='flex items-center gap-1.5 font-medium text-[var(--text-tertiary)] text-caption'>
|
||||
<Badge variant={hasError ? 'red' : 'green'} size='sm'>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type React from 'react'
|
||||
import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getBlock, getBlockByToolName } from '@/blocks'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { normalizeToolId } from '@/tools/normalize'
|
||||
|
||||
export const DEFAULT_BLOCK_COLOR = '#6b7280'
|
||||
|
||||
export interface BlockIconAndColor {
|
||||
icon: React.ComponentType<{ className?: string }> | null
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
export function isIterationType(type: string): boolean {
|
||||
const lower = type?.toLowerCase() || ''
|
||||
return lower === 'loop-iteration' || lower === 'parallel-iteration'
|
||||
}
|
||||
|
||||
export function hasErrorInTree(span: TraceSpan): boolean {
|
||||
if (span.status === 'error') return true
|
||||
if (span.children?.length) return span.children.some(hasErrorInTree)
|
||||
if (span.toolCalls?.length) return span.toolCalls.some((tc) => tc.error)
|
||||
return false
|
||||
}
|
||||
|
||||
export function hasUnhandledErrorInTree(span: TraceSpan): boolean {
|
||||
if (span.status === 'error' && !span.errorHandled) return true
|
||||
if (span.children?.length) return span.children.some(hasUnhandledErrorInTree)
|
||||
if (span.toolCalls?.length && !span.errorHandled) return span.toolCalls.some((tc) => tc.error)
|
||||
return false
|
||||
}
|
||||
|
||||
export function getBlockIconAndColor(
|
||||
type: string,
|
||||
toolName?: string,
|
||||
provider?: string
|
||||
): BlockIconAndColor {
|
||||
const lowerType = type.toLowerCase()
|
||||
if (lowerType === 'tool' && toolName) {
|
||||
const normalized = normalizeToolId(toolName)
|
||||
if (normalized === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' }
|
||||
const toolBlock = getBlockByToolName(normalized)
|
||||
if (toolBlock) return { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
|
||||
}
|
||||
if (lowerType === 'loop' || lowerType === 'loop-iteration')
|
||||
return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
|
||||
if (lowerType === 'parallel' || lowerType === 'parallel-iteration')
|
||||
return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
|
||||
if (lowerType === 'workflow') return { icon: WorkflowIcon, bgColor: '#6366F1' }
|
||||
if (lowerType === 'model' && provider) {
|
||||
const providerDef = PROVIDER_DEFINITIONS[provider]
|
||||
if (providerDef?.icon)
|
||||
return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_COLOR }
|
||||
}
|
||||
const blockType = lowerType === 'model' ? 'agent' : lowerType
|
||||
const blockConfig = getBlock(blockType)
|
||||
if (blockConfig) return { icon: blockConfig.icon, bgColor: blockConfig.bgColor }
|
||||
return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
|
||||
}
|
||||
|
||||
export function parseTime(value?: string | number | null): number {
|
||||
if (!value) return 0
|
||||
const ms = typeof value === 'number' ? value : new Date(value).getTime()
|
||||
return Number.isFinite(ms) ? ms : 0
|
||||
}
|
||||
|
||||
export function formatTokenCount(value: number | undefined): string | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
export function formatTtft(ms: number | undefined): string | undefined {
|
||||
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
export function formatTps(
|
||||
outputTokens: number | undefined,
|
||||
durationMs: number
|
||||
): string | undefined {
|
||||
if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined
|
||||
if (!(durationMs > 0)) return undefined
|
||||
const tps = Math.round(outputTokens / (durationMs / 1000))
|
||||
return tps > 0 ? `${tps.toLocaleString('en-US')} tok/s` : undefined
|
||||
}
|
||||
|
||||
export function getDisplayName(span: TraceSpan): string {
|
||||
if (span.type?.toLowerCase() === 'tool') return normalizeToolId(span.name)
|
||||
return span.name
|
||||
}
|
||||
|
||||
export function formatCostAmount(value: number | undefined): string | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
const credits = dollarsToCredits(value)
|
||||
if (credits <= 0) return '<1 credit'
|
||||
return `${credits.toLocaleString('en-US')} ${credits === 1 ? 'credit' : 'credits'}`
|
||||
}
|
||||
|
||||
export function formatTokensSummary(tokens: TraceSpan['tokens']): string | undefined {
|
||||
if (!tokens) return undefined
|
||||
const parts: string[] = []
|
||||
const input = formatTokenCount(tokens.input)
|
||||
const output = formatTokenCount(tokens.output)
|
||||
const total = formatTokenCount(tokens.total)
|
||||
const cacheRead = formatTokenCount(tokens.cacheRead)
|
||||
const cacheWrite = formatTokenCount(tokens.cacheWrite)
|
||||
const reasoning = formatTokenCount(tokens.reasoning)
|
||||
if (input) parts.push(`${input} in`)
|
||||
if (cacheRead) parts.push(`${cacheRead} cached`)
|
||||
if (cacheWrite) parts.push(`${cacheWrite} cache write`)
|
||||
if (output) parts.push(`${output} out`)
|
||||
if (reasoning) parts.push(`${reasoning} reasoning`)
|
||||
if (total) parts.push(`${total} total`)
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined
|
||||
}
|
||||
+2
-37
@@ -550,44 +550,9 @@ async function applyHostedKeyCostToResult(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a tool ID by stripping resource ID suffix (UUID/tableId).
|
||||
* Workflow tools: 'workflow_executor_<uuid>' -> 'workflow_executor'
|
||||
* Knowledge tools: 'knowledge_search_<uuid>' -> 'knowledge_search'
|
||||
* Table tools: 'table_query_rows_<tableId>' -> 'table_query_rows'
|
||||
*/
|
||||
function normalizeToolId(toolId: string): string {
|
||||
if (toolId.startsWith('workflow_executor_') && toolId.length > 'workflow_executor_'.length) {
|
||||
return 'workflow_executor'
|
||||
}
|
||||
import { normalizeToolId } from '@/tools/normalize'
|
||||
|
||||
const knowledgeOps = ['knowledge_search', 'knowledge_upload_chunk', 'knowledge_create_document']
|
||||
for (const op of knowledgeOps) {
|
||||
if (toolId.startsWith(`${op}_`) && toolId.length > op.length + 1) {
|
||||
return op
|
||||
}
|
||||
}
|
||||
|
||||
const tableOps = [
|
||||
'table_query_rows',
|
||||
'table_insert_row',
|
||||
'table_batch_insert_rows',
|
||||
'table_update_row',
|
||||
'table_update_rows_by_filter',
|
||||
'table_delete_rows_by_filter',
|
||||
'table_upsert_row',
|
||||
'table_get_row',
|
||||
'table_delete_row',
|
||||
'table_get_schema',
|
||||
]
|
||||
for (const op of tableOps) {
|
||||
if (toolId.startsWith(`${op}_`) && toolId.length > op.length + 1) {
|
||||
return op
|
||||
}
|
||||
}
|
||||
|
||||
return toolId
|
||||
}
|
||||
export { normalizeToolId } from '@/tools/normalize'
|
||||
|
||||
/**
|
||||
* Maximum request body size in bytes before we warn/error about size limits.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Normalizes a tool ID by stripping resource ID suffix (UUID/tableId).
|
||||
* Workflow tools: 'workflow_executor_<uuid>' -> 'workflow_executor'
|
||||
* Knowledge tools: 'knowledge_search_<uuid>' -> 'knowledge_search'
|
||||
* Table tools: 'table_query_rows_<tableId>' -> 'table_query_rows'
|
||||
*
|
||||
* Pure string utility — no server dependencies, safe to import in client components.
|
||||
*/
|
||||
export function normalizeToolId(toolId: string): string {
|
||||
if (toolId.startsWith('workflow_executor_') && toolId.length > 'workflow_executor_'.length) {
|
||||
return 'workflow_executor'
|
||||
}
|
||||
|
||||
const knowledgeOps = ['knowledge_search', 'knowledge_upload_chunk', 'knowledge_create_document']
|
||||
for (const op of knowledgeOps) {
|
||||
if (toolId.startsWith(`${op}_`) && toolId.length > op.length + 1) {
|
||||
return op
|
||||
}
|
||||
}
|
||||
|
||||
const tableOps = [
|
||||
'table_query_rows',
|
||||
'table_insert_row',
|
||||
'table_batch_insert_rows',
|
||||
'table_update_row',
|
||||
'table_update_rows_by_filter',
|
||||
'table_delete_rows_by_filter',
|
||||
'table_upsert_row',
|
||||
'table_get_row',
|
||||
'table_delete_row',
|
||||
'table_get_schema',
|
||||
]
|
||||
for (const op of tableOps) {
|
||||
if (toolId.startsWith(`${op}_`) && toolId.length > op.length + 1) {
|
||||
return op
|
||||
}
|
||||
}
|
||||
|
||||
return toolId
|
||||
}
|
||||
Reference in New Issue
Block a user