import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Box, Stack, Typography, Chip, Button, TextField, IconButton, Link, CircularProgress } from '@mui/material'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; import LaunchIcon from '@mui/icons-material/Launch'; import SendIcon from '@mui/icons-material/Send'; import BlockIcon from '@mui/icons-material/Block'; import { useAgentStore, type ResearchAgentState } from '@/store/agentStore'; import { useLayoutStore } from '@/store/layoutStore'; import { logger } from '@/utils/logger'; import { RESEARCH_MAX_STEPS } from '@/lib/research-store'; import type { UIMessage } from 'ai'; // --------------------------------------------------------------------------- // Type helpers — extract the dynamic-tool part type from UIMessage // --------------------------------------------------------------------------- type DynamicToolPart = Extract; type ToolPartState = DynamicToolPart['state']; /** Check if a tool part was cancelled (output-error with cancellation message). */ function isCancelledTool(tool: DynamicToolPart): boolean { return tool.state === 'output-error' && typeof (tool as Record).errorText === 'string' && ((tool as Record).errorText as string).includes('Cancelled by user'); } interface ToolCallGroupProps { tools: DynamicToolPart[]; approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>) => Promise; } // --------------------------------------------------------------------------- // Research sub-steps (inline under the research tool row) // --------------------------------------------------------------------------- /** Hook that forces a re-render every second while enabled — used so each * research card can compute its own elapsed seconds synchronously from * Date.now() without needing its own timer. */ function useSecondTick(enabled: boolean): void { const [, setTick] = useState(0); useEffect(() => { if (!enabled) return; const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, [enabled]); } /** Compute elapsed seconds from startedAt (or null). Call under useSecondTick. */ function computeElapsed(startedAt: number | null): number | null { if (startedAt === null) return null; return Math.round((Date.now() - startedAt) / 1000); } /** Format token count like the CLI: "12.4k" or "800". */ function formatTokens(tokens: number): string { return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens); } /** Format elapsed seconds like the CLI: "18s" or "2m 5s". */ function formatElapsed(seconds: number): string { if (seconds < 60) return `${seconds}s`; return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; } /** Build the research stats chip label. */ function researchChipLabel( stats: { toolCount: number; tokenCount: number; startedAt: number | null; finalElapsed: number | null }, liveElapsed: number | null, ): string | null { const elapsed = stats.finalElapsed ?? liveElapsed; if (elapsed === null && stats.toolCount === 0) return null; const parts: string[] = []; if (stats.startedAt !== null) parts.push('running'); if (stats.toolCount > 0) parts.push(`${stats.toolCount} tools`); if (stats.tokenCount > 0) parts.push(`${formatTokens(stats.tokenCount)} tokens`); if (elapsed !== null) parts.push(formatElapsed(elapsed)); return parts.join(' \u00B7 '); } /** Parse JSON args from a step string like "tool_name {json}" (may be truncated at 80 chars). */ function parseStepArgs(step: string): Record { const jsonStart = step.indexOf('{'); if (jsonStart < 0) return {}; const jsonStr = step.slice(jsonStart); try { const parsed = JSON.parse(jsonStr); const result: Record = {}; for (const [k, v] of Object.entries(parsed)) { if (typeof v === 'string') result[k] = v; } return result; } catch { // JSON likely truncated — extract key-value pairs via regex const result: Record = {}; // Match complete "key": "value" pairs for (const m of jsonStr.matchAll(/"(\w+)":\s*"([^"]*)"/g)) { result[m[1]] = m[2]; } // Match truncated trailing value: "key": "value... (no closing quote) if (Object.keys(result).length === 0 || !result.query) { const trunc = jsonStr.match(/"(\w+)":\s*"([^"]+)$/); if (trunc && !result[trunc[1]]) { result[trunc[1]] = trunc[2]; } } return result; } } /** Pretty labels for research sub-agent tool calls */ function formatResearchStep(raw: string): { label: string } { // Backend sends logs like "▸ tool_name {args}" — strip the prefix const step = raw.replace(/^▸\s*/, ''); const args = parseStepArgs(step); if (step.startsWith('github_find_examples')) { const detail = (args.keyword) || (args.repo); return { label: detail ? `Finding examples: ${detail}` : 'Finding examples' }; } if (step.startsWith('github_read_file')) { const path = (args.path) || ''; const filename = path.split('/').pop() || path; return { label: filename ? `Reading ${filename}` : 'Reading file' }; } if (step.startsWith('explore_hf_docs')) { const endpoint = (args.endpoint) || (args.query); return { label: endpoint ? `Exploring docs: ${endpoint}` : 'Exploring docs' }; } if (step.startsWith('fetch_hf_docs')) { const url = (args.url) || ''; const page = url.split('/').pop()?.replace(/\.md$/, ''); return { label: page ? `Reading docs: ${page}` : 'Fetching docs' }; } if (step.startsWith('hf_inspect_dataset')) { const dataset = (args.dataset); return { label: dataset ? `Inspecting dataset: ${dataset}` : 'Inspecting dataset' }; } if (step.startsWith('hf_papers')) { const op = args.operation as string; const detail = (args.query) || (args.arxiv_id); const opLabels: Record = { trending: 'Browsing trending papers', search: 'Searching papers', paper_details: 'Reading paper details', read_paper: 'Reading paper', citation_graph: 'Tracing citations', snippet_search: 'Searching paper snippets', recommend: 'Finding related papers', find_datasets: 'Finding paper datasets', find_models: 'Finding paper models', find_collections: 'Finding paper collections', find_all_resources: 'Finding paper resources', }; const base = (op && opLabels[op]) || 'Searching papers'; return { label: detail ? `${base}: ${detail}` : base }; } if (step.startsWith('find_hf_api')) { const detail = (args.query) || (args.tag); return { label: detail ? `Finding API: ${detail}` : 'Finding API endpoints' }; } if (step.startsWith('hf_repo_files')) { const repo = (args.repo_id) || (args.repo); return { label: repo ? `Reading ${repo} files` : 'Reading repo files' }; } if (step.startsWith('read')) { const path = (args.path) || ''; const filename = path.split('/').pop(); return { label: filename ? `Reading ${filename}` : 'Reading file' }; } if (step.startsWith('bash')) { const cmd = args.command as string; const short = cmd && cmd.length > 40 ? cmd.slice(0, 40) + '...' : cmd; return { label: short ? `Running: ${short}` : 'Running command' }; } return { label: step.replace(/^▸\s*/, '') }; } /** Rolling display of research sub-tool calls for a single agent. */ function ResearchSteps({ steps }: { steps: string[] }) { const visible = steps.slice(-RESEARCH_MAX_STEPS); if (visible.length === 0) return null; return ( {visible.map((step, i) => { const { label } = formatResearchStep(step); const isLast = i === visible.length - 1; return ( {isLast ? ( ) : ( )} {label} ); })} ); } // --------------------------------------------------------------------------- // Trackio dashboard embed // --------------------------------------------------------------------------- // HF repo IDs are `/` where each segment is alphanumerics plus // `_`, `.`, `-`. Anything else (slashes, spaces, query params, missing owner) // would let an attacker-controlled string redirect the embed to a different // Space, so we refuse to render rather than build a malformed URL. const SPACE_ID_PATTERN = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/; function isValidSpaceId(spaceId: string): boolean { return SPACE_ID_PATTERN.test(spaceId); } /** HF Space embed subdomain: 'user/space_name' → 'user-space-name'. */ function spaceIdToSubdomain(spaceId: string): string { return spaceId .toLowerCase() .replace(/[/_.]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); } function buildTrackioEmbedUrl(spaceId: string, project?: string): string { // __theme=dark is gradio's standard query param to force the embedded // dashboard into dark mode so it blends with the surrounding chat instead // of flashing a bright white panel inside the dark UI. const params = new URLSearchParams({ sidebar: 'hidden', footer: 'false', __theme: 'dark', }); if (project) params.set('project', project); return `https://${spaceIdToSubdomain(spaceId)}.hf.space/?${params.toString()}`; } function buildTrackioPageUrl(spaceId: string, project?: string): string { const qs = project ? `?${new URLSearchParams({ project }).toString()}` : ''; return `https://huggingface.co/spaces/${spaceId}${qs}`; } function TrackioEmbed({ spaceId, project }: { spaceId: string; project?: string }) { const [expanded, setExpanded] = useState(true); const [iframeLoaded, setIframeLoaded] = useState(false); const embedUrl = useMemo(() => buildTrackioEmbedUrl(spaceId, project), [spaceId, project]); const pageUrl = useMemo(() => buildTrackioPageUrl(spaceId, project), [spaceId, project]); const label = project ? `${spaceId} · ${project}` : spaceId; if (!isValidSpaceId(spaceId)) return null; return ( e.stopPropagation()} sx={{ px: 1.25, py: 0.5, borderBottom: expanded ? '1px solid var(--tool-border)' : 'none', }} > trackio {label} e.stopPropagation()} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, color: 'var(--accent-yellow)', fontSize: '0.65rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' }, }} > Open {expanded && (