mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] ui-tools: live in-place card updates (same component+id); social posts wrapped as {post} (flat props crashed the shell); per-component error wall; item-carousel validates the carousel shape
This commit is contained in:
@@ -101,6 +101,8 @@ def compose_turn_system_prompt(
|
||||
"Strongly prefer rendering rich UI over prose, every time the content fits:\n"
|
||||
"- ShowUI for any structured result: tables, stats, links, plans, progress, code, diffs, "
|
||||
"charts, maps, media, posts, receipts. Render the component, then add one line of text.\n"
|
||||
"- For multi-step work, render a progress-tracker FIRST and re-call ShowUI with the SAME "
|
||||
"props.id after each step so the card advances live; same-id re-calls update in place.\n"
|
||||
"- AskUI for ANY question with enumerable choices, an approval, or tunable values: render "
|
||||
"it and wait for the answer instead of asking in prose. Flat choices = option-list; a "
|
||||
"multi-question form = one AskUI call per question in sequence. The user can always "
|
||||
|
||||
@@ -34,7 +34,7 @@ def p_load_generated():
|
||||
GENERATED = p_load_generated()
|
||||
|
||||
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)}",
|
||||
"weather": "props: {id?: str, location: str, temp: number, unit?: 'F'|'C', high?: number, low?: number, condition?: str, forecast?: [{day: str, condition?: str, high?: number, low?: number}] (max 7)}",
|
||||
"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)}",
|
||||
}
|
||||
@@ -82,7 +82,10 @@ TOOLS = [
|
||||
"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."
|
||||
"The component renders in place of raw text; still give a one-line text summary after. "
|
||||
"LIVE UPDATES: calling ShowUI again with the SAME component and props.id updates that "
|
||||
"card in place. Use this to advance progress-tracker/plan step statuses AS you complete "
|
||||
"each step of real work, or to refresh data; never mint a new id for an update."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
|
||||
@@ -1806,7 +1806,7 @@
|
||||
}
|
||||
},
|
||||
"item-carousel": {
|
||||
"hint": "props: {id: str, name: str, subtitle?: str, image?: str, color?: str, actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'destructive'|'secondary'|'ghost'|'outline', loading?: bool, disabled?: bool, shortcut?: str}]}",
|
||||
"hint": "props: {id: str, items: [{id: str, name: str, subtitle?: str, image?: str, color?: str, actions?: [obj]}], title?: str, description?: str}",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
@@ -1815,21 +1815,13 @@
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"subtitle": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"image": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"color": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"actions": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
@@ -1838,46 +1830,76 @@
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"label": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sentence": {
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"confirmLabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"default",
|
||||
"destructive",
|
||||
"secondary",
|
||||
"ghost",
|
||||
"outline"
|
||||
]
|
||||
"format": "uri"
|
||||
},
|
||||
"loading": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"shortcut": {
|
||||
"color": {
|
||||
"type": "string"
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sentence": {
|
||||
"type": "string"
|
||||
},
|
||||
"confirmLabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"default",
|
||||
"destructive",
|
||||
"secondary",
|
||||
"ghost",
|
||||
"outline"
|
||||
]
|
||||
},
|
||||
"loading": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"shortcut": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"label"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"label"
|
||||
"name"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
"items"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ const TARGETS: Record<string, [string, string]> = {
|
||||
'image': ['../src/toolui/components/image/schema', 'SerializableImageSchema'],
|
||||
'image-gallery': ['../src/toolui/components/image-gallery/schema', 'SerializableImageGallerySchema'],
|
||||
'instagram-post': ['../src/toolui/components/instagram-post/schema', 'SerializableInstagramPostSchema'],
|
||||
'item-carousel': ['../src/toolui/components/item-carousel/schema', 'SerializableItemSchema'],
|
||||
'item-carousel': ['../src/toolui/components/item-carousel/schema', 'SerializableItemCarouselSchema'],
|
||||
'link-preview': ['../src/toolui/components/link-preview/schema', 'SerializableLinkPreviewSchema'],
|
||||
'linkedin-post': ['../src/toolui/components/linkedin-post/schema', 'SerializableLinkedInPostSchema'],
|
||||
'message-draft': ['../src/toolui/components/message-draft/schema', 'SerializableEmailDraftSchema'],
|
||||
|
||||
@@ -1058,6 +1058,25 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const renderItems: RenderItem[] = useMemo(() => {
|
||||
const items: RenderItem[] = [];
|
||||
let i = 0;
|
||||
// Live-updating cards: repeated ShowUI calls with the SAME component+props.id are one card that
|
||||
// UPDATES IN PLACE at its first position (progress advances, data refreshes), never a stack of
|
||||
// stale snapshots. Pre-scan maps each id key to its first slot and its latest call+result.
|
||||
const firstCallIdByKey = new Map<string, string>();
|
||||
const latestByKey = new Map<string, { call: (typeof activeBranchMessages)[number]; result: (typeof activeBranchMessages)[number] | null }>();
|
||||
const keyByCallId = new Map<string, string>();
|
||||
for (let s = 0; s < activeBranchMessages.length; s++) {
|
||||
const m = activeBranchMessages[s];
|
||||
const mc = m.content;
|
||||
if (m.role !== 'tool_call' || typeof mc !== 'object' || !/(^|__)ShowUI$/.test(String(mc?.tool || ''))) continue;
|
||||
const input = mc?.input as { component?: unknown; props?: { id?: unknown } } | undefined;
|
||||
const compId = input?.props?.id;
|
||||
if (!input?.component || typeof compId !== 'string' || !compId) continue;
|
||||
const key = `${input.component}:${compId}`;
|
||||
keyByCallId.set(m.id, key);
|
||||
if (!firstCallIdByKey.has(key)) firstCallIdByKey.set(key, m.id);
|
||||
const next = activeBranchMessages[s + 1];
|
||||
latestByKey.set(key, { call: m, result: next && next.role === 'tool_result' ? next : null });
|
||||
}
|
||||
// Narration that led INTO a tool phase; folds into that phase's group on a finished session.
|
||||
let leadNotes: typeof activeBranchMessages = [];
|
||||
while (i < activeBranchMessages.length) {
|
||||
@@ -1177,7 +1196,23 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
// Phase held only ShowUI/AskUI pairs: narration has no group to fold into, keep it visible.
|
||||
for (const nm of noteMarks) items.push(nm.msg);
|
||||
}
|
||||
items.push(...showUiPairs);
|
||||
for (const p of showUiPairs) {
|
||||
const key = keyByCallId.get(p.call.id);
|
||||
if (!key || isAskUiPair(p)) {
|
||||
items.push(p);
|
||||
continue;
|
||||
}
|
||||
// Later updates render nowhere themselves; the first slot always shows the latest call
|
||||
// under a STABLE key so React updates the mounted component instead of remounting it.
|
||||
if (p.call.id !== firstCallIdByKey.get(key)) continue;
|
||||
const latest = latestByKey.get(key);
|
||||
items.push({
|
||||
type: 'tool_pair' as const,
|
||||
id: `showui-${key}`,
|
||||
call: latest ? latest.call : p.call,
|
||||
result: latest ? latest.result : p.result,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!sessionRunning && msg.role === 'assistant') {
|
||||
let j = i;
|
||||
|
||||
@@ -2,6 +2,33 @@ import React, { Suspense, useEffect, useState } from 'react';
|
||||
import { useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
import { TOOL_UI_REGISTRY } from './registry';
|
||||
|
||||
interface GuardProps { name: string; children: React.ReactNode }
|
||||
|
||||
// A component render throwing must cost exactly one quiet line, never the app: the top-level
|
||||
// ErrorBoundary unmounts the whole shell for any uncaught child throw (the linkedin-post {post}
|
||||
// mismatch took down the dashboard until this wall existed).
|
||||
class ComponentGuard extends React.Component<GuardProps, { failed: boolean }> {
|
||||
constructor(props: GuardProps) {
|
||||
super(props);
|
||||
this.state = { failed: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): { failed: boolean } {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.failed) {
|
||||
return (
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.55, padding: '4px 0' }}>
|
||||
{this.props.name} failed to render
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface VendoredToolUiProps {
|
||||
name: string;
|
||||
props: Record<string, unknown>;
|
||||
@@ -65,9 +92,11 @@ function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React
|
||||
const Component = entry.Component;
|
||||
return (
|
||||
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
|
||||
<Suspense fallback={<div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />}>
|
||||
<Component {...gate.parsed} {...(extraProps || {})} />
|
||||
</Suspense>
|
||||
<ComponentGuard name={name}>
|
||||
<Suspense fallback={<div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />}>
|
||||
<Component {...gate.parsed} {...(extraProps || {})} />
|
||||
</Suspense>
|
||||
</ComponentGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ export interface ToolUiEntry {
|
||||
|
||||
/* Every entry lazy-loads both the component and its zod contract so the chat bundle only pays
|
||||
for components a transcript actually uses. Names mirror upstream tool-ui component slugs. */
|
||||
// The social-post components take their data nested as {post}; the wire props ARE the post.
|
||||
function wrapAsPost<P extends { id?: unknown }>(Inner: React.ComponentType<{ post: P }>): React.ComponentType<P> {
|
||||
return function PostAdapter(props: P) {
|
||||
return <Inner post={props} />;
|
||||
};
|
||||
}
|
||||
|
||||
export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
|
||||
'audio': {
|
||||
Component: lazy(() => import('./components/audio').then((m) => ({ default: m.Audio }))),
|
||||
@@ -52,19 +59,19 @@ export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
|
||||
loadSchema: () => import('./components/image-gallery/schema').then((m) => m.SerializableImageGallerySchema),
|
||||
},
|
||||
'instagram-post': {
|
||||
Component: lazy(() => import('./components/instagram-post').then((m) => ({ default: m.InstagramPost }))),
|
||||
Component: lazy(() => import('./components/instagram-post').then((m) => ({ default: wrapAsPost(m.InstagramPost) }))),
|
||||
loadSchema: () => import('./components/instagram-post/schema').then((m) => m.SerializableInstagramPostSchema),
|
||||
},
|
||||
'item-carousel': {
|
||||
Component: lazy(() => import('./components/item-carousel').then((m) => ({ default: m.ItemCarousel }))),
|
||||
loadSchema: () => import('./components/item-carousel/schema').then((m) => m.SerializableItemSchema),
|
||||
loadSchema: () => import('./components/item-carousel/schema').then((m) => m.SerializableItemCarouselSchema),
|
||||
},
|
||||
'link-preview': {
|
||||
Component: lazy(() => import('./components/link-preview').then((m) => ({ default: m.LinkPreview }))),
|
||||
loadSchema: () => import('./components/link-preview/schema').then((m) => m.SerializableLinkPreviewSchema),
|
||||
},
|
||||
'linkedin-post': {
|
||||
Component: lazy(() => import('./components/linkedin-post').then((m) => ({ default: m.LinkedInPost }))),
|
||||
Component: lazy(() => import('./components/linkedin-post').then((m) => ({ default: wrapAsPost(m.LinkedInPost) }))),
|
||||
loadSchema: () => import('./components/linkedin-post/schema').then((m) => m.SerializableLinkedInPostSchema),
|
||||
},
|
||||
'message-draft': {
|
||||
@@ -112,7 +119,7 @@ export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
|
||||
loadSchema: () => import('./components/video/schema').then((m) => m.SerializableVideoSchema),
|
||||
},
|
||||
'x-post': {
|
||||
Component: lazy(() => import('./components/x-post').then((m) => ({ default: m.XPost }))),
|
||||
Component: lazy(() => import('./components/x-post').then((m) => ({ default: wrapAsPost(m.XPost) }))),
|
||||
loadSchema: () => import('./components/x-post/schema').then((m) => m.SerializableXPostSchema),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user