mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-10 19:57:44 +02:00
[eric] chat: web tools read as sources (favicon stacks + domains at the shared summary choke point), perplexity treatment
This commit is contained in:
@@ -24,14 +24,7 @@ export function getInputSummary(toolName: string, input: any): string {
|
||||
if (isSettingsReadTool(toolName)) return '';
|
||||
|
||||
const mcp = parseMcpToolName(toolName);
|
||||
if (mcp.isMcp) {
|
||||
// Web tools (our openswarm-web MCP) get the clean source summary, a bare domain / quoted query,
|
||||
// instead of the generic "url: https://… prompt: …" key-value dump. Reads like Perplexity sources.
|
||||
const act = (mcp.action || '').toLowerCase();
|
||||
if (act === 'websearch') return quoteQuery(input.query || input.search_term || '');
|
||||
if (act === 'webfetch') return prettyUrl(input.url || '');
|
||||
return getMcpInputSummary(input, mcp.action, mcp.serverSlug);
|
||||
}
|
||||
if (mcp.isMcp) return getMcpInputSummary(input, mcp.action, mcp.serverSlug);
|
||||
|
||||
const n = toolName.toLowerCase();
|
||||
if (isBashTool(toolName)) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ParsedResult } from '../parsing/toolResultParsing';
|
||||
import { isSettingsWriteTool, settingsWriteSummary } from '../parsing/settingsToolMeta';
|
||||
import { McpToolInfo, getMcpShortAction, getMcpInputSummary, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
|
||||
import { McpResultCard } from '../mcp-cards/McpResultCard';
|
||||
import { domainFromUrl, faviconUrlForDomain } from './SourceFavicons';
|
||||
|
||||
interface CompactMcpBubbleProps {
|
||||
call: AgentMessage;
|
||||
@@ -57,6 +58,9 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
|
||||
})();
|
||||
const serviceLabel = mcpInfo.isMcp ? mcpVerbLabel : shortAction;
|
||||
const inputSummary = mcpInfo.isMcp ? getMcpInputSummary(input, mcpInfo.action, mcpInfo.serverSlug) : '';
|
||||
// Web rows read as sources: favicon beside the domain/query, body text instead of mono.
|
||||
const isWebRow = /web(fetch|search)$/i.test(toolName);
|
||||
const webDomain = /webfetch$/i.test(toolName) && typeof input?.url === 'string' ? domainFromUrl(input.url) : '';
|
||||
// A grouped settings write shows the masked change list (input-derived, so it reads even while pending) instead of the generic "Applied: theme" result line.
|
||||
const visibleSummary = isSettingsWriteTool(toolName)
|
||||
? settingsWriteSummary(input)
|
||||
@@ -95,21 +99,33 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
|
||||
</Typography>
|
||||
)}
|
||||
{visibleSummary && !isError && !stackBelow && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: hideVerbLabel ? c.text.primary : c.text.secondary,
|
||||
fontSize: '0.74rem',
|
||||
// Args are data (ids, URLs, params), so they read in mono, not the body serif.
|
||||
fontFamily: c.font.mono,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
...(showBody && canToggleDetails
|
||||
? { whiteSpace: 'normal', overflowWrap: 'anywhere' }
|
||||
: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
|
||||
}}
|
||||
>
|
||||
{visibleSummary}
|
||||
</Typography>
|
||||
<>
|
||||
{webDomain && (
|
||||
<Box
|
||||
component="img"
|
||||
src={faviconUrlForDomain(webDomain)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.style.display = 'none'; }}
|
||||
sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
color: hideVerbLabel ? c.text.primary : c.text.secondary,
|
||||
fontSize: '0.74rem',
|
||||
// Args are data (ids, URLs, params) and read in mono; web rows are SOURCES and read in body text.
|
||||
fontFamily: isWebRow ? undefined : c.font.mono,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
...(showBody && canToggleDetails
|
||||
? { whiteSpace: 'normal', overflowWrap: 'anywhere' }
|
||||
: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
|
||||
}}
|
||||
>
|
||||
{visibleSummary}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{(stackBelow || !visibleSummary) && !showTimer && <Box sx={{ flex: 1 }} />}
|
||||
{showTimer && (
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useTermColors, colorizeInput, colorizeOutput } from '../parsing/toolCol
|
||||
import { ParsedResult } from '../parsing/toolResultParsing';
|
||||
import { McpToolInfo } from '@/shared/mcpToolMeta';
|
||||
import { McpResultCard } from '../mcp-cards/McpResultCard';
|
||||
import { domainFromUrl, faviconUrlForDomain } from './SourceFavicons';
|
||||
|
||||
interface DefaultToolBubbleProps {
|
||||
call: AgentMessage;
|
||||
@@ -59,6 +60,10 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
const reveal = useMountReveal();
|
||||
const enterStyle = (!mcpCompact && !suppressReveal) ? reveal : {};
|
||||
const canToggleDetails = !!inputSummary && !isStreaming;
|
||||
// A web read shows its SOURCE (favicon + domain), not a url dump; the Perplexity treatment.
|
||||
const webDomain = /webfetch$/i.test(toolName) && typeof input?.url === 'string'
|
||||
? domainFromUrl(input.url)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -82,7 +87,8 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
}`,
|
||||
borderRadius: mcpCompact ? 0 : 2,
|
||||
overflow: 'hidden',
|
||||
animation: (isPending || isStreaming) && !mcpCompact ? 'border-glow 2s ease-in-out infinite' : 'none',
|
||||
// Live state stays calm: the accent border + the ElapsedTimer's small pulsing dot carry
|
||||
// "working"; the old whole-bubble box-shadow glow loop read as noise (animation-purge rule).
|
||||
transition: 'border-color 0.3s, box-shadow 0.3s',
|
||||
} as any}
|
||||
>
|
||||
@@ -134,18 +140,29 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
</Typography>
|
||||
)}
|
||||
{inputSummary && !isStreaming && (
|
||||
<Typography
|
||||
noWrap
|
||||
sx={{
|
||||
color: c.text.tertiary,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: c.font.mono,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{inputSummary}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flex: 1, minWidth: 0 }}>
|
||||
{webDomain && (
|
||||
<Box
|
||||
component="img"
|
||||
src={faviconUrlForDomain(webDomain)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.style.display = 'none'; }}
|
||||
sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Typography
|
||||
noWrap
|
||||
sx={{
|
||||
color: c.text.tertiary,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: webDomain ? undefined : c.font.mono,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{inputSummary}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{!inputSummary && <Box sx={{ flex: 1 }} />}
|
||||
{isStreaming && <Box sx={{ flex: 1 }} />}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
// Same favicon service LibreChat ships with; the browser cards already load arbitrary sites, so
|
||||
// fetching site icons adds no new exposure class.
|
||||
export function faviconUrlForDomain(domain: string): string {
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
|
||||
}
|
||||
|
||||
export function domainFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).host.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, '').split(/[/?#]/)[0];
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_STACK = 3;
|
||||
|
||||
const FaviconDot: React.FC<{ domain: string; size: number; overlap: boolean; z: number }> = ({ domain, size, overlap, z }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const ring = {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
border: `1.5px solid ${c.bg.elevated}`,
|
||||
bgcolor: c.bg.secondary,
|
||||
ml: overlap ? '-6px' : 0,
|
||||
zIndex: z,
|
||||
position: 'relative' as const,
|
||||
flexShrink: 0,
|
||||
};
|
||||
if (failed) {
|
||||
// assistant-ui's fallback: the domain's first letter beats a hole in the stack.
|
||||
return (
|
||||
<Box sx={{ ...ring, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: c.text.tertiary, fontSize: size * 0.55, fontWeight: 700 }}>
|
||||
{(domain[0] || '?').toUpperCase()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={faviconUrlForDomain(domain)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
sx={ring}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** Perplexity-style overlapping favicon stack for web-source rows. */
|
||||
export const SourceFavicons: React.FC<{ domains: string[]; size?: number }> = ({ domains, size = 16 }) => {
|
||||
const c = useClaudeTokens();
|
||||
if (domains.length === 0) return null;
|
||||
const shown = domains.slice(0, MAX_STACK);
|
||||
const extra = domains.length - shown.length;
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
{shown.map((d, i) => (
|
||||
<FaviconDot key={d} domain={d} size={size} overlap={i !== 0} z={MAX_STACK - i} />
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
ml: '-6px',
|
||||
minWidth: size,
|
||||
height: size,
|
||||
px: 0.4,
|
||||
borderRadius: 999,
|
||||
border: `1.5px solid ${c.bg.elevated}`,
|
||||
bgcolor: c.bg.secondary,
|
||||
color: c.text.tertiary,
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
+{extra}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { useMountReveal } from './useMountReveal';
|
||||
import { sanitizeSvgString } from '@/shared/sanitizeSvg';
|
||||
import { parseMcpToolName, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
|
||||
import ToolCallBubble, { ToolPair } from './ToolCallBubble';
|
||||
import { SourceFavicons, domainFromUrl } from './SourceFavicons';
|
||||
|
||||
export type ToolGroupEntry =
|
||||
| { kind: 'pair'; pair: ToolPair }
|
||||
@@ -107,8 +108,24 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
}).filter(Boolean))) as string[];
|
||||
return parsedLabels.length === 1 ? parsedLabels[0] : 'Workflow actions';
|
||||
})();
|
||||
const displayName = workflowGroupLabel || meta?.name || group.label;
|
||||
const hasSvg = !!meta?.svg && !workflowGroupLabel;
|
||||
// A web group is a SEARCH, so it wears its sources: favicon stack + "Searched the web", the
|
||||
// Perplexity read (same special-case precedent as openswarm-schedule above).
|
||||
const webDomains = useMemo(() => {
|
||||
if (group.mcpServer !== 'openswarm-web') return null;
|
||||
const domains: string[] = [];
|
||||
for (const p of group.pairs) {
|
||||
const cc = typeof p.call.content === 'object' ? p.call.content : {};
|
||||
const url = (cc.input as { url?: unknown } | undefined)?.url;
|
||||
if (typeof url === 'string' && url) {
|
||||
const d = domainFromUrl(url);
|
||||
if (d && !domains.includes(d)) domains.push(d);
|
||||
}
|
||||
}
|
||||
return domains;
|
||||
}, [group]);
|
||||
const webGroupLabel = webDomains ? 'Searched the web' : null;
|
||||
const displayName = workflowGroupLabel || webGroupLabel || meta?.name || group.label;
|
||||
const hasSvg = !!meta?.svg && !workflowGroupLabel && !webGroupLabel;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -141,21 +158,27 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
gap: 0.75,
|
||||
py: 0.4,
|
||||
cursor: 'pointer',
|
||||
color: c.text.tertiary,
|
||||
'&:hover': { color: c.text.secondary },
|
||||
}}
|
||||
>
|
||||
{webDomains && webDomains.length > 0 && <SourceFavicons domains={webDomains} size={16} />}
|
||||
<Typography sx={{ fontSize: '0.8rem', fontWeight: 500, color: 'inherit' }}>
|
||||
{group.callCount} tool call{group.callCount === 1 ? '' : 's'}
|
||||
{webGroupLabel ?? `${group.callCount} tool call${group.callCount === 1 ? '' : 's'}`}
|
||||
</Typography>
|
||||
{!allDone && (
|
||||
<Typography sx={{ fontSize: '0.7rem', color: 'inherit', fontFamily: c.font.mono, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{completedCount}/{group.callCount}
|
||||
</Typography>
|
||||
)}
|
||||
{allDone && webDomains && webDomains.length > 0 && (
|
||||
<Typography sx={{ fontSize: '0.7rem', color: 'inherit', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{webDomains.length} source{webDomains.length === 1 ? '' : 's'}
|
||||
</Typography>
|
||||
)}
|
||||
{deniedCount > 0 && (
|
||||
<Typography sx={{ color: c.status.error, fontSize: '0.68rem' }}>
|
||||
{deniedCount} denied
|
||||
@@ -176,7 +199,9 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.02)' },
|
||||
}}
|
||||
>
|
||||
{!meta ? (
|
||||
{webDomains && webDomains.length > 0 ? (
|
||||
<SourceFavicons domains={webDomains} size={16} />
|
||||
) : !meta ? (
|
||||
<SkeletonPulse width={15} height={15} borderRadius={8} />
|
||||
) : hasSvg ? (
|
||||
<GeneratedSvgIcon svg={meta.svg} size={15} color={c.accent.primary} />
|
||||
@@ -184,7 +209,7 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
<TerminalIcon sx={{ fontSize: 15, color: c.accent.primary, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
{!meta ? (
|
||||
{!meta && !webGroupLabel && !workflowGroupLabel ? (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<SkeletonPulse width={100} height={12} />
|
||||
</Box>
|
||||
@@ -231,8 +256,13 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
sx={{
|
||||
borderTop: `0.5px solid ${c.border.medium}`,
|
||||
'& > *': {
|
||||
animation: 'toolRowFadeIn 140ms ease-out',
|
||||
animation: 'toolRowFadeIn 140ms ease-out backwards',
|
||||
},
|
||||
// Staggered entrance (assistant-ui's tool-group treatment): rows cascade instead of popping at once.
|
||||
'& > *:nth-of-type(2)': { animationDelay: '40ms' },
|
||||
'& > *:nth-of-type(3)': { animationDelay: '80ms' },
|
||||
'& > *:nth-of-type(4)': { animationDelay: '120ms' },
|
||||
'& > *:nth-of-type(n+5)': { animationDelay: '160ms' },
|
||||
'@keyframes toolRowFadeIn': {
|
||||
from: { opacity: 0, transform: 'translateY(-2px)' },
|
||||
to: { opacity: 1, transform: 'translateY(0)' },
|
||||
|
||||
@@ -166,6 +166,20 @@ function p_cleanSummaryText(s: string): string {
|
||||
|
||||
export function getMcpInputSummary(input: any, action?: string, serverSlug?: string): string {
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
// Web tools read as SOURCES, not payload dumps: a quoted query for a search, a bare domain for a
|
||||
// page read. Lives here so every caller (compact rows, labels, approvals) gets the clean form.
|
||||
const p_act = (action || '').toLowerCase();
|
||||
if (p_act === 'websearch') {
|
||||
const q = input.query ?? input.search_term;
|
||||
if (typeof q === 'string' && q.trim()) return `"${p_cleanSummaryText(q.trim())}"`;
|
||||
}
|
||||
if (p_act === 'webfetch' && typeof input.url === 'string' && input.url) {
|
||||
try {
|
||||
return new URL(input.url).host.replace(/^www\./, '');
|
||||
} catch {
|
||||
return input.url.replace(/^https?:\/\//, '').split(/[/?#]/)[0];
|
||||
}
|
||||
}
|
||||
if (serverSlug === 'openswarm-schedule' || (action && getWorkflowToolLabel(action))) {
|
||||
const workflowSummary = compactWorkflowSchedule(input);
|
||||
if (workflowSummary) return workflowSummary;
|
||||
|
||||
Reference in New Issue
Block a user