[eric] chat: ShowUI widget tier (display-only MCP tool + inline weather/plan/stats/links components, schema-checked w/ plain-bubble fallback)

This commit is contained in:
ciregenz
2026-07-19 21:02:03 -07:00
parent 1367547def
commit 80a164c77c
10 changed files with 637 additions and 2 deletions
@@ -72,6 +72,14 @@ def build_effective_tool_lists(
effective_disallowed.append("mcp__openswarm-skill__Skill")
continue
if name == "openswarm-ui":
policy = builtin_perms.get("ShowUI", "always_allow")
if policy == "always_allow":
effective_allowed.append("mcp__openswarm-ui__ShowUI")
else:
effective_disallowed.append("mcp__openswarm-ui__ShowUI")
continue
if name == "openswarm-web":
# Expose our DDG-backed web tools under an MCP prefix. Honor existing WebSearch/WebFetch permission policy, if the user disabled them in Settings, don't offer the MCP variants either.
for wt in ("WebSearch", "WebFetch"):
@@ -142,6 +142,19 @@ def register_builtin_mcp_servers(
"type": "stdio",
}
# Display-only ShowUI server: renders rich inline components (weather, plan, stats, links)
# in the transcript. Pure display, no state mutation; the frontend renders from the
# tool_call input, the server only validates. Gated on the ShowUI builtin perm.
show_ui_denied = builtin_perms.get("ShowUI", "always_allow") == "deny"
if not show_ui_denied:
show_ui_server_path = os.path.join(agents_dir, "show_ui_mcp_server.py")
mcp_servers["openswarm-ui"] = {
"command": sys.executable,
"args": [show_ui_server_path],
"env": {},
"type": "stdio",
}
# Always-on schedule server: ScheduleWorkflow + CRUD + AddWorkflowStep/EditWorkflowStep so the agent (and the workflow Edit Agent) can build and schedule recurring work via the native scheduler instead of cron/launchctl. The 4 scheduling tools are force-gated in path_gate; Cron* is denied in build_effective_tool_lists.
schedule_server_path = os.path.join(
agents_dir, "schedule_mcp_server.py"
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Stdio MCP server exposing ShowUI: render a rich inline component in the chat transcript.
Display-only. The frontend renders the component straight from the tool_call input it already
has in the transcript, so this server just validates the payload and acknowledges; there is no
backend round-trip and nothing here can mutate state.
"""
import json
import sys
MAX_PROPS_BYTES = 20_000
COMPONENT_SPECS = {
"weather": "props: {location: str, temp: number, unit?: 'F'|'C', high?: number, low?: number, condition?: str, forecast?: [{day: str, condition?: str, high: number, low?: number}] (max 7)}",
"plan": "props: {title?: str, steps: [{label: str, status: 'pending'|'in_progress'|'completed'}] (max 20)}",
"stats": "props: {title?: str, stats: [{label: str, value: str, delta?: str, direction?: 'up'|'down'}] (max 8)}",
"links": "props: {links: [{title: str, url: str, description?: str}] (max 10)}",
}
TOOLS = [
{
"name": "ShowUI",
"description": (
"Render a rich inline UI component in the chat instead of describing data as text. "
"Use it whenever a result fits one of the shapes. Supported components:\n"
+ "\n".join(f"- '{name}': {spec}" for name, spec in COMPONENT_SPECS.items())
+ "\nCall it with the component name and a props object matching that shape. "
"The component renders in place of raw text; still give a one-line text summary after."
),
"inputSchema": {
"type": "object",
"properties": {
"component": {
"type": "string",
"enum": list(COMPONENT_SPECS.keys()),
"description": "Which component to render.",
},
"props": {
"type": "object",
"description": "Data for the component, matching its documented shape.",
},
},
"required": ["component", "props"],
},
},
]
def send_response(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def validate(component: str, props: dict) -> str:
if component not in COMPONENT_SPECS:
return f"Unknown component {component!r}. Supported: {', '.join(COMPONENT_SPECS)}."
try:
size = len(json.dumps(props))
except (TypeError, ValueError):
return "props must be JSON-serializable."
if size > MAX_PROPS_BYTES:
return f"props too large ({size} bytes; max {MAX_PROPS_BYTES})."
if component == "weather" and not (isinstance(props.get("location"), str) and isinstance(props.get("temp"), (int, float))):
return f"weather needs at least location + temp. {COMPONENT_SPECS['weather']}"
if component == "plan" and not (isinstance(props.get("steps"), list) and props["steps"]):
return f"plan needs a non-empty steps list. {COMPONENT_SPECS['plan']}"
if component == "stats" and not (isinstance(props.get("stats"), list) and props["stats"]):
return f"stats needs a non-empty stats list. {COMPONENT_SPECS['stats']}"
if component == "links" and not (isinstance(props.get("links"), list) and props["links"]):
return f"links needs a non-empty links list. {COMPONENT_SPECS['links']}"
return ""
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
if tool_name != "ShowUI":
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
component = str(arguments.get("component", "")).strip()
props = arguments.get("props")
if not isinstance(props, dict):
return {"content": [{"type": "text", "text": "props must be an object."}], "isError": True}
problem = validate(component, props)
if problem:
return {"content": [{"type": "text", "text": f"Not rendered: {problem}"}], "isError": True}
return {"content": [{"type": "text", "text": f"Rendered a '{component}' component inline."}]}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
method = msg.get("method")
id_ = msg.get("id")
params = msg.get("params", {}) or {}
if method == "initialize":
send_response(id_, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "openswarm-ui",
"version": "1.0.0",
},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
send_response(id_, {"tools": TOOLS})
elif method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments", {}) or {}
result = handle_tool_call(tool_name, arguments)
send_response(id_, result)
elif method == "ping":
send_response(id_, {})
elif id_ is not None:
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
+19 -2
View File
@@ -58,6 +58,8 @@ import CompactionMarker from './bubbles/CompactionMarker';
import MessageActionBar from './shell/MessageActionBar';
import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble';
import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble';
import ToolUiBubble from './tool-ui/ToolUiBubble';
import { isShowUiPair } from './tool-ui/showUiPayload';
import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar';
import ForceStopAgentBar from './ForceStopAgentBar';
import { RateLimitPill } from './shell/RateLimitPill';
@@ -1063,15 +1065,21 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
i++;
}
const calls = group.filter((m) => m.role === 'tool_call');
const allCalls = group.filter((m) => m.role === 'tool_call');
const results = group.filter((m) => m.role === 'tool_result');
const pairs: ToolPair[] = calls.map((call, idx) => ({
const allPairs: ToolPair[] = allCalls.map((call, idx) => ({
type: 'tool_pair' as const,
id: `pair-${call.id}`,
call,
result: results[idx] || null,
}));
// ShowUI calls render as inline components, never buried inside a collapsed group.
// They typically cap a run of work, so the quiet group row stays above the widget.
const showUiPairs = allPairs.filter(isShowUiPair);
const pairs = allPairs.filter((p) => !isShowUiPair(p));
const calls = pairs.map((p) => p.call);
const mcpServers = new Set(
calls.map((m) => {
const tool = typeof m.content === 'object' ? m.content.tool || '' : '';
@@ -1113,6 +1121,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
callCount: calls.length,
} satisfies ToolGroup);
}
items.push(...showUiPairs);
} else {
if (!msg.hidden) {
items.push(msg);
@@ -1593,6 +1602,14 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
}
if (isToolPair(item)) {
const isPending = item.result === null && sessionRunning;
if (isShowUiPair(item)) {
return (
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
<ToolUiBubble pair={item} sessionId={session.id} isPending={isPending} suppressReveal={item.call.id === justStreamedId} />
{compactionChip}
</Box>
);
}
return (
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
<ToolCallBubble call={item.call} result={item.result} isPending={isPending} sessionId={session.id} suppressReveal={item.call.id === justStreamedId} />
@@ -0,0 +1,56 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { LinksProps } from './showUiPayload';
function hostOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
/** Tool-UI-style link previews: domain, title, description; opens like any transcript link. */
function LinksWidget({ props }: { props: LinksProps }): React.ReactElement {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, maxWidth: 420 }}>
{props.links.map((l, i) => (
<Box
key={`${i}-${l.url.slice(0, 40)}`}
component="a"
href={l.url}
target="_blank"
rel="noreferrer"
sx={{
display: 'block',
textDecoration: 'none',
borderRadius: '12px',
border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.elevated,
px: 1.75,
py: 1.25,
transition: 'border-color 0.12s',
'&:hover': { borderColor: c.border.strong },
}}
>
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary, mb: 0.25 }}>
{hostOf(l.url)}
</Typography>
<Typography sx={{ fontSize: '0.88rem', fontWeight: 600, color: c.text.primary }}>
{l.title}
</Typography>
{l.description && (
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mt: 0.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{l.description}
</Typography>
)}
</Box>
))}
</Box>
);
}
export default LinksWidget;
@@ -0,0 +1,72 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
import CircularProgress from '@mui/material/CircularProgress';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { PlanProps } from './showUiPayload';
const MAX_VISIBLE = 6;
/** Tool-UI-style plan card: progress summary bar + step checklist. */
function PlanWidget({ props }: { props: PlanProps }): React.ReactElement {
const c = useClaudeTokens();
const done = props.steps.filter((s) => s.status === 'completed').length;
const visible = props.steps.slice(0, MAX_VISIBLE);
const hidden = props.steps.length - visible.length;
return (
<Box
sx={{
width: 320,
borderRadius: '14px',
border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.elevated,
p: 2,
}}
>
{props.title && (
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary, mb: 1.5 }}>
{props.title}
</Typography>
)}
<Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, mb: 0.5 }}>
{done} of {props.steps.length} complete
</Typography>
<Box sx={{ height: 4, borderRadius: 2, bgcolor: c.border.subtle, mb: 1.5, overflow: 'hidden' }}>
<Box sx={{ height: '100%', width: `${(done / props.steps.length) * 100}%`, bgcolor: c.text.primary, transition: 'width 0.3s ease' }} />
</Box>
{visible.map((step, i) => (
<Box key={`${i}-${step.label.slice(0, 24)}`} sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6 }}>
{step.status === 'completed' ? (
<CheckCircleIcon sx={{ fontSize: 17, color: c.text.primary }} />
) : step.status === 'in_progress' ? (
<CircularProgress size={14} thickness={5} sx={{ color: c.text.secondary }} />
) : (
<RadioButtonUncheckedIcon sx={{ fontSize: 17, color: c.border.strong }} />
)}
<Typography
sx={{
fontSize: '0.82rem',
fontWeight: step.status === 'in_progress' ? 600 : 500,
color: step.status === 'pending' ? c.text.muted : c.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{step.label}
</Typography>
</Box>
))}
{hidden > 0 && (
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, pt: 0.5 }}>
... {hidden} more
</Typography>
)}
</Box>
);
}
export default PlanWidget;
@@ -0,0 +1,58 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { StatsProps } from './showUiPayload';
/** Tool-UI-style stat tiles: label, value, optional signed delta. */
function StatsWidget({ props }: { props: StatsProps }): React.ReactElement {
const c = useClaudeTokens();
return (
<Box sx={{ maxWidth: 460 }}>
{props.title && (
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary, mb: 1 }}>
{props.title}
</Typography>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{props.stats.map((s, i) => (
<Box
key={`${i}-${s.label.slice(0, 16)}`}
sx={{
minWidth: 120,
flex: '1 1 120px',
borderRadius: '12px',
border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.elevated,
px: 1.5,
py: 1.25,
}}
>
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', color: c.text.tertiary }}>
{s.label}
</Typography>
<Typography sx={{ fontSize: '1.15rem', fontWeight: 700, color: c.text.primary, mt: 0.25, fontVariantNumeric: 'tabular-nums' }}>
{s.value}
</Typography>
{s.delta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
{s.direction === 'down' ? (
<ArrowDownwardIcon sx={{ fontSize: 12, color: c.status.error }} />
) : (
<ArrowUpwardIcon sx={{ fontSize: 12, color: c.status.success }} />
)}
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: s.direction === 'down' ? c.status.error : c.status.success }}>
{s.delta}
</Typography>
</Box>
)}
</Box>
))}
</Box>
</Box>
);
}
export default StatsWidget;
@@ -0,0 +1,36 @@
import React from 'react';
import Box from '@mui/material/Box';
import ToolCallBubble from '../tool-bubbles/ToolCallBubble';
import type { ToolPair } from '../tool-bubbles/ToolCallBubble';
import { parseShowUiPayload } from './showUiPayload';
import WeatherWidget from './WeatherWidget';
import PlanWidget from './PlanWidget';
import StatsWidget from './StatsWidget';
import LinksWidget from './LinksWidget';
interface ToolUiBubbleProps {
pair: ToolPair;
sessionId: string;
isPending: boolean;
suppressReveal: boolean;
}
/** Renders a ShowUI call as its inline component; any schema mismatch falls back to the plain tool bubble. */
function ToolUiBubble({ pair, sessionId, isPending, suppressReveal }: ToolUiBubbleProps): React.ReactElement {
const payload = parseShowUiPayload(pair);
if (!payload) {
return (
<ToolCallBubble call={pair.call} result={pair.result} isPending={isPending} sessionId={sessionId} suppressReveal={suppressReveal} />
);
}
return (
<Box sx={{ my: 1, contain: 'layout style' }} data-select-type="tool-ui" data-select-id={pair.id} data-select-meta={JSON.stringify({ component: payload.component })}>
{payload.component === 'weather' && <WeatherWidget props={payload.props} />}
{payload.component === 'plan' && <PlanWidget props={payload.props} />}
{payload.component === 'stats' && <StatsWidget props={payload.props} />}
{payload.component === 'links' && <LinksWidget props={payload.props} />}
</Box>
);
}
export default ToolUiBubble;
@@ -0,0 +1,92 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import WbSunnyOutlinedIcon from '@mui/icons-material/WbSunnyOutlined';
import CloudOutlinedIcon from '@mui/icons-material/CloudOutlined';
import GrainIcon from '@mui/icons-material/Grain';
import AcUnitIcon from '@mui/icons-material/AcUnit';
import ThunderstormOutlinedIcon from '@mui/icons-material/ThunderstormOutlined';
import type { WeatherProps } from './showUiPayload';
function conditionIcon(condition: string | undefined, size: number): React.ReactElement {
const cond = (condition || '').toLowerCase();
const sx = { fontSize: size, color: 'rgba(255,255,255,0.92)' };
if (/thunder|storm/.test(cond)) return <ThunderstormOutlinedIcon sx={sx} />;
if (/rain|drizzle|shower/.test(cond)) return <GrainIcon sx={sx} />;
if (/snow|sleet|ice/.test(cond)) return <AcUnitIcon sx={sx} />;
if (/cloud|overcast|fog|mist/.test(cond)) return <CloudOutlinedIcon sx={sx} />;
return <WbSunnyOutlinedIcon sx={sx} />;
}
/** iOS-style weather card: dusk-sky art, big thin temperature, five-day strip. */
function WeatherWidget({ props }: { props: WeatherProps }): React.ReactElement {
const unit = props.unit || 'F';
return (
<Box
sx={{
width: 300,
borderRadius: '16px',
overflow: 'hidden',
position: 'relative',
p: 2,
color: '#fff',
background:
'radial-gradient(120% 90% at 78% 62%, rgba(255,196,110,0.9) 0%, rgba(214,142,90,0.75) 30%, rgba(120,85,80,0.4) 55%, rgba(0,0,0,0) 75%), linear-gradient(180deg, #4d4048 0%, #6b5a58 45%, #8a6a55 100%)',
boxShadow: '0 10px 30px rgba(0,0,0,0.3)',
}}
>
<Typography sx={{ fontSize: '1.05rem', fontWeight: 600, textShadow: '0 1px 8px rgba(0,0,0,0.35)' }}>
{props.location}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'flex-start', mt: 0.5 }}>
<Typography sx={{ fontSize: '4rem', fontWeight: 200, lineHeight: 1, letterSpacing: '-2px', textShadow: '0 2px 12px rgba(0,0,0,0.3)' }}>
{Math.round(props.temp)}
</Typography>
<Typography sx={{ fontSize: '1.6rem', fontWeight: 300, mt: 0.5, opacity: 0.85 }}>°{unit}</Typography>
</Box>
{(props.high != null || props.low != null) && (
<Box sx={{ display: 'flex', gap: 1.5, mt: 1 }}>
{props.high != null && (
<Typography sx={{ fontSize: '0.9rem', fontWeight: 600 }}>
<Box component="span" sx={{ opacity: 0.6, fontWeight: 400 }}>H </Box>{Math.round(props.high)}°
</Typography>
)}
{props.low != null && (
<Typography sx={{ fontSize: '0.9rem', fontWeight: 600 }}>
<Box component="span" sx={{ opacity: 0.6, fontWeight: 400 }}>L </Box>{Math.round(props.low)}°
</Typography>
)}
</Box>
)}
{props.forecast && props.forecast.length > 0 && (
<Box
sx={{
display: 'flex',
mt: 2.5,
borderRadius: '12px',
background: 'rgba(255,255,255,0.14)',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
px: 1,
py: 1.25,
}}
>
{props.forecast.slice(0, 5).map((d, i) => (
<Box key={`${d.day}-${i}`} sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', opacity: 0.85 }}>
{d.day}
</Typography>
{conditionIcon(d.condition, 16)}
<Typography sx={{ fontSize: '0.82rem', fontWeight: 700 }}>{Math.round(d.high)}°</Typography>
{d.low != null && (
<Typography sx={{ fontSize: '0.72rem', opacity: 0.6 }}>{Math.round(d.low)}°</Typography>
)}
</Box>
))}
</Box>
)}
</Box>
);
}
export default WeatherWidget;
@@ -0,0 +1,151 @@
import type { ToolPair } from '../tool-bubbles/ToolCallBubble';
export interface WeatherForecastDay {
day: string;
condition?: string;
high: number;
low?: number;
}
export interface WeatherProps {
location: string;
temp: number;
unit?: 'F' | 'C';
high?: number;
low?: number;
condition?: string;
forecast?: WeatherForecastDay[];
}
export interface PlanStep {
label: string;
status: 'pending' | 'in_progress' | 'completed';
}
export interface PlanProps {
title?: string;
steps: PlanStep[];
}
export interface StatItem {
label: string;
value: string;
delta?: string;
direction?: 'up' | 'down';
}
export interface StatsProps {
title?: string;
stats: StatItem[];
}
export interface LinkItem {
title: string;
url: string;
description?: string;
}
export interface LinksProps {
links: LinkItem[];
}
export type ShowUiPayload =
| { component: 'weather'; props: WeatherProps }
| { component: 'plan'; props: PlanProps }
| { component: 'stats'; props: StatsProps }
| { component: 'links'; props: LinksProps };
function num(v: unknown): v is number {
return typeof v === 'number' && Number.isFinite(v);
}
function str(v: unknown): v is string {
return typeof v === 'string' && v.length > 0;
}
export function isShowUiPair(pair: ToolPair): boolean {
const tool = typeof pair.call.content === 'object' ? String(pair.call.content?.tool || '') : '';
return /(^|__)ShowUI$/.test(tool);
}
/** Strict parse of a ShowUI tool_call's input; null on any mismatch so the caller falls back to the plain bubble. */
export function parseShowUiPayload(pair: ToolPair): ShowUiPayload | null {
const content = typeof pair.call.content === 'object' ? pair.call.content : null;
const input = content?.input;
if (!input || typeof input !== 'object') return null;
const component = String((input as { component?: unknown }).component || '');
const props = (input as { props?: unknown }).props;
if (!props || typeof props !== 'object') return null;
const p = props as Record<string, unknown>;
if (component === 'weather') {
if (!str(p.location) || !num(p.temp)) return null;
const forecast = Array.isArray(p.forecast)
? (p.forecast as Array<Record<string, unknown>>)
.filter((d) => str(d.day) && num(d.high))
.slice(0, 7)
.map((d) => ({
day: d.day as string,
condition: str(d.condition) ? d.condition : undefined,
high: d.high as number,
low: num(d.low) ? d.low : undefined,
}))
: undefined;
return {
component: 'weather',
props: {
location: p.location,
temp: p.temp,
unit: p.unit === 'C' ? 'C' : 'F',
high: num(p.high) ? p.high : undefined,
low: num(p.low) ? p.low : undefined,
condition: str(p.condition) ? p.condition : undefined,
forecast,
},
};
}
if (component === 'plan') {
if (!Array.isArray(p.steps)) return null;
const steps = (p.steps as Array<Record<string, unknown>>)
.filter((s) => str(s.label))
.slice(0, 20)
.map((s) => ({
label: s.label as string,
status: (s.status === 'completed' || s.status === 'in_progress' ? s.status : 'pending') as PlanStep['status'],
}));
if (steps.length === 0) return null;
return { component: 'plan', props: { title: str(p.title) ? p.title : undefined, steps } };
}
if (component === 'stats') {
if (!Array.isArray(p.stats)) return null;
const stats = (p.stats as Array<Record<string, unknown>>)
.filter((s) => str(s.label) && str(s.value))
.slice(0, 8)
.map((s) => ({
label: s.label as string,
value: s.value as string,
delta: str(s.delta) ? s.delta : undefined,
direction: (s.direction === 'up' || s.direction === 'down' ? s.direction : undefined) as StatItem['direction'],
}));
if (stats.length === 0) return null;
return { component: 'stats', props: { title: str(p.title) ? p.title : undefined, stats } };
}
if (component === 'links') {
if (!Array.isArray(p.links)) return null;
const links = (p.links as Array<Record<string, unknown>>)
.filter((l) => str(l.title) && str(l.url) && /^https?:\/\//i.test(l.url as string))
.slice(0, 10)
.map((l) => ({
title: l.title as string,
url: l.url as string,
description: str(l.description) ? l.description : undefined,
}));
if (links.length === 0) return null;
return { component: 'links', props: { links } };
}
return null;
}