519423cb0d
- landing/app/mira/wiki: tag=mira-wiki list now shows all wiki paragraphs (depends on KBDB tag filter exposed in matrix/kbdb commit, separate repo) - landing: app/mira hub + feed split + various WIP from prior sessions - registry/components: claude_api / kbdb_create_block / kbdb_get / km_writer / platform_crypto / auth_oauth2 contracts + main.go (accumulated) - .component-builds: pkg-lock updates + index.ts adjustments (WIP) - .agents/specs/arcrun/frontend-redesign: design notes - docs/test_credentials, docs/user_requirements/arcrun-landing-page: WIP docs - cypher-executor: auth-dispatcher / wasi-shim adjustments (WIP) Includes accumulated work from prior sessions plus the wiki UI tag-filter update that surfaces the AI-generated wiki paragraphs at /mira/wiki. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
256 lines
12 KiB
React
256 lines
12 KiB
React
const WorkflowViewer = ({ onNav }) => {
|
|
const nodes = [
|
|
{ id: 'trigger', x: 60, y: 260, title: 'Weekly Schedule', type: 'trigger', badge: 'CRON', icon: 'clock', tone: '#22C55E',
|
|
inputs: [], outputs: [{k: 'timestamp', t: 'ISO8601'}, {k: 'runId', t: 'string'}] },
|
|
{ id: 'fetch', x: 320, y: 140, title: 'Fetch Accounts', type: 'database.query', badge: 'DB', icon: 'database', tone: '#3B82F6',
|
|
inputs: [{k: 'segment', t: 'string'}], outputs: [{k: 'accounts', t: 'Account[]'}, {k: 'count', t: 'number'}] },
|
|
{ id: 'events', x: 320, y: 380, title: 'Pull Events', type: 'segment.events', badge: 'API', icon: 'bolt', tone: '#F59E0B',
|
|
inputs: [{k: 'since', t: 'ISO8601'}], outputs: [{k: 'events', t: 'Event[]'}] },
|
|
{ id: 'summarize', x: 600, y: 260, title: 'Summarize with Claude', type: 'ai.completion', badge: 'AI', icon: 'spark', tone: '#8B5CF6',
|
|
inputs: [{k: 'accounts', t: 'Account[]'}, {k: 'events', t: 'Event[]'}, {k: 'prompt', t: 'string'}],
|
|
outputs: [{k: 'digest', t: 'Digest'}, {k: 'tokens', t: 'number'}] },
|
|
{ id: 'filter', x: 880, y: 160, title: 'Filter — priority ≥ 2', type: 'logic.filter', badge: 'IF', icon: 'filter', tone: '#64748B',
|
|
inputs: [{k: 'digest', t: 'Digest'}], outputs: [{k: 'items', t: 'Item[]'}] },
|
|
{ id: 'slack', x: 1140, y: 100, title: 'Post to #revenue', type: 'slack.message', badge: 'OUT', icon: 'slack', tone: '#EC4899',
|
|
inputs: [{k: 'channel', t: 'string'}, {k: 'blocks', t: 'Block[]'}], outputs: [{k: 'ts', t: 'string'}] },
|
|
{ id: 'mail', x: 1140, y: 260, title: 'Email Digest', type: 'mail.send', badge: 'OUT', icon: 'mail', tone: '#6366F1',
|
|
inputs: [{k: 'to', t: 'string[]'}, {k: 'subject', t: 'string'}, {k: 'html', t: 'string'}], outputs: [{k: 'messageId', t: 'string'}] },
|
|
{ id: 'log', x: 880, y: 400, title: 'Log run metadata', type: 'arcrun.log', badge: 'LOG', icon: 'terminal', tone: '#475569',
|
|
inputs: [{k: 'runId', t: 'string'}, {k: 'stats', t: 'Stats'}], outputs: [] },
|
|
];
|
|
|
|
const edges = [
|
|
['trigger', 'fetch'],
|
|
['trigger', 'events'],
|
|
['fetch', 'summarize'],
|
|
['events', 'summarize'],
|
|
['summarize', 'filter'],
|
|
['summarize', 'log'],
|
|
['filter', 'slack'],
|
|
['filter', 'mail'],
|
|
];
|
|
|
|
const [selectedId, setSelectedId] = React.useState('summarize');
|
|
const [title, setTitle] = React.useState('digest/weekly');
|
|
const [zoom, setZoom] = React.useState(100);
|
|
|
|
const selected = nodes.find(n => n.id === selectedId);
|
|
|
|
// Edit triplet inline (for the summarize node's prompt config)
|
|
const [triplet, setTriplet] = React.useState({
|
|
model: 'claude-haiku-4-5',
|
|
temperature: '0.3',
|
|
prompt: 'Summarize this week\'s account activity for the revenue team.',
|
|
});
|
|
|
|
// Measure node widths for edge endpoint accuracy
|
|
const nodeRefs = React.useRef({});
|
|
const [sizes, setSizes] = React.useState({});
|
|
React.useEffect(() => {
|
|
const ns = {};
|
|
for (const n of nodes) {
|
|
const el = nodeRefs.current[n.id];
|
|
if (el) ns[n.id] = { w: el.offsetWidth, h: el.offsetHeight };
|
|
}
|
|
setSizes(ns);
|
|
}, []);
|
|
|
|
const getPort = (id, side) => {
|
|
const n = nodes.find(x => x.id === id);
|
|
const sz = sizes[id] || { w: 200, h: 60 };
|
|
return {
|
|
x: side === 'out' ? n.x + sz.w : n.x,
|
|
y: n.y + sz.h / 2,
|
|
};
|
|
};
|
|
|
|
return (
|
|
<div className="wf-viewer">
|
|
<div className="wf-topbar">
|
|
<div className="back" onClick={() => onNav('dashboard')} title="Back to dashboard">
|
|
<Icon name="arrow_left" size={16} />
|
|
</div>
|
|
<Logo size="sm" onClick={() => onNav('landing')} />
|
|
<div className="sep" />
|
|
<div className="wf-breadcrumb">
|
|
<span className="cr" onClick={() => onNav('dashboard')}>Workflows</span>
|
|
<Icon name="chevron_right" size={11} />
|
|
<input
|
|
className="wf-title mono"
|
|
value={title}
|
|
onChange={e => setTitle(e.target.value)}
|
|
/>
|
|
</div>
|
|
<span className="wf-saved">
|
|
<span style={{width: 6, height: 6, borderRadius: '50%', background: '#22C55E', boxShadow: '0 0 0 3px rgba(34,197,94,0.18)'}} />
|
|
Saved · 2m ago
|
|
</span>
|
|
<div className="spacer" />
|
|
<button className="btn btn-ghost btn-sm"><Icon name="share" size={13} /> Share</button>
|
|
<button className="btn btn-secondary btn-sm"><Icon name="download" size={13} /> Export YAML</button>
|
|
<button className="wf-edit-in-claude">
|
|
<Icon name="spark" size={13} /> Edit in Claude <Icon name="external" size={12} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="wf-canvas">
|
|
<svg className="wf-edges" width="100%" height="100%">
|
|
<defs>
|
|
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
|
<path d="M0,0 L10,5 L0,10 z" fill="#6366F1" />
|
|
</marker>
|
|
<marker id="arrow-dim" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
|
<path d="M0,0 L10,5 L0,10 z" fill="#3a3a3a" />
|
|
</marker>
|
|
</defs>
|
|
{edges.map(([a, b], i) => {
|
|
const p1 = getPort(a, 'out');
|
|
const p2 = getPort(b, 'in');
|
|
const dx = Math.max(40, (p2.x - p1.x) * 0.5);
|
|
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x - 2} ${p2.y}`;
|
|
const highlight = a === selectedId || b === selectedId;
|
|
return (
|
|
<path key={i} d={d}
|
|
stroke={highlight ? '#6366F1' : '#3a3a3a'}
|
|
strokeWidth={highlight ? 2 : 1.5}
|
|
fill="none"
|
|
markerEnd={`url(#${highlight ? 'arrow' : 'arrow-dim'})`}
|
|
opacity={highlight ? 0.95 : 0.6} />
|
|
);
|
|
})}
|
|
</svg>
|
|
|
|
<div className="wf-nodes">
|
|
{nodes.map(n => (
|
|
<div key={n.id}
|
|
ref={el => (nodeRefs.current[n.id] = el)}
|
|
className={`wf-node ${selectedId === n.id ? 'selected' : ''}`}
|
|
style={{left: n.x, top: n.y}}
|
|
onClick={() => setSelectedId(n.id)}>
|
|
{n.inputs.length > 0 && <span className="port in" />}
|
|
{n.outputs.length > 0 && <span className="port out" />}
|
|
<div className="node-row-top">
|
|
<span className="node-icon" style={{background: n.tone}}>
|
|
<Icon name={n.icon} size={12} />
|
|
</span>
|
|
<span className="node-title">{n.title}</span>
|
|
<span className="node-badge">{n.badge}</span>
|
|
</div>
|
|
<div className="node-sub">{n.type}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Detail panel */}
|
|
{selected && (
|
|
<div className="wf-detail">
|
|
<div className="dt-head">
|
|
<span className="dt-icon" style={{background: selected.tone}}>
|
|
<Icon name={selected.icon} size={15} />
|
|
</span>
|
|
<div className="dt-meta">
|
|
<h3>{selected.title}</h3>
|
|
<div className="dt-type">{selected.type}</div>
|
|
</div>
|
|
<button className="close-btn" onClick={() => setSelectedId(null)}>
|
|
<Icon name="close" size={14} />
|
|
</button>
|
|
</div>
|
|
<div className="dt-body">
|
|
<div className="dt-section">
|
|
<h4>Input schema</h4>
|
|
{selected.inputs.length === 0 ? (
|
|
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No inputs — this is a trigger.</div>
|
|
) : selected.inputs.map(f => (
|
|
<div key={f.k} className="schema-field">
|
|
<span className="k">{f.k}</span>
|
|
<span className="t">{f.t}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="dt-section">
|
|
<h4>Output schema</h4>
|
|
{selected.outputs.length === 0 ? (
|
|
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No outputs — terminal node.</div>
|
|
) : selected.outputs.map(f => (
|
|
<div key={f.k} className="schema-field">
|
|
<span className="k">{f.k}</span>
|
|
<span className="t">{f.t}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{selected.id === 'summarize' && (
|
|
<div className="dt-section">
|
|
<h4>Configuration</h4>
|
|
<div className="triplet">
|
|
<div className="trow">
|
|
<div className="tkey">model</div>
|
|
<input className="tval" value={triplet.model} onChange={e => setTriplet({...triplet, model: e.target.value})} />
|
|
</div>
|
|
<div className="trow">
|
|
<div className="tkey">temp</div>
|
|
<input className="tval" value={triplet.temperature} onChange={e => setTriplet({...triplet, temperature: e.target.value})} />
|
|
</div>
|
|
<div className="trow">
|
|
<div className="tkey">prompt</div>
|
|
<input className="tval" value={triplet.prompt} onChange={e => setTriplet({...triplet, prompt: e.target.value})} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="dt-section">
|
|
<h4>Last run</h4>
|
|
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12}}>
|
|
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
|
|
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Duration</div>
|
|
<div style={{fontFamily: 'JetBrains Mono, monospace', marginTop: 3}}>2.4s</div>
|
|
</div>
|
|
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
|
|
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Status</div>
|
|
<div style={{marginTop: 2}}><span className="pill active"><span className="pdot" />success</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button className="btn btn-primary" style={{width: '100%', marginTop: 4}}>
|
|
<Icon name="spark" size={13} /> Edit this node in Claude
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Minimap */}
|
|
<div className="wf-minimap">
|
|
<div className="mini-label">Overview</div>
|
|
{nodes.map(n => {
|
|
const sz = sizes[n.id] || {w: 180, h: 60};
|
|
return (
|
|
<div key={n.id} className="mini-box" style={{
|
|
left: 8 + (n.x / 1400) * 164,
|
|
top: 18 + (n.y / 500) * 80,
|
|
width: Math.max(6, (sz.w / 1400) * 164),
|
|
height: Math.max(4, (sz.h / 500) * 80),
|
|
opacity: selectedId === n.id ? 1 : 0.5,
|
|
background: selectedId === n.id ? 'var(--primary)' : 'var(--primary-soft)',
|
|
}} />
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Zoom controls */}
|
|
<div className="wf-controls">
|
|
<button onClick={() => setZoom(Math.max(40, zoom - 10))}><Icon name="zoom_out" size={13} /></button>
|
|
<div className="zoom-val">{zoom}%</div>
|
|
<button onClick={() => setZoom(Math.min(200, zoom + 10))}><Icon name="zoom_in" size={13} /></button>
|
|
<button><Icon name="maximize" size={13} /></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
window.WorkflowViewer = WorkflowViewer;
|