diff --git a/apps/ai-studio/.env.example b/apps/ai-studio/.env.example new file mode 100644 index 000000000..751b07155 --- /dev/null +++ b/apps/ai-studio/.env.example @@ -0,0 +1,6 @@ +# Base URL of the reference backend the demo talks to. +VITE_BACKEND_URL=http://127.0.0.1:3001 +# Cloudflare Turnstile site key (public). Leave empty to run the demo without +# bot protection (local dev). When set, the Run button attaches a Turnstile +# token that the backend verifies before executing a workflow. +VITE_TURNSTILE_SITE_KEY= diff --git a/apps/ai-studio/package.json b/apps/ai-studio/package.json index 9f3e7b85c..b48b18b99 100644 --- a/apps/ai-studio/package.json +++ b/apps/ai-studio/package.json @@ -20,9 +20,14 @@ "@workflow-builder/types": "workspace:*", "@xyflow/react": "catalog:", "clsx": "^2.1.1", + "html-to-image": "1.11.11", "immer": "^10.1.1", + "mermaid": "^11.15.0", "react": "^19.1.0", "react-dom": "catalog:", + "react-markdown": "^10.1.0", + "recharts": "^3.9.0", + "remark-gfm": "^4.0.1", "zustand": "^5.0.1" }, "devDependencies": { diff --git a/apps/ai-studio/src/app/app.tsx b/apps/ai-studio/src/app/app.tsx index 8d96eb295..8d6761a34 100644 --- a/apps/ai-studio/src/app/app.tsx +++ b/apps/ai-studio/src/app/app.tsx @@ -1,28 +1,42 @@ import { WorkflowBuilder } from '@workflowbuilder/sdk'; +import './brand-override.css'; import '@workflowbuilder/sdk/style.css'; +import { BrandLogo } from '../components/brand/brand-logo'; import { AiStudioControls } from '../components/controls/ai-studio-controls'; +import { DisclaimerModal } from '../components/disclaimer/disclaimer-modal'; import { ExecutionHighlighting } from '../components/execution/highlighting'; import { ExecutionLogPanel } from '../components/execution/log-panel'; import { ExecutionNodeDetail } from '../components/execution/node-detail'; import { aiStudioTemplates } from '../data/ai-studio-templates'; import { aiStudioNodeTypes } from '../data/node-types'; +import { supportTriageFlow } from '../data/support-triage-flow'; import { plugin as aiStudioFeaturesPlugin } from '../plugin'; +// Auto-load a relatable, runnable workflow on first visit instead of greeting +// the visitor with a blank canvas. Passing non-empty initial nodes/edges makes +// the SDK skip the welcome picker; a returning visitor's saved diagram still wins. +const flagship = supportTriageFlow.value; + export function App() { return ( + + ); } diff --git a/apps/ai-studio/src/app/brand-override.css b/apps/ai-studio/src/app/brand-override.css new file mode 100644 index 000000000..f366a21e4 --- /dev/null +++ b/apps/ai-studio/src/app/brand-override.css @@ -0,0 +1,14 @@ +/* TEMPORARY override. The SDK app bar (`Toolbar`) renders the Workflow Builder + logo as an SVG with CSS-module class `logo`, followed by a `nav-segment` + holding the Save button, in a flex row (gap 2rem). The SDK exposes no prop to + replace the logo or make it a link, so we hide its pixels here and render our + own linked CDN logo (see BrandLogo). We keep the logo's box in the flex flow + (`visibility`, NOT `display: none`) and reserve a width matching the overlay, + so the Save button keeps its place instead of sliding left underneath the + overlay. The prefix selectors survive the per-build module-hash suffix (e.g. + `_logo_2tbzr_18`). Remove this and the BrandLogo overlay once the SDK exposes + a logo / logoHref prop on . */ +[class^='_toolbar_'] [class^='_logo_'] { + visibility: hidden; + width: 6rem; /* >= the BrandLogo overlay width (~5.8rem) so the Save button clears it */ +} diff --git a/apps/ai-studio/src/components/brand/brand-logo.module.css b/apps/ai-studio/src/components/brand/brand-logo.module.css new file mode 100644 index 000000000..3b45c28f9 --- /dev/null +++ b/apps/ai-studio/src/components/brand/brand-logo.module.css @@ -0,0 +1,35 @@ +/* Overlays the app bar's logo slot (the SDK logo sits at ~33px / 37px, 55x22). + Fixed so it stays put over the fixed app bar. */ +.logo { + position: fixed; + top: 2.45rem; + left: 2.05rem; + z-index: 100; + display: inline-flex; + align-items: center; + height: 1.15rem; +} + +.image { + height: 100%; + width: auto; +} + +/* Theme-swapped logo: the SDK sets `data-theme` on . Show the dark-text + logo by default (light theme) and the white-text logo when the dark theme is + active, so neither variant carries a baked-in background. */ +.light { + display: block; +} + +.dark { + display: none; +} + +:global([data-theme='dark']) .light { + display: none; +} + +:global([data-theme='dark']) .dark { + display: block; +} diff --git a/apps/ai-studio/src/components/brand/brand-logo.tsx b/apps/ai-studio/src/components/brand/brand-logo.tsx new file mode 100644 index 000000000..fadd36f45 --- /dev/null +++ b/apps/ai-studio/src/components/brand/brand-logo.tsx @@ -0,0 +1,27 @@ +import styles from './brand-logo.module.css'; + +// The CDN's `-solid` asset bakes in a white background (a white box in dark +// mode), so we use the transparent dark-text / white-text variants of the same +// logo and swap them by the active SDK theme (see brand-logo.module.css). +const LOGO_LIGHT = 'https://cdn.synergycodes.com/workflow-builder-logo.svg'; +const LOGO_DARK = 'https://cdn.synergycodes.com/workflow-builder-logo-white.svg'; +const LOGO_HREF = 'https://workflowbuilder.io'; + +// Temporary consumer-side replacement for the SDK app-bar logo (the SDK one is +// hidden via brand-override.css). Renders the CDN logo over the app bar's logo +// slot and links it to workflowbuilder.io. Replace with an SDK logo/logoHref +// prop on when one is available. +export function BrandLogo() { + return ( + + Workflow Builder + + + ); +} diff --git a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css new file mode 100644 index 000000000..1e041f2fb --- /dev/null +++ b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.module.css @@ -0,0 +1,111 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: rgba(8, 10, 14, 0.55); + backdrop-filter: blur(2px); +} + +.card { + position: relative; + width: 100%; + max-width: 460px; + padding: 2rem; + font-family: var(--wb-font-family); + background: var(--ax-ui-bg-primary-default, #ffffff); + border: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3); + border-radius: var(--wb-app-bar-border-radius, 0.75rem); + box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.25); + color: var(--ax-txt-primary-default, #151516); +} + +.close { + position: absolute; + top: 0.9rem; + right: 0.9rem; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border: none; + border-radius: 0.5rem; + background: transparent; + color: var(--ax-txt-tertiary-default, #6f7480); + cursor: pointer; +} + +.close:hover { + background: var(--ax-ui-bg-secondary-default, #f5f5f7); + color: var(--ax-txt-primary-default, #151516); +} + +.heading { + display: flex; + align-items: center; + gap: 0.7rem; + margin-bottom: 0.9rem; +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + flex-shrink: 0; + border-radius: 0.6rem; + background: var(--ax-ui-bg-secondary-default, #f5f5f7); + color: var(--ax-colors-acc1-500, #1096e7); +} + +.title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--ax-txt-primary-default, #151516); +} + +.body { + margin: 0 0 1.5rem; + font-size: 0.92rem; + line-height: 1.6; + color: var(--ax-txt-secondary-default, #4d5059); +} + +.body p { + margin: 0 0 0.7rem; +} + +.body p:last-child { + margin-bottom: 0; +} + +.body strong { + color: var(--ax-txt-primary-default, #151516); + font-weight: 600; +} + +.cta { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0.7rem 1rem; + border: none; + border-radius: 0.625rem; + background: var(--ax-colors-acc1-500, #1096e7); + color: #ffffff; + font-size: 0.95rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; +} + +.cta:hover { + background: var(--ax-colors-acc1-600, #0477c5); +} diff --git a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx new file mode 100644 index 000000000..3c4854a89 --- /dev/null +++ b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx @@ -0,0 +1,75 @@ +import { Info, X } from '@phosphor-icons/react'; +import { useState } from 'react'; + +import styles from './disclaimer-modal.module.css'; + +const STORAGE_KEY = 'ai-studio:disclaimer-acknowledged'; + +function hasAcknowledged(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +export function DisclaimerModal() { + const [open, setOpen] = useState(() => !hasAcknowledged()); + + if (!open) { + return null; + } + + function dismiss() { + try { + localStorage.setItem(STORAGE_KEY, 'true'); + } catch { + // A locked-down or private session is fine — just close for this visit. + } + setOpen(false); + } + + return ( +
+
event.stopPropagation()} + > + + +
+
+ +
+

+ Welcome to AI Studio +

+
+ +
+

+ This is a live demo of Workflow Builder — a toolkit for building visual, AI-powered + workflow editors. +

+

+ The workflows here run for real: every AI step calls a live model through OpenRouter. +

+

+ It is not a place to test or benchmark AI models. The model is just the engine — the point + is to show what you can build with Workflow Builder. To keep the demo open to everyone, runs are + rate-limited. +

+
+ + +
+
+ ); +} diff --git a/apps/ai-studio/src/components/execution/log-panel.module.css b/apps/ai-studio/src/components/execution/log-panel.module.css index 54250bf46..23a7cb9d9 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -7,6 +7,7 @@ background: var(--wb-app-bar-background); border: 0.0625rem solid var(--wb-app-bar-border-color); border-radius: var(--wb-app-bar-border-radius); + color: var(--ax-txt-primary-default, #151516); display: flex; flex-direction: column; z-index: 10; diff --git a/apps/ai-studio/src/components/execution/node-detail.module.css b/apps/ai-studio/src/components/execution/node-detail.module.css index 7d869c47c..3ef9fa1f3 100644 --- a/apps/ai-studio/src/components/execution/node-detail.module.css +++ b/apps/ai-studio/src/components/execution/node-detail.module.css @@ -7,6 +7,7 @@ background: var(--wb-app-bar-background); border: 0.0625rem solid var(--wb-app-bar-border-color); border-radius: var(--wb-app-bar-border-radius); + color: var(--ax-txt-primary-default, #151516); display: flex; flex-direction: column; z-index: 11; diff --git a/apps/ai-studio/src/components/visualize/chart-renderer.tsx b/apps/ai-studio/src/components/visualize/chart-renderer.tsx new file mode 100644 index 000000000..65695a882 --- /dev/null +++ b/apps/ai-studio/src/components/visualize/chart-renderer.tsx @@ -0,0 +1,178 @@ +import type { ReactElement } from 'react'; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import styles from './renderers.module.css'; + +import type { RendererProps } from './renderers'; + +const PALETTE = ['#1096e7', '#45b7a8', '#e0a458', '#a78bfa', '#ef6f9b', '#6fae6f', '#0477c5']; +const AXIS = '#9aa1ab'; +const GRID = '#edeff3'; + +type Row = Record; + +// Accepts either an explicit chart spec ({ type, data: [...] }) or a bare array +// of rows ({label, value} / {x, y} / object rows), and infers the category and +// numeric value columns. +// Parse raw JSON, or a ```json fenced block embedded in a larger response. +function tryParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + // not raw JSON — try a fenced block + } + const fence = /```(?:json)?\s*\n([\s\S]*?)```/.exec(text); + if (fence) { + try { + return JSON.parse(fence[1].trim()); + } catch { + // fenced content is not JSON either + } + } + return undefined; +} + +function toPayload(text: string, data: unknown): { type: string; rows: Row[] } | null { + let value = data; + if (value === undefined) { + value = tryParseJson(text); + if (value === undefined) { + return null; + } + } + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + Array.isArray((value as { data?: unknown }).data) + ) { + const envelope = value as { type?: unknown; data: unknown[] }; + return { type: String(envelope.type ?? 'bar').toLowerCase(), rows: envelope.data as Row[] }; + } + if (Array.isArray(value)) { + return { type: 'bar', rows: value as Row[] }; + } + return null; +} + +export function ChartRenderer({ text, data }: RendererProps) { + const payload = toPayload(text, data); + if (!payload || payload.rows.length === 0 || typeof payload.rows[0] !== 'object' || payload.rows[0] === null) { + return
{text}
; + } + + const { type, rows } = payload; + const keys = Object.keys(rows[0]); + const valueKeys = keys.filter((key) => rows.every((row) => typeof row[key] === 'number')); + const categoryKey = keys.find((key) => !valueKeys.includes(key)) ?? keys[0]; + + if (valueKeys.length === 0) { + return
{text}
; + } + + let chart: ReactElement; + switch (type) { + case 'pie': + case 'donut': { + chart = ( + + + + {rows.map((row, index) => ( + + ))} + + + ); + break; + } + case 'line': { + chart = ( + + + + + + {valueKeys.length > 1 && } + {valueKeys.map((key, index) => ( + + ))} + + ); + break; + } + case 'area': { + chart = ( + + + + + + {valueKeys.length > 1 && } + {valueKeys.map((key, index) => ( + + ))} + + ); + break; + } + default: { + chart = ( + + + + + + {valueKeys.length > 1 && } + {valueKeys.map((key, index) => ( + + ))} + + ); + } + } + + return ( +
+ + {chart} + +
+ ); +} diff --git a/apps/ai-studio/src/components/visualize/diagram-renderer.tsx b/apps/ai-studio/src/components/visualize/diagram-renderer.tsx new file mode 100644 index 000000000..5ed99f45a --- /dev/null +++ b/apps/ai-studio/src/components/visualize/diagram-renderer.tsx @@ -0,0 +1,70 @@ +import mermaid from 'mermaid'; +import { useEffect, useState } from 'react'; + +import styles from './renderers.module.css'; + +import type { RendererProps } from './renderers'; + +mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'neutral' }); + +let counter = 0; + +// Renders a mermaid source string to SVG in the browser. It validates with +// mermaid.parse({ suppressErrors: true }) BEFORE rendering: invalid input (e.g. +// the node is forced to Diagram on non-diagram text) falls back to raw text and +// never calls render, so mermaid does not inject its giant "Syntax error" +// graphic into the document. +export function DiagramRenderer({ text, data }: RendererProps) { + const raw = typeof data === 'string' ? data : text; + // If the diagram is embedded in a larger response, render just the fenced block. + const fence = /```mermaid\s*\n([\s\S]*?)```/.exec(raw); + const source = fence ? fence[1].trim() : raw; + const [svg, setSvg] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + counter += 1; + setSvg(null); + setFailed(false); + + const run = async () => { + try { + const isValid = await mermaid.parse(source, { suppressErrors: true }); + if (!isValid) { + if (!cancelled) { + setFailed(true); + } + return; + } + const { svg: rendered } = await mermaid.render(`viz-mermaid-${counter}`, source); + if (!cancelled) { + setSvg(rendered); + } + } catch { + if (!cancelled) { + setFailed(true); + } + } + }; + void run(); + + return () => { + cancelled = true; + }; + }, [source]); + + if (failed) { + return ( +
+

Not a valid Mermaid diagram — showing the raw text.

+
{text}
+
+ ); + } + if (svg === null) { + return

Rendering diagram…

; + } + // mermaid output, sanitized via securityLevel 'strict' + return
; +} diff --git a/apps/ai-studio/src/components/visualize/renderers.module.css b/apps/ai-studio/src/components/visualize/renderers.module.css new file mode 100644 index 000000000..bbb1b29bc --- /dev/null +++ b/apps/ai-studio/src/components/visualize/renderers.module.css @@ -0,0 +1,243 @@ +/* Shared content styles for the Visualize renderers. The card frame/header/badge + live in visualize-card.module.css; this file styles the rendered output. */ + +.markdown { + font-size: 0.85rem; + line-height: 1.6; + color: var(--ax-txt-primary-default, #151516); + overflow-wrap: anywhere; +} + +.markdown h1, +.markdown h2, +.markdown h3 { + margin: 0.9em 0 0.4em; + font-weight: 600; + line-height: 1.3; +} + +.markdown h1 { + font-size: 1.15rem; +} + +.markdown h2 { + font-size: 1.05rem; +} + +.markdown h3 { + font-size: 0.95rem; +} + +.markdown p { + margin: 0.5em 0; +} + +.markdown ul, +.markdown ol { + margin: 0.5em 0; + padding-left: 1.3em; +} + +.markdown li { + margin: 0.2em 0; +} + +.markdown a { + color: var(--ax-colors-acc1-500, #1096e7); + text-decoration: underline; +} + +.markdown strong { + font-weight: 600; +} + +.markdown code { + font-family: 'IBM Plex Mono', ui-monospace, monospace; + font-size: 0.82em; + background: var(--ax-ui-bg-secondary-default, #f5f5f7); + padding: 0.1em 0.35em; + border-radius: 0.25rem; +} + +.markdown pre { + margin: 0.6em 0; + padding: 0.7em 0.85em; + background: var(--ax-ui-bg-secondary-default, #f5f5f7); + border-radius: 0.5rem; + overflow-x: auto; +} + +.markdown pre code { + background: none; + padding: 0; +} + +.markdown blockquote { + margin: 0.6em 0; + padding-left: 0.85em; + border-left: 0.1875rem solid var(--ax-ui-stroke-primary-default, #edeff3); + color: var(--ax-txt-secondary-default, #4d5059); +} + +/* Inline markdown inside table cells / stat-card values */ +.rich p { + margin: 0; +} + +.rich ul, +.rich ol { + margin: 0.2em 0; + padding-left: 1.1em; +} + +.rich code { + font-family: 'IBM Plex Mono', ui-monospace, monospace; + font-size: 0.85em; + background: var(--ax-ui-bg-secondary-default, #f5f5f7); + padding: 0.05em 0.3em; + border-radius: 0.2rem; +} + +.rich a { + color: var(--ax-colors-acc1-500, #1096e7); + text-decoration: underline; +} + +.rich strong { + font-weight: 600; +} + +.text { + margin: 0; + font-family: 'IBM Plex Mono', ui-monospace, monospace; + font-size: 0.8rem; + line-height: 1.55; + color: var(--ax-txt-primary-default, #151516); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* JSON tree */ +.json { + font-family: 'IBM Plex Mono', ui-monospace, monospace; + font-size: 0.78rem; + line-height: 1.5; + color: var(--ax-txt-primary-default, #151516); +} + +.json-row { + display: flex; + gap: 0.3rem; + align-items: baseline; +} + +.json-row--expandable { + cursor: pointer; +} + +.json-children { + padding-left: 0.9rem; + border-left: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3); + margin-left: 0.25rem; +} + +.json-toggle { + width: 0.7rem; + flex-shrink: 0; + color: var(--ax-txt-tertiary-default, #6f7480); +} + +.json-key { + color: var(--ax-colors-acc1-600, #0477c5); +} + +.json-string { + color: #15803d; +} + +.json-number { + color: #b45309; +} + +.json-boolean, +.json-null { + color: #7c3aed; +} + +.json-bracket { + color: var(--ax-txt-tertiary-default, #6f7480); +} + +/* Table */ +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} + +.table th, +.table td { + border: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3); + padding: 0.3em 0.5em; + text-align: left; + vertical-align: top; +} + +.table th { + background: var(--ax-ui-bg-secondary-default, #f5f5f7); + font-weight: 600; + position: sticky; + top: 0; +} + +/* Stat cards */ +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); + gap: 0.5rem; +} + +.stat-card { + padding: 0.6rem 0.7rem; + border: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3); + border-radius: 0.5rem; + background: var(--ax-ui-bg-secondary-default, #f5f5f7); +} + +.stat-value { + font-size: 1.05rem; + font-weight: 600; + color: var(--ax-txt-primary-default, #151516); + overflow-wrap: anywhere; +} + +.stat-label { + margin-top: 0.15rem; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--ax-txt-tertiary-default, #6f7480); +} + +.fallback-note { + margin-bottom: 0.4rem; + font-size: 0.72rem; + color: var(--ax-txt-tertiary-default, #6f7480); +} + +/* Chart */ +.chart { + width: 100%; + height: 14rem; +} + +/* Diagram */ +.diagram { + display: flex; + justify-content: center; +} + +.diagram svg { + max-width: 100%; + height: auto; +} diff --git a/apps/ai-studio/src/components/visualize/renderers.tsx b/apps/ai-studio/src/components/visualize/renderers.tsx new file mode 100644 index 000000000..e381d4dee --- /dev/null +++ b/apps/ai-studio/src/components/visualize/renderers.tsx @@ -0,0 +1,275 @@ +import { type ComponentType, lazy, useState } from 'react'; +import Markdown, { type Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +import styles from './renderers.module.css'; + +import { type VisualizeRenderer, detectFormat } from '../../utils/detect-format'; + +// Lazy so recharts (~140KB) only loads when a chart is actually rendered. +const ChartRenderer = lazy(() => import('./chart-renderer').then((module) => ({ default: module.ChartRenderer }))); +// Lazy so mermaid (~150KB) only loads when a diagram is actually rendered. +const DiagramRenderer = lazy(() => + import('./diagram-renderer').then((module) => ({ default: module.DiagramRenderer })), +); + +export type RendererProps = { + text: string; + // Pre-parsed payload from detectFormat (auto mode); renderers parse `text` themselves otherwise. + data?: unknown; +}; + +export const RENDERER_LABELS: Record = { + markdown: 'Markdown', + text: 'Text', + json: 'JSON', + table: 'Table', + 'stat-cards': 'Stat cards', + chart: 'Chart', + diagram: 'Diagram', +}; + +function parseOr(text: string, data: unknown): unknown { + if (data !== undefined) { + return data; + } + try { + return JSON.parse(text); + } catch { + // not raw JSON — try a fenced ```json block (LLM output often wraps it) + } + const fence = /```(?:json)?\s*\n?([\s\S]*?)```/.exec(text); + if (fence) { + try { + return JSON.parse(fence[1].trim()); + } catch { + // fenced content is not JSON either + } + } + return undefined; +} + +function formatCell(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); +} + +function humanize(key: string): string { + return key + .replaceAll(/[_-]+/g, ' ') + .replaceAll(/([a-z])([A-Z])/g, '$1 $2') + .replace(/^./, (c) => c.toUpperCase()); +} + +// Cell / value strings from an LLM often carry markdown (bold, lists, links, +// code). Render those as markdown; leave plain strings untouched so values like +// "user_id" or "a_b" are not mangled. Raw HTML is intentionally NOT rendered +// (escaped) to avoid XSS from untrusted model output. +function hasRichText(value: string): boolean { + return ( + value.includes('\n') || + /\*\*|__|~~/.test(value) || + /`[^`]+`/.test(value) || + /\[[^\]]+\]\([^)]+\)/.test(value) || + /^\s*(?:#{1,6}\s|[-*+]\s|>\s|\d+\.\s)/m.test(value) + ); +} + +function RichText({ value }: { value: string }) { + if (!hasRichText(value)) { + return <>{value}; + } + return ( +
+ {value} +
+ ); +} + +// Render fenced code blocks richly: a ```mermaid block becomes a real diagram, +// a ```json block is detected and rendered (chart/table/json/...), so a mixed +// markdown response with an embedded diagram/data block renders it inline rather +// than as raw code. `pre` is unwrapped so these block renderers own their wrapper. +const markdownComponents: Components = { + pre: ({ children }) => <>{children}, + code({ className, children }) { + const language = /language-(\w+)/.exec(className ?? '')?.[1]; + const value = String(children).replace(/\n$/, ''); + if (language === 'mermaid') { + return ; + } + if (language === 'json') { + const detected = detectFormat(value); + const Renderer = getRenderer(detected.renderer); + return ; + } + if (language) { + return ( +
+          {children}
+        
+ ); + } + return {children}; + }, +}; + +function MarkdownRenderer({ text }: RendererProps) { + return ( +
+ + {text} + +
+ ); +} + +function TextRenderer({ text }: RendererProps) { + return
{text}
; +} + +function JsonValue({ name, value, depth }: { name?: string; value: unknown; depth: number }) { + const [open, setOpen] = useState(depth < 2); + const isExpandable = typeof value === 'object' && value !== null; + + if (!isExpandable) { + const scalarClass = + value === null ? 'json-null' : (`json-${typeof value}` as 'json-string' | 'json-number' | 'json-boolean'); + const display = typeof value === 'string' ? `"${value}"` : String(value); + return ( +
+ + {name !== undefined && {name}:} + {display} +
+ ); + } + + const entries: [string, unknown][] = Array.isArray(value) + ? value.map((item, index) => [String(index), item]) + : Object.entries(value as Record); + const bracket = Array.isArray(value) ? `[${entries.length}]` : `{${entries.length}}`; + + return ( +
+
setOpen((o) => !o)}> + {open ? '▾' : '▸'} + {name !== undefined && {name}:} + {bracket} +
+ {open && ( +
+ {entries.map(([key, child]) => ( + + ))} +
+ )} +
+ ); +} + +function JsonRenderer({ text, data }: RendererProps) { + const value = parseOr(text, data); + if (value === undefined) { + return
{text}
; + } + return ( +
+ +
+ ); +} + +function TableRenderer({ text, data }: RendererProps) { + const rows = parseOr(text, data); + if (!Array.isArray(rows) || rows.length === 0) { + return
{text}
; + } + + const objectRows = rows.every((row) => row !== null && typeof row === 'object' && !Array.isArray(row)); + const headers = objectRows + ? [...new Set(rows.flatMap((row) => Object.keys(row as Record)))] + : ['value']; + + return ( + + + + {headers.map((header) => ( + + ))} + + + + {rows.map((row, rowIndex) => ( + + {objectRows ? ( + headers.map((header) => ( + + )) + ) : ( + + )} + + ))} + +
{header}
+ )[header])} /> + + +
+ ); +} + +function StatCardsRenderer({ text, data }: RendererProps) { + const value = parseOr(text, data); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return
{text}
; + } + const entries = Object.entries(value as Record); + return ( +
+ {entries.map(([key, entryValue]) => ( +
+
+ +
+
{humanize(key)}
+
+ ))} +
+ ); +} + +// Resolve a renderer to its component. `chart` (recharts) and `diagram` (mermaid) +// are lazy and load only when actually used. +export function getRenderer(renderer: VisualizeRenderer): ComponentType { + switch (renderer) { + case 'text': { + return TextRenderer; + } + case 'json': { + return JsonRenderer; + } + case 'table': { + return TableRenderer; + } + case 'stat-cards': { + return StatCardsRenderer; + } + case 'chart': { + return ChartRenderer; + } + case 'diagram': { + return DiagramRenderer; + } + default: { + return MarkdownRenderer; + } + } +} diff --git a/apps/ai-studio/src/components/visualize/visualize-card.module.css b/apps/ai-studio/src/components/visualize/visualize-card.module.css new file mode 100644 index 000000000..9f3ed55b3 --- /dev/null +++ b/apps/ai-studio/src/components/visualize/visualize-card.module.css @@ -0,0 +1,177 @@ +/* Rendered in-flow inside the node body, so the node grows to contain it. The + node's own panel provides the border/background; this only adds a divider + from the node header and lays out the toolbar + content. */ +.integrated { + width: 100%; + margin-top: 0.4rem; + padding-top: 0.5rem; + border-top: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3); + font-family: var(--wb-font-family); + text-align: left; + color: var(--ax-txt-primary-default, #151516); + + &, + * { + box-sizing: border-box; + } +} + +.toolbar { + display: flex; + align-items: center; + gap: 0.3rem; + margin-bottom: 0.4rem; +} + +.badge { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: 'IBM Plex Mono', ui-monospace, monospace; + font-size: 0.64rem; + padding: 0.15rem 0.4rem; + border-radius: 0.3rem; + background: var(--ax-ui-bg-secondary-default, #f5f5f7); + color: var(--ax-txt-tertiary-default, #6f7480); +} + +.actions { + display: flex; + gap: 0.05rem; + margin-left: auto; + flex-shrink: 0; +} + +.action { + display: flex; + align-items: center; + justify-content: center; + width: 1.45rem; + height: 1.45rem; + border: none; + border-radius: 0.3rem; + background: transparent; + color: var(--ax-txt-tertiary-default, #6f7480); + cursor: pointer; +} + +.action svg { + width: 0.9rem; + height: 0.9rem; +} + +.action:hover { + background: var(--ax-ui-bg-secondary-default, #f5f5f7); + color: var(--ax-txt-primary-default, #151516); +} + +.chip { + margin-bottom: 0.4rem; + font-family: var(--wb-font-family); + font-size: 0.66rem; + padding: 0.15rem 0.5rem; + border: 0.0625rem solid var(--ax-colors-acc1-500, #1096e7); + border-radius: 0.3rem; + background: transparent; + color: var(--ax-colors-acc1-600, #0477c5); + cursor: pointer; + white-space: nowrap; +} + +.chip:hover { + background: var(--ax-ui-bg-secondary-default, #f5f5f7); +} + +.body { + max-height: 20rem; + overflow-y: auto; +} + +.revealed { + animation: md-reveal 420ms cubic-bezier(0.2, 0.7, 0.2, 1) both; +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 5rem; + text-align: center; + padding: 0.5rem 0.25rem; +} + +.empty-icon { + width: 1.6rem; + height: 1.6rem; + color: var(--ax-txt-tertiary-default, #6f7480); + opacity: 0.55; +} + +.empty-text { + margin: 0; + font-size: 0.74rem; + line-height: 1.45; + color: var(--ax-txt-tertiary-default, #6f7480); +} + +.dots { + display: flex; + gap: 0.4rem; + justify-content: center; +} + +.dot { + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: var(--ax-colors-acc1-500, #1096e7); + animation: md-pulse 1s ease-in-out infinite; +} + +.dot:nth-child(2) { + animation-delay: 0.15s; +} + +.dot:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes md-reveal { + from { + opacity: 0; + transform: translateY(0.5rem) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes md-pulse { + 0%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + + 50% { + opacity: 1; + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .revealed { + animation: none; + } + + .dot { + animation: none; + opacity: 0.7; + } +} diff --git a/apps/ai-studio/src/components/visualize/visualize-card.tsx b/apps/ai-studio/src/components/visualize/visualize-card.tsx new file mode 100644 index 000000000..66f0b6778 --- /dev/null +++ b/apps/ai-studio/src/components/visualize/visualize-card.tsx @@ -0,0 +1,201 @@ +import { ArrowsOut, ClipboardText, Copy, DownloadSimple, Eye, Sparkle } from '@phosphor-icons/react'; +import { getStoreEdges, getStoreNodes } from '@workflowbuilder/sdk'; +import { Suspense, useEffect, useRef, useState } from 'react'; + +import styles from './visualize-card.module.css'; + +import { useExecutionStore } from '../../stores/use-execution-store'; +import { adaptVisualization } from '../../utils/adapt-visualization'; +import { type VisualizeRenderer, detectFormat } from '../../utils/detect-format'; +import { copyImage, copySource, downloadPng } from '../../utils/export-visualization'; +import { extractOutputText } from '../../utils/extract-output-text'; +import { RENDERER_LABELS, getRenderer } from './renderers'; +import { VisualizeModal } from './visualize-modal'; + +type Props = { + props?: { + nodeId: string; + }; +}; + +type VisualizeMode = VisualizeRenderer | 'auto'; +const VALID_MODES = new Set(['auto', 'markdown', 'text', 'json', 'table', 'stat-cards', 'chart', 'diagram']); +// Formats the LLM "AI adapt" can convert into (markdown/text need no conversion). +const ADAPTABLE = new Set(['diagram', 'chart', 'table', 'json', 'stat-cards']); + +function EmptyState({ running }: { running: boolean }) { + if (running) { + return ( +
+
+ + + +
+

Generating visualization…

+
+ ); + } + return ( +
+ +

The visualization appears here after you run the workflow.

+
+ ); +} + +// Injected into the node body via the OptionalNodeContent decorator, so the +// visualization renders as part of the node itself. Reads the upstream node's +// output, picks a renderer (the node's `mode` or auto-detected), reveals it, and +// offers export, expand, and LLM "adapt" (manual button or the node's aiAdapt toggle). +export function VisualizeCard({ props }: Props) { + const nodeId = props?.nodeId ?? ''; + const [forceChart, setForceChart] = useState(false); + const [expanded, setExpanded] = useState(false); + const [adaptedText, setAdaptedText] = useState(null); + const [adapting, setAdapting] = useState(false); + const contentRef = useRef(null); + + // Node vocabulary and edges are static during a run, so snapshot reads are fine. + const node = getStoreNodes().find((entry) => entry.id === nodeId); + const isVisualizeNode = node?.data.type === 'ai-studio/visualize'; + const sourceId = getStoreEdges().find((edge) => edge.target === nodeId)?.source; + + const selfStatus = useExecutionStore((state) => state.nodeStates[nodeId]?.status); + const sourceOutput = useExecutionStore((state) => (sourceId ? state.nodeStates[sourceId]?.output : undefined)); + + const text = extractOutputText(sourceOutput); + const hasOutput = selfStatus === 'completed' && text.length > 0; + + const properties = node?.data.properties as { mode?: string; aiAdapt?: boolean } | undefined; + const mode: VisualizeMode = + properties?.mode && VALID_MODES.has(properties.mode) ? (properties.mode as VisualizeMode) : 'auto'; + const aiAdapt = Boolean(properties?.aiAdapt); + const detection = detectFormat(text); + let activeRenderer: VisualizeRenderer = mode === 'auto' ? detection.renderer : mode; + if (forceChart && mode === 'auto') { + activeRenderer = 'chart'; + } + + const runAdapt = (format: VisualizeRenderer) => { + setAdapting(true); + adaptVisualization(text, format) + .then((output) => setAdaptedText(output)) + .catch(() => { + // keep the original content on failure + }) + .finally(() => setAdapting(false)); + }; + + // A fresh upstream output clears any prior adaptation / chart override. + useEffect(() => { + setAdaptedText(null); + setForceChart(false); + }, [text]); + + // When the node's aiAdapt toggle is on, auto-convert the output to the format. + useEffect(() => { + if (hasOutput && aiAdapt && ADAPTABLE.has(activeRenderer) && adaptedText === null && !adapting) { + runAdapt(activeRenderer); + } + }, [hasOutput, aiAdapt, activeRenderer, text, adaptedText]); + + if (!isVisualizeNode) { + return null; + } + + const renderText = adaptedText ?? text; + const data = adaptedText === null && mode === 'auto' ? detection.data : undefined; + const Renderer = hasOutput ? getRenderer(activeRenderer) : null; + const badge = mode === 'auto' ? `Auto › ${RENDERER_LABELS[activeRenderer]}` : RENDERER_LABELS[activeRenderer]; + const showChartChip = mode === 'auto' && Boolean(detection.chartable) && activeRenderer !== 'chart'; + const isVector = activeRenderer === 'chart' || activeRenderer === 'diagram'; + const showAdaptButton = ADAPTABLE.has(activeRenderer) && !aiAdapt; + + return ( +
+ {hasOutput && Renderer ? ( + <> +
+ {badge} +
+ {showAdaptButton && ( + + )} + + + + +
+
+ {showChartChip && ( + + )} +
+ {adapting ? ( +
+
+ + + +
+

Adapting with AI…

+
+ ) : ( + Loading…

}> +
+ +
+
+ )} +
+ + ) : ( + + )} + {expanded && ( + setExpanded(false)} + /> + )} +
+ ); +} diff --git a/apps/ai-studio/src/components/visualize/visualize-modal.module.css b/apps/ai-studio/src/components/visualize/visualize-modal.module.css new file mode 100644 index 000000000..7850c1acc --- /dev/null +++ b/apps/ai-studio/src/components/visualize/visualize-modal.module.css @@ -0,0 +1,92 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: rgba(8, 10, 14, 0.55); + backdrop-filter: blur(2px); + font-family: var(--wb-font-family); +} + +.modal { + display: flex; + flex-direction: column; + width: min(56rem, 92vw); + max-height: 86vh; + background: var(--ax-ui-bg-primary-default, #ffffff); + border: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3); + border-radius: 0.9rem; + box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.4); + color: var(--ax-txt-primary-default, #151516); + overflow: hidden; + + &, + * { + box-sizing: border-box; + } +} + +.header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 0.0625rem solid var(--ax-ui-stroke-primary-default, #edeff3); +} + +.title { + font-size: 0.95rem; + font-weight: 600; +} + +.badge { + font-family: 'IBM Plex Mono', ui-monospace, monospace; + font-size: 0.7rem; + padding: 0.15rem 0.5rem; + border-radius: 0.3rem; + background: var(--ax-ui-bg-secondary-default, #f5f5f7); + color: var(--ax-txt-tertiary-default, #6f7480); +} + +.actions { + display: flex; + gap: 0.15rem; + margin-left: auto; +} + +.action { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border: none; + border-radius: 0.4rem; + background: transparent; + color: var(--ax-txt-tertiary-default, #6f7480); + cursor: pointer; +} + +.action svg { + width: 1.15rem; + height: 1.15rem; +} + +.action:hover { + background: var(--ax-ui-bg-secondary-default, #f5f5f7); + color: var(--ax-txt-primary-default, #151516); +} + +.body { + flex: 1; + overflow: auto; + padding: 1.25rem 1.5rem; +} + +.loading { + color: var(--ax-txt-tertiary-default, #6f7480); + font-size: 0.85rem; +} diff --git a/apps/ai-studio/src/components/visualize/visualize-modal.tsx b/apps/ai-studio/src/components/visualize/visualize-modal.tsx new file mode 100644 index 000000000..fc51fa375 --- /dev/null +++ b/apps/ai-studio/src/components/visualize/visualize-modal.tsx @@ -0,0 +1,81 @@ +import { ClipboardText, Copy, DownloadSimple, FileSvg, X } from '@phosphor-icons/react'; +import { Suspense, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +import styles from './visualize-modal.module.css'; + +import type { VisualizeRenderer } from '../../utils/detect-format'; +import { copyImage, copySource, downloadPng, downloadSvg } from '../../utils/export-visualization'; +import { getRenderer } from './renderers'; + +type Props = { + renderer: VisualizeRenderer; + text: string; + data?: unknown; + badge: string; + isVector: boolean; + onClose: () => void; +}; + +// Full-size view of a visualization. Rendered through a portal to document.body +// so its fixed overlay escapes the transformed React Flow viewport. +export function VisualizeModal({ renderer, text, data, badge, isVector, onClose }: Props) { + const contentRef = useRef(null); + const Renderer = getRenderer(renderer); + + return createPortal( +
+
event.stopPropagation()}> +
+ Visualize + {badge} +
+ + + {isVector && ( + + )} + + +
+
+
+ Loading…

}> + +
+
+
+
, + document.body, + ); +} diff --git a/apps/ai-studio/src/config.ts b/apps/ai-studio/src/config.ts index 35dfa8743..9b393dc57 100644 --- a/apps/ai-studio/src/config.ts +++ b/apps/ai-studio/src/config.ts @@ -1 +1,5 @@ export const BACKEND_URL = import.meta.env['VITE_BACKEND_URL'] ?? 'http://127.0.0.1:3001'; + +// Cloudflare Turnstile site key (public). Undefined = bot protection disabled +// (local dev); when set, the Run button attaches a token the backend verifies. +export const TURNSTILE_SITE_KEY = import.meta.env['VITE_TURNSTILE_SITE_KEY'] as string | undefined; diff --git a/apps/ai-studio/src/data/ai-debate-flow.ts b/apps/ai-studio/src/data/ai-debate-flow.ts new file mode 100644 index 000000000..99d2c00f2 --- /dev/null +++ b/apps/ai-studio/src/data/ai-debate-flow.ts @@ -0,0 +1,182 @@ +import type { DiagramModel, TemplateModel } from '@workflowbuilder/sdk'; + +const diagram: DiagramModel = { + name: 'AI Debate', + diagram: { + nodes: [ + { + id: 'trigger-1', + type: 'start-node', + position: { x: 0, y: 300 }, + data: { + segments: [], + properties: { + label: 'The Question', + description: 'A decision to stress-test from both sides.', + inputPrompt: + 'Should our 12-person startup build a native mobile app now, or keep doubling down on the web app first?', + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/trigger', + icon: 'Lightning', + }, + selected: false, + measured: { width: 258, height: 63 }, + dragging: false, + }, + { + id: 'optimist-1', + type: 'node', + position: { x: 400, y: 120 }, + data: { + segments: [], + properties: { + label: 'Optimist', + description: 'Argues the strongest case in favour.', + systemPrompt: `You are an optimistic strategist. Argue the strongest possible case FOR the +proposal in the question. Give 3-4 crisp bullet points - upside, opportunity, +why now. Be persuasive but honest, no hype.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'skeptic-1', + type: 'node', + position: { x: 400, y: 480 }, + data: { + segments: [], + properties: { + label: 'Skeptic', + description: "Argues the strongest case against - devil's advocate.", + systemPrompt: `You are a rigorous skeptic and devil's advocate. Argue the strongest possible +case AGAINST the proposal in the question. Give 3-4 crisp bullet points - risks, +hidden costs, what could go wrong. Surface the objections others gloss over.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'verdict-1', + type: 'node', + position: { x: 820, y: 300 }, + data: { + segments: [], + properties: { + label: 'Balanced Verdict', + description: 'Weighs both sides and recommends.', + systemPrompt: `You moderate a debate. You receive an optimist's case and a skeptic's case for +the same proposal. Weigh both and deliver a balanced recommendation as markdown, +in exactly this shape: + +**Verdict:** [one decisive line - pick a direction] + +| For | Against | +| --- | --- | +| [strongest point in favour] | [strongest point against] | +| [second point in favour] | [second point against] | + +**Reasoning:** [2-3 sentences: why this verdict, and the first concrete step.] + +Be decisive.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'visualize-1', + type: 'node', + position: { x: 1240, y: 300 }, + data: { + segments: [], + properties: { + label: 'Visualize', + description: 'Renders the verdict (auto-detects the format).', + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/visualize', + icon: 'Eye', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + ], + edges: [ + { + source: 'trigger-1', + sourceHandle: 'source', + target: 'optimist-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-trigger-optimist', + data: {}, + }, + { + source: 'trigger-1', + sourceHandle: 'source', + target: 'skeptic-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-trigger-skeptic', + data: {}, + }, + { + source: 'optimist-1', + sourceHandle: 'source', + target: 'verdict-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-optimist-verdict', + data: {}, + }, + { + source: 'skeptic-1', + sourceHandle: 'source', + target: 'verdict-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-skeptic-verdict', + data: {}, + }, + { + source: 'verdict-1', + sourceHandle: 'source', + target: 'visualize-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-verdict-visualize', + data: {}, + }, + ], + viewport: { x: 80, y: 80, zoom: 0.55 }, + }, + layoutDirection: 'RIGHT', +}; + +export const aiDebateFlow: TemplateModel = { + id: 302, + name: 'AI Debate', + value: diagram, + icon: 'ChatCircleDots', +}; diff --git a/apps/ai-studio/src/data/ai-studio-templates.ts b/apps/ai-studio/src/data/ai-studio-templates.ts index 6f3010121..a603b4fe7 100644 --- a/apps/ai-studio/src/data/ai-studio-templates.ts +++ b/apps/ai-studio/src/data/ai-studio-templates.ts @@ -1,5 +1,15 @@ import type { TemplateModel } from '@workflowbuilder/sdk'; -import { salesInquiryFlow } from './sales-inquiry-flow'; +import { aiDebateFlow } from './ai-debate-flow'; +import { contentRepurposerFlow } from './content-repurposer-flow'; +import { meetingNotesFlow } from './meeting-notes-flow'; +import { researchFlow } from './research-flow'; +import { supportTriageFlow } from './support-triage-flow'; -export const aiStudioTemplates: TemplateModel[] = [salesInquiryFlow]; +export const aiStudioTemplates: TemplateModel[] = [ + supportTriageFlow, + aiDebateFlow, + contentRepurposerFlow, + meetingNotesFlow, + researchFlow, +]; diff --git a/apps/ai-studio/src/data/content-repurposer-flow.ts b/apps/ai-studio/src/data/content-repurposer-flow.ts new file mode 100644 index 000000000..57322225e --- /dev/null +++ b/apps/ai-studio/src/data/content-repurposer-flow.ts @@ -0,0 +1,235 @@ +import type { DiagramModel, TemplateModel } from '@workflowbuilder/sdk'; + +const diagram: DiagramModel = { + name: 'Content Repurposer', + diagram: { + nodes: [ + { + id: 'trigger-1', + type: 'start-node', + position: { x: 0, y: 320 }, + data: { + segments: [], + properties: { + label: 'Source Content', + description: 'One piece of long-form content to repurpose.', + inputPrompt: `We just shipped Scheduled Reports in Lumen. You can now build any dashboard view +and have it delivered as a PDF to your inbox or a Slack channel on a daily, +weekly, or monthly cadence - no more manual exports before the Monday standup. + +It works with every chart type, respects your team's access permissions, and +takes about 30 seconds to set up. Early users tell us it has quietly removed one +of the most tedious parts of their week.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/trigger', + icon: 'Lightning', + }, + selected: false, + measured: { width: 258, height: 63 }, + dragging: false, + }, + { + id: 'twitter-1', + type: 'node', + position: { x: 420, y: 100 }, + data: { + segments: [], + properties: { + label: 'X / Twitter Thread', + description: 'Rewrites the content as a thread.', + systemPrompt: `Turn the source content into an engaging X/Twitter thread of 4-6 posts. +- Open with a scroll-stopping hook +- One idea per post, punchy and concrete +- End with a clear call to action +Number each post (1/, 2/, ...).`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'linkedin-1', + type: 'node', + position: { x: 420, y: 320 }, + data: { + segments: [], + properties: { + label: 'LinkedIn Post', + description: 'Rewrites the content for LinkedIn.', + systemPrompt: `Turn the source content into a LinkedIn post: +- A strong first line that earns the click-to-expand +- Short, skimmable paragraphs +- 2-3 concrete takeaways +- A closing question to drive comments +Keep it under 200 words. Professional but human.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'instagram-1', + type: 'node', + position: { x: 420, y: 540 }, + data: { + segments: [], + properties: { + label: 'Instagram Caption', + description: 'Rewrites the content as a caption.', + systemPrompt: `Turn the source content into an Instagram caption: +- Punchy and friendly, conversational tone +- A few relevant emojis (not too many) +- A short call to action +- 5 relevant hashtags on the last line`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'pack-1', + type: 'node', + position: { x: 840, y: 320 }, + data: { + segments: [], + properties: { + label: 'Content Pack', + description: 'Collects every channel into one document.', + systemPrompt: `You receive a source brief and three repurposed drafts from previous steps: an +X/Twitter thread, a LinkedIn post, and an Instagram caption. Identify each draft +by its content and assemble them into a single content pack as markdown, one +section per channel, in exactly this shape: + +## X / Twitter +[the thread] + +## LinkedIn +[the post] + +## Instagram +[the caption] + +Keep each draft's wording as-is - do not rewrite it. Just organize and label.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'visualize-1', + type: 'node', + position: { x: 1260, y: 320 }, + data: { + segments: [], + properties: { + label: 'Visualize', + description: 'Renders the content pack (auto-detects the format).', + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/visualize', + icon: 'Eye', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + ], + edges: [ + { + source: 'trigger-1', + sourceHandle: 'source', + target: 'twitter-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-trigger-twitter', + data: {}, + }, + { + source: 'trigger-1', + sourceHandle: 'source', + target: 'linkedin-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-trigger-linkedin', + data: {}, + }, + { + source: 'trigger-1', + sourceHandle: 'source', + target: 'instagram-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-trigger-instagram', + data: {}, + }, + { + source: 'twitter-1', + sourceHandle: 'source', + target: 'pack-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-twitter-pack', + data: {}, + }, + { + source: 'linkedin-1', + sourceHandle: 'source', + target: 'pack-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-linkedin-pack', + data: {}, + }, + { + source: 'instagram-1', + sourceHandle: 'source', + target: 'pack-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-instagram-pack', + data: {}, + }, + { + source: 'pack-1', + sourceHandle: 'source', + target: 'visualize-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-pack-visualize', + data: {}, + }, + ], + viewport: { x: 120, y: 80, zoom: 0.5 }, + }, + layoutDirection: 'RIGHT', +}; + +export const contentRepurposerFlow: TemplateModel = { + id: 303, + name: 'Content Repurposer', + value: diagram, + icon: 'Broadcast', +}; diff --git a/apps/ai-studio/src/data/meeting-notes-flow.ts b/apps/ai-studio/src/data/meeting-notes-flow.ts new file mode 100644 index 000000000..62fd234d3 --- /dev/null +++ b/apps/ai-studio/src/data/meeting-notes-flow.ts @@ -0,0 +1,175 @@ +import type { DiagramModel, TemplateModel } from '@workflowbuilder/sdk'; + +const diagram: DiagramModel = { + name: 'Meeting Notes to Action Items', + diagram: { + nodes: [ + { + id: 'trigger-1', + type: 'start-node', + position: { x: 0, y: 300 }, + data: { + segments: [], + properties: { + label: 'Meeting Transcript', + description: 'A raw transcript pasted in.', + inputPrompt: `[10:02] Priya: Okay, the launch date. Marketing wants the 14th, but the export bug is still open. +[10:03] Sam: Engineering can have the export fix in by the 11th if QA starts Monday. +[10:04] Priya: Works. Let's lock the 14th then. Sam, you own the fix. +[10:05] Dana: I'll prep the announcement email and the changelog - ready for review by the 12th. +[10:06] Priya: Great. And we still need pricing sign-off from finance before we announce. +[10:07] Sam: I'll ping finance today.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/trigger', + icon: 'Lightning', + }, + selected: false, + measured: { width: 258, height: 63 }, + dragging: false, + }, + { + id: 'summary-1', + type: 'node', + position: { x: 360, y: 300 }, + data: { + segments: [], + properties: { + label: 'Summarize', + description: 'Condenses the discussion and decisions.', + systemPrompt: `Summarize the meeting transcript in 3-4 sentences: what was discussed and what +was decided. Neutral, factual tone.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'actions-1', + type: 'node', + position: { x: 720, y: 300 }, + data: { + segments: [], + properties: { + label: 'Extract Action Items', + description: 'Pulls out owners, tasks and due dates.', + systemPrompt: `From the meeting transcript, extract every action item as a list. For each item +give: owner, task, and due date if one was mentioned. If the owner is unclear, +mark it "unassigned". Do not invent items that were not discussed.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'recap-1', + type: 'node', + position: { x: 1080, y: 300 }, + data: { + segments: [], + properties: { + label: 'Format Recap', + description: 'Produces a clean recap with an action-items table.', + systemPrompt: `Produce a clean meeting recap as markdown, in exactly this shape: + +## Recap +[one short paragraph summarizing the meeting and its decisions] + +## Action Items + +| Owner | Task | Due | +| --- | --- | --- | +| [owner] | [task] | [due date or "-"] | + +One row per action item. Keep it tight and skimmable. Sign off with a final +line "_Meeting Bot_".`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'visualize-1', + type: 'node', + position: { x: 1440, y: 300 }, + data: { + segments: [], + properties: { + label: 'Visualize', + description: 'Renders the recap (auto-detects the format).', + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/visualize', + icon: 'Eye', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + ], + edges: [ + { + source: 'trigger-1', + sourceHandle: 'source', + target: 'summary-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-trigger-summary', + data: {}, + }, + { + source: 'summary-1', + sourceHandle: 'source', + target: 'actions-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-summary-actions', + data: {}, + }, + { + source: 'actions-1', + sourceHandle: 'source', + target: 'recap-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-actions-recap', + data: {}, + }, + { + source: 'recap-1', + sourceHandle: 'source', + target: 'visualize-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-recap-visualize', + data: {}, + }, + ], + viewport: { x: 80, y: 90, zoom: 0.55 }, + }, + layoutDirection: 'RIGHT', +}; + +export const meetingNotesFlow: TemplateModel = { + id: 304, + name: 'Meeting Notes to Action Items', + value: diagram, + icon: 'CalendarCheck', +}; diff --git a/apps/ai-studio/src/data/node-types.ts b/apps/ai-studio/src/data/node-types.ts index 8390126bc..48ad1faef 100644 --- a/apps/ai-studio/src/data/node-types.ts +++ b/apps/ai-studio/src/data/node-types.ts @@ -3,11 +3,12 @@ import type { PaletteItemOrGroup } from '@workflowbuilder/sdk'; import { aiAgentPaletteItem } from '../nodes/ai-agent'; import { decisionPaletteItem } from '../nodes/decision'; import { triggerPaletteItem } from '../nodes/trigger'; +import { visualizePaletteItem } from '../nodes/visualize'; export const aiStudioNodeTypes: PaletteItemOrGroup[] = [ { label: 'AI Studio', isOpen: true, - groupItems: [triggerPaletteItem, aiAgentPaletteItem, decisionPaletteItem], + groupItems: [triggerPaletteItem, aiAgentPaletteItem, decisionPaletteItem, visualizePaletteItem], }, ]; diff --git a/apps/ai-studio/src/data/research-flow.ts b/apps/ai-studio/src/data/research-flow.ts new file mode 100644 index 000000000..725ebf3b0 --- /dev/null +++ b/apps/ai-studio/src/data/research-flow.ts @@ -0,0 +1,111 @@ +import type { DiagramModel, TemplateModel } from '@workflowbuilder/sdk'; + +const diagram: DiagramModel = { + name: 'Market Research Brief', + diagram: { + nodes: [ + { + id: 'trigger-1', + type: 'start-node', + position: { x: 0, y: 300 }, + data: { + segments: [], + properties: { + label: 'Research Topic', + description: 'The question to research on the web.', + inputPrompt: `Give me a briefing on the current market for AI-powered customer support tools: the main products, any notable recent launches or updates, and what users commonly praise or complain about.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/trigger', + icon: 'Lightning', + }, + selected: false, + measured: { width: 258, height: 63 }, + dragging: false, + }, + { + id: 'research-1', + type: 'node', + position: { x: 380, y: 300 }, + data: { + segments: [], + properties: { + label: 'Research Agent', + description: 'Searches the web and writes a sourced brief.', + systemPrompt: `You are a research analyst. Use the web_search tool to gather current, factual +information about the topic in the request - search more than once if it helps. +Then write a concise brief in Markdown, in exactly this shape: + +## Summary +2-3 sentences answering the request. + +## Key findings +- 3-5 bullets, each a concrete fact you found. + +## Sources +- A short list of the page titles and URLs you actually used. + +Only state things you found via search. If a claim isn't supported by a result, leave it out.`, + webSearch: true, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'visualize-1', + type: 'node', + position: { x: 760, y: 300 }, + data: { + segments: [], + properties: { + label: 'Visualize', + description: 'Renders the research brief (auto-detects the format).', + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/visualize', + icon: 'Eye', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + ], + edges: [ + { + source: 'trigger-1', + sourceHandle: 'source', + target: 'research-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-trigger-research', + data: {}, + }, + { + source: 'research-1', + sourceHandle: 'source', + target: 'visualize-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-research-visualize', + data: {}, + }, + ], + viewport: { x: 180, y: 150, zoom: 0.7 }, + }, + layoutDirection: 'RIGHT', +}; + +export const researchFlow: TemplateModel = { + id: 305, + name: 'Market Research Brief', + value: diagram, + icon: 'MagnifyingGlass', +}; diff --git a/apps/ai-studio/src/data/sales-inquiry-flow.ts b/apps/ai-studio/src/data/sales-inquiry-flow.ts deleted file mode 100644 index b23995024..000000000 --- a/apps/ai-studio/src/data/sales-inquiry-flow.ts +++ /dev/null @@ -1,339 +0,0 @@ -import type { DiagramModel, TemplateModel } from '@workflowbuilder/sdk'; - -const PRODUCT_KNOWLEDGE = `You work for Synergy Codes, the company behind Workflow Builder. - -Key product facts: -- Workflow Builder is a frontend-only SDK for building visual workflow editors, not a SaaS platform -- It's built on React Flow, extends it with node library, schema-driven properties panel, design system, plugin architecture -- White-label: can be themed and branded to match the customer's product -- Community Edition: Apache 2.0 (free, commercial use allowed) -- Enterprise Edition: one-time license EUR 6,990 (no subscription, no revenue sharing) -- Source code ownership: customer gets full source code, can modify and extend -- Use cases: embed workflow editors into B2B SaaS, build AI agent platforms, visual rule engines, automation tools -- NOT an iPaaS like n8n/Make/Zapier — those are hosted platforms, WB is an embeddable SDK -- Execution-agnostic: outputs JSON, customer builds their own execution backend -- Handles up to ~500 nodes, supports undo/redo, keyboard shortcuts, WCAG accessibility -- No telemetry, no external data — runs entirely in customer's infrastructure -- Tech stack: React, Zustand, React Flow, JSONForms, Overflow UI`; - -const diagram: DiagramModel = { - name: 'Sales Inquiry Pipeline', - diagram: { - nodes: [ - { - id: 'trigger-1', - type: 'start-node', - position: { x: 0, y: 300 }, - data: { - segments: [], - properties: { - label: 'Incoming Email', - description: 'Customer inquiry arrives via email.', - inputPrompt: `Hi there, - -I'm a product manager at DataFlow Inc. We're building an internal automation platform and need a visual workflow editor for our users to design data pipelines. - -We've looked at building something custom with React Flow but realized it would take months to get to production quality. A colleague mentioned Workflow Builder. - -A few questions: -1. Can we embed it into our existing React app? -2. How does pricing work — is it per-seat or per-deployment? -3. Do you support custom node types? Our nodes would need to represent database connections, API calls, and ML model steps. -4. How does it compare to just using React Flow directly? - -We'd need this for about 200 internal users. Timeline is Q3 this year. - -Best, -Sarah Chen -Product Manager, DataFlow Inc.`, - errors: [], - errorPolicy: 'fail', - }, - type: 'ai-studio/trigger', - icon: 'Lightning', - }, - selected: false, - measured: { width: 258, height: 63 }, - dragging: false, - }, - { - id: 'classify-1', - type: 'node', - position: { x: 350, y: 300 }, - data: { - segments: [], - properties: { - label: 'Classify & Extract', - description: 'Classifies the inquiry type and extracts key details.', - systemPrompt: `${PRODUCT_KNOWLEDGE} - -Analyze the incoming customer email. Return a structured classification: - -**Type:** [pricing / technical / feature-request / partnership / general] -**Urgency:** [high / medium / low] -**Company:** [extract company name if mentioned] -**Key Questions:** [bullet list of specific questions asked] -**Product Interest:** [which aspects of Workflow Builder they're asking about] - -Be concise. Use the exact format above.`, - errors: [], - errorPolicy: 'fail', - }, - type: 'ai-studio/ai-agent', - icon: 'AiAgent', - }, - selected: false, - measured: { width: 258, height: 123 }, - dragging: false, - }, - { - id: 'decision-1', - type: 'decision-node', - position: { x: 700, y: 300 }, - data: { - segments: [], - properties: { - label: 'Route by Type', - description: 'Routes the inquiry to the right specialist.', - decisionBranches: [ - { - id: 'branch-pricing', - sourceHandle: 'source:inner:pricing', - label: 'Pricing', - conditions: [ - { - x: '{{nodes.classify-1.response}}', - y: 'pricing', - comparisonOperator: 'isContaining', - logicalOperator: 'AND', - }, - ], - }, - { - id: 'branch-technical', - sourceHandle: 'source:inner:technical', - label: 'Technical', - conditions: [ - { - x: '{{nodes.classify-1.response}}', - y: 'technical', - comparisonOperator: 'isContaining', - logicalOperator: 'AND', - }, - ], - }, - { - id: 'branch-general', - sourceHandle: 'source:inner:general', - label: 'General', - conditions: [], - }, - ], - errors: [], - errorPolicy: 'fail', - }, - type: 'ai-studio/decision', - icon: 'ArrowsSplit', - }, - selected: false, - measured: { width: 258, height: 236 }, - dragging: false, - }, - { - id: 'pricing-1', - type: 'node', - position: { x: 1100, y: 50 }, - data: { - segments: [], - properties: { - label: 'Pricing Specialist', - description: 'Drafts a pricing-focused reply.', - systemPrompt: `${PRODUCT_KNOWLEDGE} - -You are a pricing specialist at Synergy Codes. Write a pricing-focused reply to the customer: -- Lead with clear pricing info: Enterprise EUR 6,990 one-time, Community free (Apache 2.0) -- Explain what's included in each tier -- Address any specific pricing question they asked -- Offer a call to walk through licensing details -- Keep under 180 words. Sign as "Synergy Codes Sales".`, - errors: [], - errorPolicy: 'fail', - }, - type: 'ai-studio/ai-agent', - icon: 'AiAgent', - }, - selected: false, - measured: { width: 258, height: 123 }, - dragging: false, - }, - { - id: 'technical-1', - type: 'node', - position: { x: 1100, y: 300 }, - data: { - segments: [], - properties: { - label: 'Technical Specialist', - description: 'Drafts a technical reply.', - systemPrompt: `${PRODUCT_KNOWLEDGE} - -You are a senior engineer at Synergy Codes. Write a technical reply focused on integration and extension: -- Answer technical questions concretely (embedding, custom nodes, React Flow comparison) -- Reference how the plugin architecture and schema-driven properties panel work -- Offer a demo call to show custom node patterns -- Keep under 180 words. Sign as "Synergy Codes Engineering".`, - errors: [], - errorPolicy: 'fail', - }, - type: 'ai-studio/ai-agent', - icon: 'AiAgent', - }, - selected: false, - measured: { width: 258, height: 123 }, - dragging: false, - }, - { - id: 'general-1', - type: 'node', - position: { x: 1100, y: 550 }, - data: { - segments: [], - properties: { - label: 'General Response', - description: 'Drafts a general reply when no specialist branch matches.', - systemPrompt: `${PRODUCT_KNOWLEDGE} - -You are a sales engineer at Synergy Codes. Write a friendly general reply: -- Acknowledge the inquiry -- Summarize what Workflow Builder is in 2-3 sentences -- Ask a clarifying question to direct the conversation -- Offer a discovery call -- Keep under 150 words. Sign as "Synergy Codes Team".`, - errors: [], - errorPolicy: 'fail', - }, - type: 'ai-studio/ai-agent', - icon: 'AiAgent', - }, - selected: false, - measured: { width: 258, height: 123 }, - dragging: false, - }, - { - id: 'review-1', - type: 'node', - position: { x: 1500, y: 300 }, - data: { - segments: [], - properties: { - label: 'Final QA Check', - description: 'Reviews the draft for accuracy and tone before sending.', - systemPrompt: `${PRODUCT_KNOWLEDGE} - -Review the draft email reply from the previous step. Check for: -1. **Factual accuracy** — does it match the product knowledge? Any wrong claims? -2. **Tone** — professional but approachable? Not too salesy? -3. **Completeness** — did it address all the customer's questions? -4. **Call to action** — is there a clear next step? - -If everything is good, output: "✅ APPROVED" followed by the final email text. -If there are issues, output: "⚠️ NEEDS REVISION" followed by specific corrections.`, - errors: [], - errorPolicy: 'fail', - }, - type: 'ai-studio/ai-agent', - icon: 'AiAgent', - }, - selected: false, - measured: { width: 258, height: 123 }, - dragging: false, - }, - ], - edges: [ - { - source: 'trigger-1', - sourceHandle: 'source', - target: 'classify-1', - targetHandle: 'target', - type: 'labelEdge', - id: 'edge-trigger-classify', - data: {}, - }, - { - source: 'classify-1', - sourceHandle: 'source', - target: 'decision-1', - targetHandle: 'target', - type: 'labelEdge', - id: 'edge-classify-decision', - data: {}, - }, - { - source: 'decision-1', - sourceHandle: 'source:inner:pricing', - zIndex: 1001, - target: 'pricing-1', - targetHandle: 'target', - type: 'labelEdge', - id: 'edge-decision-pricing', - data: {}, - }, - { - source: 'decision-1', - sourceHandle: 'source:inner:technical', - zIndex: 1001, - target: 'technical-1', - targetHandle: 'target', - type: 'labelEdge', - id: 'edge-decision-technical', - data: {}, - }, - { - source: 'decision-1', - sourceHandle: 'source:inner:general', - zIndex: 1001, - target: 'general-1', - targetHandle: 'target', - type: 'labelEdge', - id: 'edge-decision-general', - data: {}, - }, - { - source: 'pricing-1', - sourceHandle: 'source', - target: 'review-1', - targetHandle: 'target', - type: 'labelEdge', - id: 'edge-pricing-review', - data: {}, - }, - { - source: 'technical-1', - sourceHandle: 'source', - target: 'review-1', - targetHandle: 'target', - type: 'labelEdge', - id: 'edge-technical-review', - data: {}, - }, - { - source: 'general-1', - sourceHandle: 'source', - target: 'review-1', - targetHandle: 'target', - type: 'labelEdge', - id: 'edge-general-review', - data: {}, - }, - ], - viewport: { x: 100, y: 100, zoom: 0.6 }, - }, - layoutDirection: 'RIGHT', -}; - -export const salesInquiryFlow: TemplateModel = { - id: 202, - name: 'Sales Inquiry Pipeline', - value: diagram, - icon: 'Envelope', -}; diff --git a/apps/ai-studio/src/data/support-triage-flow.ts b/apps/ai-studio/src/data/support-triage-flow.ts new file mode 100644 index 000000000..a11083a6c --- /dev/null +++ b/apps/ai-studio/src/data/support-triage-flow.ts @@ -0,0 +1,352 @@ +import type { DiagramModel, TemplateModel } from '@workflowbuilder/sdk'; + +const SUPPORT_CONTEXT = `You are part of the customer support team for Lumen, a SaaS analytics product. + +Plans: Free, Pro ($49 / month), and Team (custom pricing). +Support style: empathetic, concise, solution-first. Always acknowledge how the +customer feels, give concrete next steps, and never promise a timeline you cannot keep.`; + +const diagram: DiagramModel = { + name: 'Customer Support Triage', + diagram: { + nodes: [ + { + id: 'trigger-1', + type: 'start-node', + position: { x: 0, y: 300 }, + data: { + segments: [], + properties: { + label: 'New Support Ticket', + description: 'A support ticket arrives in the shared inbox.', + inputPrompt: `Subject: Charged twice AND export is broken + +Hi, I've been on the Pro plan for 8 months and I just noticed TWO $49 charges on my card this month instead of one. On top of that, the CSV export on the Reports page has been stuck on a spinner for two days. + +I have a board meeting on Thursday and I genuinely need that export working. This is really frustrating - can someone please sort out the refund and tell me how to get my data out? + +Thanks, +Marcus +Head of Ops, Brightwave`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/trigger', + icon: 'Lightning', + }, + selected: false, + measured: { width: 258, height: 63 }, + dragging: false, + }, + { + id: 'classify-1', + type: 'node', + position: { x: 350, y: 300 }, + data: { + segments: [], + properties: { + label: 'Classify Ticket', + description: 'Detects the primary issue type, urgency and sentiment.', + systemPrompt: `${SUPPORT_CONTEXT} + +Read the incoming support ticket and classify it. A ticket may mention several +problems - pick the SINGLE most important one as the primary type. + +Return exactly this format: + +**Type:** [one of: billing / bug / how-to / other] +**Urgency:** [high / medium / low] +**Sentiment:** [happy / neutral / frustrated] +**Summary:** [one sentence describing the core request] +**Also mentioned:** [any secondary issues, or "none"] + +Use the exact lowercase keyword on the Type line - it drives downstream routing.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'decision-1', + type: 'decision-node', + position: { x: 700, y: 300 }, + data: { + segments: [], + properties: { + label: 'Route by Type', + description: 'Sends the ticket to the right responder.', + decisionBranches: [ + { + id: 'branch-billing', + sourceHandle: 'source:inner:billing', + label: 'Billing', + conditions: [ + { + x: '{{nodes.classify-1.response}}', + y: 'billing', + comparisonOperator: 'isContaining', + logicalOperator: 'AND', + }, + ], + }, + { + id: 'branch-bug', + sourceHandle: 'source:inner:bug', + label: 'Bug', + conditions: [ + { + x: '{{nodes.classify-1.response}}', + y: 'bug', + comparisonOperator: 'isContaining', + logicalOperator: 'AND', + }, + ], + }, + { + id: 'branch-general', + sourceHandle: 'source:inner:general', + label: 'How-to / Other', + conditions: [], + }, + ], + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/decision', + icon: 'ArrowsSplit', + }, + selected: false, + measured: { width: 258, height: 236 }, + dragging: false, + }, + { + id: 'billing-1', + type: 'node', + position: { x: 1100, y: 50 }, + data: { + segments: [], + properties: { + label: 'Billing Reply', + description: 'Drafts a reply for billing and payment issues.', + systemPrompt: `${SUPPORT_CONTEXT} + +You handle billing issues. Draft a reply to the customer: +- Open by acknowledging the problem and apologising for the duplicate charge +- Explain the refund will be issued to the original card and how long it usually takes +- If they also reported a non-billing problem, tell them you've looped in the right team and they'll hear back separately +- End with a clear next step +- Keep it under 160 words. Sign as "Lumen Support".`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'bug-1', + type: 'node', + position: { x: 1100, y: 300 }, + data: { + segments: [], + properties: { + label: 'Bug Triage Reply', + description: 'Drafts a reply for product bugs and breakage.', + systemPrompt: `${SUPPORT_CONTEXT} + +You triage product bugs. Draft a reply to the customer: +- Acknowledge the broken behaviour and that it is not expected +- Offer a workaround if a plausible one exists (e.g. a different export path) +- Ask for the details engineering will need: browser, time it last worked, a screenshot +- Set honest expectations - it has been escalated, not "fixed by Thursday" +- Keep it under 160 words. Sign as "Lumen Support".`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'general-1', + type: 'node', + position: { x: 1100, y: 550 }, + data: { + segments: [], + properties: { + label: 'How-to Reply', + description: 'Drafts a reply for how-to questions and everything else.', + systemPrompt: `${SUPPORT_CONTEXT} + +You answer how-to and general questions. Draft a friendly reply: +- Acknowledge the question +- Give a concrete, step-by-step answer if you can, or ask one clarifying question if you can't +- Point to the relevant Help Center section +- Keep it under 140 words. Sign as "Lumen Support".`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'qa-1', + type: 'node', + position: { x: 1500, y: 300 }, + data: { + segments: [], + properties: { + label: 'Tone & Accuracy QA', + description: 'Reviews the drafted reply before it goes out.', + systemPrompt: `${SUPPORT_CONTEXT} + +Review the drafted reply from the previous step before it is sent. Check: +1. **Tone** - empathetic and professional, not defensive or robotic? +2. **Accuracy** - does it match the plan and policy facts above? No invented promises? +3. **Completeness** - did it address the customer's main request? +4. **Next step** - is there a clear call to action? + +If it is good, output "✅ APPROVED" followed by the final reply text. +If not, output "⚠️ NEEDS REVISION" followed by specific, actionable fixes.`, + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/ai-agent', + icon: 'AiAgent', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'visualize-1', + type: 'node', + position: { x: 1850, y: 300 }, + data: { + segments: [], + properties: { + label: 'Visualize', + description: 'Visualizes the approved reply (auto-detects the format).', + errors: [], + errorPolicy: 'fail', + }, + type: 'ai-studio/visualize', + icon: 'Eye', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + ], + edges: [ + { + source: 'trigger-1', + sourceHandle: 'source', + target: 'classify-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-trigger-classify', + data: {}, + }, + { + source: 'classify-1', + sourceHandle: 'source', + target: 'decision-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-classify-decision', + data: {}, + }, + { + source: 'decision-1', + sourceHandle: 'source:inner:billing', + zIndex: 1001, + target: 'billing-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-decision-billing', + data: {}, + }, + { + source: 'decision-1', + sourceHandle: 'source:inner:bug', + zIndex: 1001, + target: 'bug-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-decision-bug', + data: {}, + }, + { + source: 'decision-1', + sourceHandle: 'source:inner:general', + zIndex: 1001, + target: 'general-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-decision-general', + data: {}, + }, + { + source: 'billing-1', + sourceHandle: 'source', + target: 'qa-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-billing-qa', + data: {}, + }, + { + source: 'bug-1', + sourceHandle: 'source', + target: 'qa-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-bug-qa', + data: {}, + }, + { + source: 'general-1', + sourceHandle: 'source', + target: 'qa-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-general-qa', + data: {}, + }, + { + source: 'qa-1', + sourceHandle: 'source', + target: 'visualize-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-qa-visualize', + data: {}, + }, + ], + viewport: { x: 100, y: 100, zoom: 0.6 }, + }, + layoutDirection: 'RIGHT', +}; + +export const supportTriageFlow: TemplateModel = { + id: 301, + name: 'Customer Support Triage', + value: diagram, + icon: 'Envelope', +}; diff --git a/apps/ai-studio/src/hooks/use-backend-execution.ts b/apps/ai-studio/src/hooks/use-backend-execution.ts index 2ac606541..77bff5f6a 100644 --- a/apps/ai-studio/src/hooks/use-backend-execution.ts +++ b/apps/ai-studio/src/hooks/use-backend-execution.ts @@ -2,6 +2,7 @@ import { useCallback, useRef } from 'react'; import { connectExecutionStream } from '../adapters/execution-stream-adapter'; import { BACKEND_URL } from '../config'; +import { getTurnstileToken } from '../security/turnstile'; import { resetExecution, setExecutionStarted, useExecutionStore } from '../stores/use-execution-store'; export function useBackendExecution() { @@ -26,9 +27,16 @@ export function useBackendExecution() { const { id: workflowId } = (await wfResponse.json()) as { id: string }; + // Bot-verification token for the public demo. Undefined when Turnstile is + // not configured (local dev) — the backend then skips verification too. + const turnstileToken = await getTurnstileToken(); + const execResponse = await fetch(`${BACKEND_URL}/api/workflows/${workflowId}/execute`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(turnstileToken ? { 'cf-turnstile-token': turnstileToken } : {}), + }, body: JSON.stringify({ sourceVersion: 'draft', triggerPayload }), }); diff --git a/apps/ai-studio/src/nodes/ai-agent/default-properties-data.ts b/apps/ai-studio/src/nodes/ai-agent/default-properties-data.ts index 5fe48ee57..99b2adb30 100644 --- a/apps/ai-studio/src/nodes/ai-agent/default-properties-data.ts +++ b/apps/ai-studio/src/nodes/ai-agent/default-properties-data.ts @@ -6,5 +6,6 @@ export const defaultPropertiesData: NodeDataProperties = { label: 'AI Agent', description: '', systemPrompt: '', + webSearch: false, errorPolicy: 'fail', }; diff --git a/apps/ai-studio/src/nodes/ai-agent/index.ts b/apps/ai-studio/src/nodes/ai-agent/index.ts index 0dffa4dfe..dc665226a 100644 --- a/apps/ai-studio/src/nodes/ai-agent/index.ts +++ b/apps/ai-studio/src/nodes/ai-agent/index.ts @@ -13,4 +13,13 @@ export const aiAgentPaletteItem: PaletteItem = { defaultPropertiesData, schema, uischema, + // Declares the executor's output so `{{ nodes..response }}` references + // (e.g. in decision conditions) resolve to a real mention suggestion instead + // of rendering as an unresolved "missing mention" pill. + outputSchema: { + type: 'default', + properties: { + response: { type: 'string', label: 'Response', description: 'The text generated by the AI model' }, + }, + }, }; diff --git a/apps/ai-studio/src/nodes/ai-agent/schema.ts b/apps/ai-studio/src/nodes/ai-agent/schema.ts index 37aabb088..30f5ec99e 100644 --- a/apps/ai-studio/src/nodes/ai-agent/schema.ts +++ b/apps/ai-studio/src/nodes/ai-agent/schema.ts @@ -9,6 +9,9 @@ export const schema = { systemPrompt: { type: 'string', }, + webSearch: { + type: 'boolean', + }, }, } satisfies NodeSchema; diff --git a/apps/ai-studio/src/nodes/ai-agent/uischema.ts b/apps/ai-studio/src/nodes/ai-agent/uischema.ts index 676d50c7c..b9f797435 100644 --- a/apps/ai-studio/src/nodes/ai-agent/uischema.ts +++ b/apps/ai-studio/src/nodes/ai-agent/uischema.ts @@ -21,6 +21,11 @@ export const uischema: UISchema = { placeholder: 'Describe what the AI should do...', minRows: 5, }, + { + type: 'Switch', + scope: scope('properties.webSearch'), + label: 'Web search (let the agent look things up)', + }, { type: 'Select', scope: scope('properties.errorPolicy'), diff --git a/apps/ai-studio/src/nodes/visualize/default-properties-data.ts b/apps/ai-studio/src/nodes/visualize/default-properties-data.ts new file mode 100644 index 000000000..a5cf7e1d0 --- /dev/null +++ b/apps/ai-studio/src/nodes/visualize/default-properties-data.ts @@ -0,0 +1,11 @@ +import type { NodeDataProperties } from '@workflowbuilder/sdk'; + +import type { VisualizeSchema } from './schema'; + +export const defaultPropertiesData: NodeDataProperties = { + label: 'Visualize', + description: '', + mode: 'auto', + aiAdapt: false, + errorPolicy: 'fail', +}; diff --git a/apps/ai-studio/src/nodes/visualize/index.ts b/apps/ai-studio/src/nodes/visualize/index.ts new file mode 100644 index 000000000..98ae3a2ee --- /dev/null +++ b/apps/ai-studio/src/nodes/visualize/index.ts @@ -0,0 +1,16 @@ +import { NodeType, type PaletteItem } from '@workflowbuilder/sdk'; + +import { defaultPropertiesData } from './default-properties-data'; +import { type VisualizeSchema, schema } from './schema'; +import { uischema } from './uischema'; + +export const visualizePaletteItem: PaletteItem = { + label: 'Visualize', + description: 'Render the result visually', + type: 'ai-studio/visualize', + icon: 'Eye', + templateType: NodeType.Node, + defaultPropertiesData, + schema, + uischema, +}; diff --git a/apps/ai-studio/src/nodes/visualize/schema.ts b/apps/ai-studio/src/nodes/visualize/schema.ts new file mode 100644 index 000000000..8562a3cde --- /dev/null +++ b/apps/ai-studio/src/nodes/visualize/schema.ts @@ -0,0 +1,34 @@ +import { errorPolicyProperty, sharedProperties } from '@workflowbuilder/sdk'; +import type { NodeSchema } from '@workflowbuilder/sdk'; + +// `auto` lets the node detect the format; the rest force a specific renderer. +const VISUALIZE_MODES = ['auto', 'markdown', 'text', 'json', 'table', 'stat-cards', 'chart', 'diagram'] as const; +type VisualizeMode = (typeof VISUALIZE_MODES)[number]; + +const MODE_LABELS: Record = { + auto: 'Auto (detect format)', + markdown: 'Markdown', + text: 'Plain text', + json: 'JSON tree', + table: 'Table', + 'stat-cards': 'Stat cards', + chart: 'Chart', + diagram: 'Diagram', +}; + +export const schema = { + type: 'object', + properties: { + ...sharedProperties, + ...errorPolicyProperty, + mode: { + type: 'string', + options: VISUALIZE_MODES.map((value) => ({ label: MODE_LABELS[value], value })), + }, + aiAdapt: { + type: 'boolean', + }, + }, +} satisfies NodeSchema; + +export type VisualizeSchema = typeof schema; diff --git a/apps/ai-studio/src/nodes/visualize/uischema.ts b/apps/ai-studio/src/nodes/visualize/uischema.ts new file mode 100644 index 000000000..445ab209a --- /dev/null +++ b/apps/ai-studio/src/nodes/visualize/uischema.ts @@ -0,0 +1,33 @@ +import { getScope } from '@workflowbuilder/sdk'; +import type { UISchema } from '@workflowbuilder/sdk'; + +import type { VisualizeSchema } from './schema'; + +const scope = getScope; + +export const uischema: UISchema = { + type: 'VerticalLayout', + elements: [ + { + type: 'Text', + scope: scope('properties.label'), + label: 'Title', + placeholder: 'Node Title...', + }, + { + type: 'Select', + scope: scope('properties.mode'), + label: 'Render as', + }, + { + type: 'Switch', + scope: scope('properties.aiAdapt'), + label: 'AI: adapt output to this format', + }, + { + type: 'Select', + scope: scope('properties.errorPolicy'), + label: 'Error Policy', + }, + ], +}; diff --git a/apps/ai-studio/src/plugin.ts b/apps/ai-studio/src/plugin.ts index d4fb8698e..9469d85f2 100644 --- a/apps/ai-studio/src/plugin.ts +++ b/apps/ai-studio/src/plugin.ts @@ -2,6 +2,7 @@ import { type OptionalNodeContent, registerComponentDecorator } from '@workflowb import { ErrorHandle } from './components/error-handle/error-handle'; import { ExecutionNodeMarkers } from './components/execution/node-markers'; +import { VisualizeCard } from './components/visualize/visualize-card'; type OptionalNodeContentProps = React.ComponentProps; @@ -13,4 +14,8 @@ export function plugin(): void { content: ErrorHandle, place: 'after', }); + registerComponentDecorator('OptionalNodeContent', { + content: VisualizeCard, + place: 'after', + }); } diff --git a/apps/ai-studio/src/security/turnstile.ts b/apps/ai-studio/src/security/turnstile.ts new file mode 100644 index 000000000..3d897bdcc --- /dev/null +++ b/apps/ai-studio/src/security/turnstile.ts @@ -0,0 +1,85 @@ +import { TURNSTILE_SITE_KEY } from '../config'; + +interface TurnstileApi { + render: (element: HTMLElement, options: Record) => string; + execute: (widgetId: string, options?: Record) => void; + reset: (widgetId: string) => void; +} + +const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + +let scriptPromise: Promise | null = null; +let widgetId: string | null = null; +let container: HTMLElement | null = null; +let resolveToken: ((token: string) => void) | null = null; +let rejectToken: ((error: Error) => void) | null = null; + +function turnstileApi(): TurnstileApi | undefined { + return (globalThis as typeof globalThis & { turnstile?: TurnstileApi }).turnstile; +} + +function loadScript(): Promise { + if (scriptPromise) { + return scriptPromise; + } + scriptPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = SCRIPT_URL; + script.async = true; + script.defer = true; + script.addEventListener('load', () => resolve()); + script.addEventListener('error', () => reject(new Error('Failed to load Turnstile'))); + document.head.append(script); + }); + return scriptPromise; +} + +async function waitForApi(): Promise { + await loadScript(); + for (let attempt = 0; attempt < 50 && !turnstileApi(); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + const api = turnstileApi(); + if (!api) { + throw new Error('Turnstile is unavailable'); + } + return api; +} + +/** + * Returns a fresh Turnstile token, or undefined when no site key is configured + * (local dev). Uses one invisible widget, re-executed per run, since a token is + * single-use and short-lived. + */ +export async function getTurnstileToken(): Promise { + const siteKey = TURNSTILE_SITE_KEY; + if (!siteKey) { + return undefined; + } + + const turnstile = await waitForApi(); + + return new Promise((resolve, reject) => { + resolveToken = resolve; + rejectToken = reject; + + let id = widgetId; + if (id === null) { + container = document.createElement('div'); + container.style.display = 'none'; + document.body.append(container); + id = turnstile.render(container, { + sitekey: siteKey, + size: 'invisible', + callback: (token: string) => resolveToken?.(token), + 'error-callback': () => rejectToken?.(new Error('Turnstile error')), + 'timeout-callback': () => rejectToken?.(new Error('Turnstile timeout')), + }); + widgetId = id; + } else { + turnstile.reset(id); + } + + turnstile.execute(id, { sitekey: siteKey }); + }); +} diff --git a/apps/ai-studio/src/utils/adapt-visualization.ts b/apps/ai-studio/src/utils/adapt-visualization.ts new file mode 100644 index 000000000..1f2c2711e --- /dev/null +++ b/apps/ai-studio/src/utils/adapt-visualization.ts @@ -0,0 +1,23 @@ +import { BACKEND_URL } from '../config'; +import { getTurnstileToken } from '../security/turnstile'; + +// Ask the backend to convert an arbitrary upstream output into a specific render +// format via an LLM. Attaches a Turnstile token when configured (same gate as +// workflow execution). Returns the converted payload (clean mermaid / JSON / ...). +export async function adaptVisualization(content: string, format: string): Promise { + const token = await getTurnstileToken(); + const response = await fetch(`${BACKEND_URL}/api/visualize/adapt`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { 'cf-turnstile-token': token } : {}), + }, + body: JSON.stringify({ content, format }), + }); + if (!response.ok) { + const error = (await response.json().catch(() => ({}))) as { message?: string }; + throw new Error(error.message ?? 'Adapt failed'); + } + const data = (await response.json()) as { output: string }; + return data.output; +} diff --git a/apps/ai-studio/src/utils/detect-format.test.ts b/apps/ai-studio/src/utils/detect-format.test.ts new file mode 100644 index 000000000..ef2e3a51a --- /dev/null +++ b/apps/ai-studio/src/utils/detect-format.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { detectFormat } from './detect-format'; + +describe('detectFormat', () => { + it('returns text for empty input', () => { + expect(detectFormat('').renderer).toBe('text'); + expect(detectFormat(' ').renderer).toBe('text'); + }); + + it('falls back to markdown for prose and markdown', () => { + expect(detectFormat('Hi Marcus, sorry about the double charge.').renderer).toBe('markdown'); + expect(detectFormat('# Title\n\nSome **bold** text.').renderer).toBe('markdown'); + }); + + it('detects a mermaid diagram from a clear declaration', () => { + expect(detectFormat('flowchart TD\n A --> B').renderer).toBe('diagram'); + expect(detectFormat('sequenceDiagram\n A->>B: hi').renderer).toBe('diagram'); + }); + + it('detects a fenced mermaid block and strips the fence', () => { + const result = detectFormat('```mermaid\nflowchart TD\n A --> B\n```'); + expect(result.renderer).toBe('diagram'); + expect(result.data).toBe('flowchart TD\n A --> B'); + }); + + it('does not mis-detect prose starting with graph/pie/timeline as a diagram', () => { + expect(detectFormat('graph shows the revenue went up this quarter.').renderer).toBe('markdown'); + expect(detectFormat('pie of the market share by region, roughly even.').renderer).toBe('markdown'); + expect(detectFormat('timeline of the rollout is aggressive but doable.').renderer).toBe('markdown'); + }); + + it('detects a flat scalar object as stat-cards', () => { + const result = detectFormat('{"users": 1200, "churn": 0.03, "plan": "Pro"}'); + expect(result.renderer).toBe('stat-cards'); + expect(result.data).toEqual({ users: 1200, churn: 0.03, plan: 'Pro' }); + }); + + it('detects a nested object as json tree', () => { + expect(detectFormat('{"a": {"b": 1}}').renderer).toBe('json'); + }); + + it('detects an array of objects as a table', () => { + const result = detectFormat('[{"id": 1, "city": "NY"}, {"id": 2, "city": "LA"}]'); + expect(result.renderer).toBe('table'); + expect(result.chartable).toBe(true); // id is a numeric column + }); + + it('detects a {label,value} array as a chart', () => { + expect(detectFormat('[{"name": "A", "value": 3}, {"name": "B", "value": 5}]').renderer).toBe('chart'); + expect(detectFormat('[{"x": "Jan", "y": 10}, {"x": "Feb", "y": 20}]').renderer).toBe('chart'); + }); + + it('detects an explicit chart-spec envelope', () => { + const result = detectFormat('{"type": "bar", "data": [{"k": 1}]}'); + expect(result.renderer).toBe('chart'); + }); + + it('detects CSV as a table', () => { + const result = detectFormat('name,age\nAlice,30\nBob,25'); + expect(result.renderer).toBe('table'); + expect((result.data as Record[])[0]).toEqual({ name: 'Alice', age: '30' }); + }); + + it('does not mistake comma-prose for CSV', () => { + expect(detectFormat('Hi there,\nthanks a lot, really, for everything you did.').renderer).toBe('markdown'); + }); + + it('does not treat a bare scalar as JSON', () => { + expect(detectFormat('42').renderer).toBe('markdown'); + expect(detectFormat('"hello"').renderer).toBe('markdown'); + }); +}); diff --git a/apps/ai-studio/src/utils/detect-format.ts b/apps/ai-studio/src/utils/detect-format.ts new file mode 100644 index 000000000..5eadb37c4 --- /dev/null +++ b/apps/ai-studio/src/utils/detect-format.ts @@ -0,0 +1,157 @@ +// Heuristic format detection for the Visualize node's `auto` mode. Maps a raw +// output string to the renderer that fits it best. Conservative by design: only +// picks `chart`/`diagram` on an unambiguous signal, and falls back to `markdown` +// (react-markdown renders plain prose fine too) rather than raw `text`. `text` +// (
) is reachable via explicit override only.
+
+export type VisualizeRenderer = 'markdown' | 'text' | 'json' | 'table' | 'stat-cards' | 'chart' | 'diagram';
+
+type DetectResult = {
+  renderer: VisualizeRenderer;
+  // Parsed payload for structured formats (json/table/chart), so renderers do not re-parse.
+  data?: unknown;
+  // True when the data could also be charted (drives the "try as chart" suggestion).
+  chartable?: boolean;
+};
+
+// A fenced ```mermaid block, or a first line that is unambiguously a mermaid
+// declaration. Deliberately strict: flowchart/graph need a direction, and
+// prose-like bare words ("pie", "graph", "timeline", "journey") are NOT matched,
+// so plain prose starting with such a word is not mis-detected as a diagram.
+const MERMAID_FENCE = /^```mermaid\s*\n?([\s\S]*?)```$/;
+const MERMAID_FIRST_LINE =
+  /^(?:sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|gantt|gitGraph|mindmap|quadrantChart|requirementDiagram)\b|^(?:flowchart|graph)\s+(?:TB|TD|BT|RL|LR)\b/;
+
+const LABEL_KEYS = new Set(['label', 'name', 'category', 'x', 'key', 'date', 'month', 'day']);
+const VALUE_KEYS = new Set(['value', 'count', 'y', 'amount', 'total', 'qty', 'quantity', 'score']);
+const CHART_TYPES = new Set(['bar', 'line', 'pie', 'area', 'donut']);
+
+function isScalar(value: unknown): boolean {
+  return value === null || ['string', 'number', 'boolean'].includes(typeof value);
+}
+
+function isPlainObject(value: unknown): value is Record {
+  return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function looksLikeChartArray(array: unknown[]): boolean {
+  if (array.length === 0) {
+    return false;
+  }
+  return array.every((item) => {
+    if (!isPlainObject(item)) {
+      return false;
+    }
+    const keys = Object.keys(item).map((k) => k.toLowerCase());
+    const hasLabel = keys.some((k) => LABEL_KEYS.has(k));
+    const hasNumericValue = Object.entries(item).some(
+      ([k, v]) => VALUE_KEYS.has(k.toLowerCase()) && typeof v === 'number',
+    );
+    return hasLabel && hasNumericValue;
+  });
+}
+
+function hasNumericColumn(rows: Record[]): boolean {
+  if (rows.length === 0) {
+    return false;
+  }
+  return Object.keys(rows[0]).some((key) => rows.every((row) => typeof row[key] === 'number'));
+}
+
+function detectJson(parsed: unknown): DetectResult | null {
+  // Explicit chart spec envelope: { type: 'bar'|'line'|..., data: [...] }
+  if (
+    isPlainObject(parsed) &&
+    typeof parsed['type'] === 'string' &&
+    Array.isArray(parsed['data']) &&
+    CHART_TYPES.has(parsed['type'].toLowerCase())
+  ) {
+    return { renderer: 'chart', data: parsed };
+  }
+
+  if (Array.isArray(parsed)) {
+    if (looksLikeChartArray(parsed)) {
+      return { renderer: 'chart', data: parsed, chartable: true };
+    }
+    if (parsed.length > 0 && parsed.every(isPlainObject)) {
+      return { renderer: 'table', data: parsed, chartable: hasNumericColumn(parsed as Record[]) };
+    }
+    return {
+      renderer: 'table',
+      data: parsed,
+      chartable: parsed.length > 0 && parsed.every((v) => typeof v === 'number'),
+    };
+  }
+
+  if (isPlainObject(parsed)) {
+    return Object.values(parsed).every(isScalar)
+      ? { renderer: 'stat-cards', data: parsed }
+      : { renderer: 'json', data: parsed };
+  }
+
+  return null; // scalar JSON (number/string/bool) is not "structured"
+}
+
+function parseCsv(text: string): Record[] | null {
+  const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
+  if (lines.length < 2) {
+    return null;
+  }
+  const delimiter = lines[0].includes('\t') ? '\t' : ',';
+  const counts = lines.map((line) => line.split(delimiter).length);
+  // Strong guard against prose: every line must have the same column count (>= 2).
+  if (counts[0] < 2 || !counts.every((c) => c === counts[0])) {
+    return null;
+  }
+  const headers = lines[0].split(delimiter).map((h) => h.trim());
+  // Headers should look like headers, not sentences.
+  if (headers.some((h) => h.length === 0 || h.length > 30)) {
+    return null;
+  }
+  return lines.slice(1).map((line) => {
+    const cells = line.split(delimiter);
+    const row: Record = {};
+    for (const [index, header] of headers.entries()) {
+      row[header] = (cells[index] ?? '').trim();
+    }
+    return row;
+  });
+}
+
+export function detectFormat(input: string): DetectResult {
+  const text = (input ?? '').trim();
+  if (!text) {
+    return { renderer: 'text' };
+  }
+
+  // 1. Mermaid diagram: a fenced ```mermaid block, or a clearly-declared first line.
+  const fence = MERMAID_FENCE.exec(text);
+  if (fence) {
+    return { renderer: 'diagram', data: fence[1].trim() };
+  }
+  const firstLine = text.split(/\r?\n/)[0].trim();
+  if (MERMAID_FIRST_LINE.test(firstLine)) {
+    return { renderer: 'diagram', data: text };
+  }
+
+  // 2. JSON (gate on { or [ so bare scalars do not register as JSON).
+  if (text.startsWith('{') || text.startsWith('[')) {
+    try {
+      const result = detectJson(JSON.parse(text));
+      if (result) {
+        return result;
+      }
+    } catch {
+      // not valid JSON — fall through
+    }
+  }
+
+  // 3. CSV / TSV.
+  const csv = parseCsv(text);
+  if (csv) {
+    return { renderer: 'table', data: csv };
+  }
+
+  // 4. Fallback: markdown (handles both real markdown and plain prose well).
+  return { renderer: 'markdown' };
+}
diff --git a/apps/ai-studio/src/utils/export-visualization.ts b/apps/ai-studio/src/utils/export-visualization.ts
new file mode 100644
index 000000000..d32be3ba2
--- /dev/null
+++ b/apps/ai-studio/src/utils/export-visualization.ts
@@ -0,0 +1,62 @@
+import { toBlob, toPng, toSvg } from 'html-to-image';
+
+const PNG_OPTIONS = { pixelRatio: 2, backgroundColor: '#ffffff' };
+
+function triggerDownload(href: string, filename: string): void {
+  const link = document.createElement('a');
+  link.href = href;
+  link.download = filename;
+  link.click();
+}
+
+/** Render a DOM subtree to a PNG and download it. */
+export async function downloadPng(element: HTMLElement, filename = 'visualization.png'): Promise {
+  const dataUrl = await toPng(element, PNG_OPTIONS);
+  triggerDownload(dataUrl, filename);
+}
+
+/**
+ * Copy a DOM subtree to the clipboard as a PNG. Returns true if it reached the
+ * clipboard, false if it fell back to a download (e.g. Firefox, which cannot
+ * write image blobs to the clipboard).
+ */
+export async function copyImage(element: HTMLElement): Promise {
+  const blob = await toBlob(element, PNG_OPTIONS);
+  if (!blob) {
+    return false;
+  }
+  const canCopyImage = typeof ClipboardItem !== 'undefined' && Boolean(navigator.clipboard?.write);
+  if (canCopyImage) {
+    try {
+      await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
+      return true;
+    } catch {
+      // fall through to download
+    }
+  }
+  triggerDownload(URL.createObjectURL(blob), 'visualization.png');
+  return false;
+}
+
+/** Download a DOM subtree as SVG (serializes an inner  when present). */
+export async function downloadSvg(element: HTMLElement, filename = 'visualization.svg'): Promise {
+  const svg = element.querySelector('svg');
+  if (svg) {
+    const serialized = new XMLSerializer().serializeToString(svg);
+    const blob = new Blob([serialized], { type: 'image/svg+xml' });
+    triggerDownload(URL.createObjectURL(blob), filename);
+    return;
+  }
+  const dataUrl = await toSvg(element);
+  triggerDownload(dataUrl, filename);
+}
+
+/** Copy the raw source text to the clipboard. */
+export async function copySource(text: string): Promise {
+  try {
+    await navigator.clipboard.writeText(text);
+    return true;
+  } catch {
+    return false;
+  }
+}
diff --git a/apps/backend/.env.example b/apps/backend/.env.example
index 5e0ded9e8..7878d3013 100644
--- a/apps/backend/.env.example
+++ b/apps/backend/.env.example
@@ -11,3 +11,16 @@ HOST=127.0.0.1
 # exposing the API. Remove this line when wiring a real AuthPort. See:
 # apps/backend/auth-port.decision-log.md
 WB_AUTH_PORT=allow-all
+# Cloudflare Turnstile secret key (server-side). Leave empty to disable bot
+# verification (local dev). When set, POST /api/workflows/:id/execute requires a
+# valid Turnstile token sent by the frontend as the cf-turnstile-token header.
+TURNSTILE_SECRET_KEY=
+# Per-IP execution rate limit: at most EXECUTE_RATE_LIMIT runs per
+# EXECUTE_RATE_WINDOW_MS. Applies even without Turnstile.
+EXECUTE_RATE_LIMIT=10
+EXECUTE_RATE_WINDOW_MS=60000
+# OpenRouter key for the Visualize "AI adapt" endpoint (POST /api/visualize/adapt).
+# Optional: leave empty to disable AI adapt (the endpoint returns 501). The
+# execution worker keeps its own key for running workflows.
+OPENROUTER_API_KEY=
+AI_MODEL=google/gemini-2.5-flash-lite
diff --git a/apps/backend/package.json b/apps/backend/package.json
index fec96ac60..ea7844088 100644
--- a/apps/backend/package.json
+++ b/apps/backend/package.json
@@ -17,9 +17,11 @@
   },
   "dependencies": {
     "@hono/node-server": "^1.14.0",
+    "@openrouter/ai-sdk-provider": "^2.8.0",
     "@temporalio/client": "^1.11.0",
     "@workflow-builder/execution-core": "workspace:*",
     "@workflow-builder/types": "workspace:*",
+    "ai": "^6.0.168",
     "dotenv": "^17.4.2",
     "drizzle-orm": "^0.44.0",
     "hono": "^4.7.0",
diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts
index 12645d6d6..c79513b75 100644
--- a/apps/backend/src/env.ts
+++ b/apps/backend/src/env.ts
@@ -12,4 +12,15 @@ export const env = {
   HOST: envOr('HOST', '127.0.0.1'),
   DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'),
   TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'),
+  // Cloudflare Turnstile secret. Null = verification disabled (local dev runs
+  // unprotected). When set, workflow execution requires a valid Turnstile token.
+  TURNSTILE_SECRET_KEY: process.env['TURNSTILE_SECRET_KEY'] ?? null,
+  // Per-IP execution rate limit: at most LIMIT runs per WINDOW_MS. Applies even
+  // without Turnstile, so the public demo budget has a backstop out of the box.
+  EXECUTE_RATE_LIMIT: Number(envOr('EXECUTE_RATE_LIMIT', '10')),
+  EXECUTE_RATE_WINDOW_MS: Number(envOr('EXECUTE_RATE_WINDOW_MS', '60000')),
+  // OpenRouter for the Visualize "AI adapt" endpoint. Null = adapt disabled
+  // (the endpoint returns 501). The worker keeps its own key for execution.
+  OPENROUTER_API_KEY: process.env['OPENROUTER_API_KEY'] ?? null,
+  AI_MODEL: envOr('AI_MODEL', 'google/gemini-2.5-flash-lite'),
 };
diff --git a/apps/backend/src/routes/visualize.ts b/apps/backend/src/routes/visualize.ts
new file mode 100644
index 000000000..d5f68428d
--- /dev/null
+++ b/apps/backend/src/routes/visualize.ts
@@ -0,0 +1,85 @@
+import { createOpenRouter } from '@openrouter/ai-sdk-provider';
+import { generateText } from 'ai';
+import { Hono } from 'hono';
+import { z } from 'zod';
+
+import type { AssertAuthorized, AuthVariables } from '../auth';
+import { env } from '../env';
+import { logger as backendLogger } from '../logger';
+import { guardExecution } from '../security/execution-guard';
+import type { TenantVariables } from '../tenant';
+
+const logger = backendLogger.child({ component: 'visualize-route' });
+
+const adaptSchema = z.object({
+  content: z.string().min(1).max(20_000),
+  format: z.enum(['diagram', 'chart', 'table', 'json', 'stat-cards', 'markdown', 'text']),
+});
+
+// Reshape an automation step's output into a specific visualization format. Each
+// prompt asks for ONLY the payload (no fences, no prose) and reshapes the data to
+// fit the format using only facts present in the content.
+const FORMAT_PROMPTS: Record['format'], string> = {
+  diagram: `You reshape content into a Mermaid diagram so it can be rendered as one. Choose the diagram type that best represents the content: a flowchart (\`flowchart TD\`) for processes/steps/dependencies, a \`sequenceDiagram\` for interactions over time.
+Rules:
+- Output ONLY the raw Mermaid source. No code fences, no commentary.
+- Begin with a valid declaration and direction, e.g. \`flowchart TD\`.
+- Keep node labels short. Wrap every label that contains a space or punctuation in double quotes, e.g. \`A["Fix export bug"]\`. Never put parentheses, semicolons, colons, or unescaped quotes inside a label.
+- Aim for 4-12 nodes; connect them to show the real relationships.
+- Use only facts from the content. Do not invent steps.`,
+  chart: `You reshape content into chart data so it can be rendered as a chart. Find a categorical dimension and a numeric measure in the content.
+Rules:
+- Output ONLY JSON. No code fences, no commentary.
+- Shape: a JSON array like [{"label":"Q1","value":42}], OR {"type":"bar"|"line"|"pie"|"area","data":[{"label":...,"value":...}]}.
+- "value" must be a number. Aggregate or count where the content implies it (e.g. number of items per category).
+- Produce 2-12 data points. Use real numbers from the content; if there are none, count occurrences. If the content has nothing quantifiable, output [].`,
+  table: `You reshape content into a table. Output ONLY a JSON array of flat row objects that ALL share the same keys (the columns). Use concise column names, flatten nested values to short strings, and include one object per row. No code fences, no commentary. Use only facts from the content.`,
+  json: `Output ONLY a single JSON value (object or array) that faithfully captures the structure of the content. No code fences, no commentary.`,
+  'stat-cards': `Extract the key metrics / KPIs from the content as a flat JSON object mapping a short human label to a scalar value (string, number, or boolean), e.g. {"Open tickets":14,"Owner":"Sam"}. Use 2-8 entries, the most important first. Output ONLY JSON, no code fences, no commentary.`,
+  markdown: `Reformat the content as clean, well-structured Markdown (headings, lists, bold where it helps). Keep all the information. Output ONLY the Markdown.`,
+  text: `Return the content as clean, readable plain text. Output ONLY the text.`,
+};
+
+export function createVisualizeRoutes(
+  assertAuthorized: AssertAuthorized,
+): Hono<{ Variables: AuthVariables & TenantVariables }> {
+  const routes = new Hono<{ Variables: AuthVariables & TenantVariables }>();
+
+  // Convert an arbitrary upstream output into a specific render format via an LLM.
+  // Same abuse gate as workflow execution (per-IP rate limit + optional Turnstile).
+  routes.post('/adapt', async (c) => {
+    await assertAuthorized(c, 'workflows:execute', { kind: 'workflows' });
+
+    const blocked = await guardExecution(c);
+    if (blocked) {
+      return blocked;
+    }
+
+    if (!env.OPENROUTER_API_KEY) {
+      return c.json({ code: 'adapt_disabled', message: 'AI adapt is not configured on this server.' }, 501);
+    }
+
+    const parsed = z.safeParse(adaptSchema, await c.req.json());
+    if (!parsed.success) {
+      return c.json({ code: 'validation_error', message: 'Request body failed validation' }, 400);
+    }
+    const { content, format } = parsed.data;
+
+    try {
+      const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY });
+      const result = await generateText({
+        model: openrouter.chat(env.AI_MODEL),
+        system: FORMAT_PROMPTS[format],
+        // Low temperature for stable, well-formed structured output.
+        temperature: 0.2,
+        prompt: `Content to convert:\n\n${content}`,
+      });
+      return c.json({ output: result.text.trim() });
+    } catch (error) {
+      logger.error('adapt failed', { error: error instanceof Error ? error.message : String(error) });
+      return c.json({ code: 'adapt_failed', message: 'Could not adapt the content.' }, 502);
+    }
+  });
+
+  return routes;
+}
diff --git a/apps/backend/src/routes/workflows.ts b/apps/backend/src/routes/workflows.ts
index 20d632f81..37d3bd21c 100644
--- a/apps/backend/src/routes/workflows.ts
+++ b/apps/backend/src/routes/workflows.ts
@@ -9,6 +9,7 @@ import { mapToExecutionModel } from '../domain/mapper/from-integration-data';
 import { workflowSnapshotSchema } from '../domain/mapper/snapshot-schema';
 import { getWorkflowEngine } from '../engine';
 import { logger as backendLogger } from '../logger';
+import { guardExecution } from '../security/execution-guard';
 import type { TenantVariables } from '../tenant';
 
 const logger = backendLogger.child({ component: 'workflows-route' });
@@ -170,6 +171,13 @@ export function createWorkflowsRoutes(
 
     await assertAuthorized(c, 'workflows:execute', { kind: 'workflow', workflowId });
 
+    // Public-demo abuse control (per-IP rate limit + optional Turnstile). This
+    // is the only endpoint that spends real LLM budget, so it is the gate.
+    const blocked = await guardExecution(c);
+    if (blocked) {
+      return blocked;
+    }
+
     const parsed = z.safeParse(executeSchema, await c.req.json());
     if (!parsed.success) {
       return c.json(
diff --git a/apps/backend/src/security/execution-guard.ts b/apps/backend/src/security/execution-guard.ts
new file mode 100644
index 000000000..fdd1b9b05
--- /dev/null
+++ b/apps/backend/src/security/execution-guard.ts
@@ -0,0 +1,78 @@
+import type { Context } from 'hono';
+
+import { env } from '../env';
+import { isTurnstileEnabled, verifyTurnstileToken } from './turnstile';
+
+type Bucket = { count: number; resetAt: number };
+
+// In-memory per-IP fixed-window counter. Fine for a single-instance demo; a
+// multi-instance deployment would back this with a shared store (e.g. Redis).
+const buckets = new Map();
+const MAX_TRACKED_IPS = 10_000;
+
+function clientIp(c: Context): string {
+  const forwarded = c.req.header('x-forwarded-for');
+  return (
+    c.req.header('cf-connecting-ip') ??
+    (forwarded ? forwarded.split(',')[0].trim() : undefined) ??
+    c.req.header('x-real-ip') ??
+    'unknown'
+  );
+}
+
+function checkRateLimit(ip: string): { allowed: boolean; retryAfterSec: number } {
+  const now = Date.now();
+
+  // Cheap memory bound: a single window never holds enough distinct demo IPs
+  // for clearing to lose useful state.
+  if (buckets.size > MAX_TRACKED_IPS) {
+    buckets.clear();
+  }
+
+  const bucket = buckets.get(ip);
+  if (!bucket || bucket.resetAt <= now) {
+    buckets.set(ip, { count: 1, resetAt: now + env.EXECUTE_RATE_WINDOW_MS });
+    return { allowed: true, retryAfterSec: 0 };
+  }
+
+  bucket.count += 1;
+  if (bucket.count > env.EXECUTE_RATE_LIMIT) {
+    return { allowed: false, retryAfterSec: Math.ceil((bucket.resetAt - now) / 1000) };
+  }
+  return { allowed: true, retryAfterSec: 0 };
+}
+
+/**
+ * Abuse control for the single workflow-execution choke point: a per-IP rate
+ * limit plus an optional Cloudflare Turnstile check. Returns a Response to
+ * short-circuit the request, or null when the run may proceed. Both controls
+ * are no-ops when unconfigured, so local dev runs without any keys.
+ */
+export async function guardExecution(c: Context): Promise {
+  const ip = clientIp(c);
+
+  const rate = checkRateLimit(ip);
+  if (!rate.allowed) {
+    c.header('Retry-After', String(rate.retryAfterSec));
+    return c.json(
+      { code: 'rate_limited', message: 'Too many runs from this session. Please wait a moment and try again.' },
+      429,
+    );
+  }
+
+  if (isTurnstileEnabled()) {
+    const token = c.req.header('cf-turnstile-token');
+    if (!token) {
+      return c.json({ code: 'verification_required', message: 'Bot verification is required to run a workflow.' }, 403);
+    }
+    const ok = await verifyTurnstileToken(token, ip === 'unknown' ? undefined : ip);
+    if (!ok) {
+      return c.json(
+        { code: 'verification_failed', message: 'Bot verification failed. Please reload the page and try again.' },
+        403,
+      );
+    }
+  }
+
+  return null;
+}
diff --git a/apps/backend/src/security/turnstile.ts b/apps/backend/src/security/turnstile.ts
new file mode 100644
index 000000000..a544c0b30
--- /dev/null
+++ b/apps/backend/src/security/turnstile.ts
@@ -0,0 +1,48 @@
+import { env } from '../env';
+import { logger as backendLogger } from '../logger';
+
+const logger = backendLogger.child({ component: 'turnstile' });
+
+const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
+
+/** True when a Turnstile secret is configured; otherwise verification is skipped. */
+export function isTurnstileEnabled(): boolean {
+  return Boolean(env.TURNSTILE_SECRET_KEY);
+}
+
+/**
+ * Verify a Cloudflare Turnstile token server-side. Returns true when disabled
+ * (no secret configured). Fails closed on a verifier/network error, since this
+ * gates a public, paid LLM run.
+ */
+export async function verifyTurnstileToken(token: string, remoteIp?: string): Promise {
+  const secret = env.TURNSTILE_SECRET_KEY;
+  if (!secret) {
+    return true;
+  }
+
+  const form = new URLSearchParams();
+  form.set('secret', secret);
+  form.set('response', token);
+  if (remoteIp) {
+    form.set('remoteip', remoteIp);
+  }
+
+  try {
+    const response = await fetch(SITEVERIFY_URL, {
+      method: 'POST',
+      headers: { 'content-type': 'application/x-www-form-urlencoded' },
+      body: form,
+    });
+    const result = (await response.json()) as { success?: boolean; 'error-codes'?: string[] };
+    if (!result.success) {
+      logger.warn('turnstile verification rejected', { errorCodes: result['error-codes'] ?? [] });
+    }
+    return result.success === true;
+  } catch (error) {
+    logger.error('turnstile verification error', {
+      error: error instanceof Error ? error.message : String(error),
+    });
+    return false;
+  }
+}
diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts
index 351dc9a81..848e30b3b 100644
--- a/apps/backend/src/server.ts
+++ b/apps/backend/src/server.ts
@@ -15,6 +15,7 @@ import {
 import { env } from './env';
 import { logger } from './logger';
 import { createExecutionsRoutes } from './routes/executions';
+import { createVisualizeRoutes } from './routes/visualize';
 import { createWorkflowsRoutes } from './routes/workflows';
 import { NoopTenantContextPort, type TenantContextPort, type TenantVariables, createTenantMiddleware } from './tenant';
 
@@ -62,6 +63,7 @@ app.use('/api/*', createTenantMiddleware(tenantPort));
 
 app.route('/api/workflows', createWorkflowsRoutes(assertAuthorized));
 app.route('/api/executions', createExecutionsRoutes(assertAuthorized));
+app.route('/api/visualize', createVisualizeRoutes(assertAuthorized));
 
 serve({ fetch: app.fetch, port: env.PORT, hostname: env.HOST }, () => {
   logger.info('backend listening', { url: `http://${env.HOST}:${env.PORT}` });
diff --git a/apps/execution-worker/.env.example b/apps/execution-worker/.env.example
index 9703d99c5..4a2aaf531 100644
--- a/apps/execution-worker/.env.example
+++ b/apps/execution-worker/.env.example
@@ -3,4 +3,9 @@ TEMPORAL_ADDRESS=127.0.0.1:7233
 
 # OpenRouter — any model
 OPENROUTER_API_KEY=sk-or-...
-AI_MODEL=anthropic/claude-3.5-haiku
+AI_MODEL=google/gemini-2.5-flash-lite
+
+# Tavily web search (optional). Enables the AI Agent's "Web search" tool. Get a
+# free key at https://tavily.com (free tier ~1000 searches/month). Leave empty
+# to disable: agents with web search toggled on still run, just without the tool.
+TAVILY_API_KEY=
diff --git a/apps/execution-worker/src/activities/ai-agent.ts b/apps/execution-worker/src/activities/ai-agent.ts
index 582be8056..6b70a1992 100644
--- a/apps/execution-worker/src/activities/ai-agent.ts
+++ b/apps/execution-worker/src/activities/ai-agent.ts
@@ -1,12 +1,19 @@
-import { generateText } from 'ai';
+import { generateText, stepCountIs } from 'ai';
 
 import { type ExecutionContext, type LoggerPort, resolveTemplate } from '@workflow-builder/execution-core';
 
 import type { AiAgentNode } from '../domain/ai-studio-nodes';
+import { createWebSearchTool } from '../tools/web-search';
+
+// Cap on the agentic tool loop: enough for a search → synthesize round-trip
+// (and a retry), bounded so a misbehaving model can't run up cost.
+const MAX_TOOL_STEPS = 4;
 
 type AiAgentDeps = {
   model: Parameters[0]['model'];
   logger?: LoggerPort;
+  // Optional. When present and the node opts in, the agent gets a web-search tool.
+  tavilyApiKey?: string;
 };
 
 export async function executeAiAgent(node: AiAgentNode, context: ExecutionContext, deps: AiAgentDeps) {
@@ -36,11 +43,19 @@ export async function executeAiAgent(node: AiAgentNode, context: ExecutionContex
     userPrompt = `Here is the context from previous steps:\n\n${parts.join('\n\n')}`;
   }
 
+  // Expose the web-search tool only when the node opted in AND a key is set.
+  // Without it the agent still runs — it just answers without searching.
+  const webSearchEnabled = node.config.webSearch === true && Boolean(deps.tavilyApiKey);
+  const tools = webSearchEnabled ? { webSearch: createWebSearchTool(deps.tavilyApiKey!) } : undefined;
+
   try {
     const result = await generateText({
       model: deps.model,
       system: resolvedPrompt,
       prompt: userPrompt,
+      // The AI SDK runs the tool call/execute/continue loop internally up to
+      // this many steps; no effect when `tools` is undefined.
+      ...(tools ? { tools, stopWhen: stepCountIs(MAX_TOOL_STEPS) } : {}),
     });
 
     return { output: { response: result.text } };
diff --git a/apps/execution-worker/src/domain/ai-studio-nodes.ts b/apps/execution-worker/src/domain/ai-studio-nodes.ts
index 131fb0a73..c21e67809 100644
--- a/apps/execution-worker/src/domain/ai-studio-nodes.ts
+++ b/apps/execution-worker/src/domain/ai-studio-nodes.ts
@@ -7,6 +7,7 @@ type TriggerNodeConfig = Record;
 
 type AiAgentNodeConfig = {
   systemPrompt: string; // supports {{namespace.path}} template references
+  webSearch?: boolean; // when true (and TAVILY_API_KEY is set), expose the web-search tool
 };
 
 export type DecisionBranchCondition = {
@@ -27,6 +28,10 @@ type DecisionNodeConfig = {
   decisionBranches: DecisionBranch[];
 };
 
+// Display-only node: it renders an upstream output on the canvas. Has no
+// runtime config - the UI reads the upstream node's output directly.
+type VisualizeNodeConfig = Record;
+
 export type TriggerNode = {
   id: string;
   type: 'ai-studio/trigger';
@@ -45,4 +50,10 @@ export type DecisionNode = {
   config: DecisionNodeConfig;
 };
 
-export type AiStudioNode = TriggerNode | AiAgentNode | DecisionNode;
+type VisualizeNode = {
+  id: string;
+  type: 'ai-studio/visualize';
+  config: VisualizeNodeConfig;
+};
+
+export type AiStudioNode = TriggerNode | AiAgentNode | DecisionNode | VisualizeNode;
diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts
index 326ed7c5a..ee443ff3b 100644
--- a/apps/execution-worker/src/engines/temporal/worker.ts
+++ b/apps/execution-worker/src/engines/temporal/worker.ts
@@ -10,6 +10,7 @@ import type { AiStudioNode } from '../../domain/ai-studio-nodes';
 import { env } from '../../env';
 import { executeDecision } from '../../executors/decision';
 import { executeTrigger } from '../../executors/trigger';
+import { executeVisualize } from '../../executors/visualize';
 import { logger } from '../../logger';
 
 const { createOpenRouter } = await import('@openrouter/ai-sdk-provider');
@@ -24,7 +25,9 @@ const aiAgentLogger = logger.child({ component: 'ai-agent' });
 const nodeExecutors: NodeExecutorRegistry = {
   'ai-studio/trigger': executeTrigger,
   'ai-studio/decision': executeDecision,
-  'ai-studio/ai-agent': (node, context) => executeAiAgent(node, context, { model, logger: aiAgentLogger }),
+  'ai-studio/ai-agent': (node, context) =>
+    executeAiAgent(node, context, { model, logger: aiAgentLogger, tavilyApiKey: env.TAVILY_API_KEY }),
+  'ai-studio/visualize': executeVisualize,
 };
 
 const activities = {
diff --git a/apps/execution-worker/src/env.ts b/apps/execution-worker/src/env.ts
index 019e35447..f3cbfb074 100644
--- a/apps/execution-worker/src/env.ts
+++ b/apps/execution-worker/src/env.ts
@@ -17,5 +17,10 @@ export const env = {
   DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'),
   TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'),
   OPENROUTER_API_KEY: requireEnv('OPENROUTER_API_KEY'),
-  AI_MODEL: envOr('AI_MODEL', 'anthropic/claude-3.5-haiku'),
+  // Cheap, fast default for the public demo. Quality-per-cost is what matters
+  // here, not frontier capability — the canvas is the product, not the model.
+  AI_MODEL: envOr('AI_MODEL', 'google/gemini-2.5-flash-lite'),
+  // Optional. Enables the AI Agent's web-search tool. Without it, agents with
+  // "Web search" toggled on still run — they just answer without the tool.
+  TAVILY_API_KEY: process.env['TAVILY_API_KEY'],
 };
diff --git a/apps/execution-worker/src/executors/visualize.ts b/apps/execution-worker/src/executors/visualize.ts
new file mode 100644
index 000000000..bc5d5b536
--- /dev/null
+++ b/apps/execution-worker/src/executors/visualize.ts
@@ -0,0 +1,6 @@
+// Visualize executor — display-only node. The on-canvas card renders the
+// upstream node's output in the UI; the executor only has to complete so the
+// node lights up and the reveal animation fires. It needs no inputs.
+export function executeVisualize() {
+  return { output: { visualized: true } };
+}
diff --git a/apps/execution-worker/src/tools/web-search.ts b/apps/execution-worker/src/tools/web-search.ts
new file mode 100644
index 000000000..ab89ce11f
--- /dev/null
+++ b/apps/execution-worker/src/tools/web-search.ts
@@ -0,0 +1,65 @@
+import { jsonSchema, tool } from 'ai';
+
+// Tavily is a search API built for LLM agents: it returns a short synthesized
+// answer plus clean snippets, so we feed the model far fewer tokens than raw
+// SERP JSON would. `search_depth: 'basic'` is the cheapest tier (1 credit).
+const TAVILY_ENDPOINT = 'https://api.tavily.com/search';
+const MAX_RESULTS = 5;
+
+type TavilyResult = { title: string; url: string; content: string };
+type TavilyResponse = { answer?: string; results?: TavilyResult[] };
+
+/**
+ * Web-search tool for the AI Agent node, backed by Tavily. The agent decides
+ * when to call it; the AI SDK runs the call/execute/continue loop internally
+ * (no graph cycles, so it stays compatible with the DAG runner). Search errors
+ * are returned to the model as a soft error rather than thrown, so a flaky
+ * lookup degrades the answer instead of failing the whole node.
+ */
+export function createWebSearchTool(apiKey: string) {
+  return tool({
+    description:
+      'Search the web for current or external information. Use it when the answer needs up-to-date facts, recent events, or sources that were not provided. Returns a short answer plus the top result snippets with URLs.',
+    inputSchema: jsonSchema<{ query: string }>({
+      type: 'object',
+      properties: {
+        query: { type: 'string', description: 'The search query' },
+      },
+      required: ['query'],
+      additionalProperties: false,
+    }),
+    execute: async ({ query }) => {
+      try {
+        const response = await fetch(TAVILY_ENDPOINT, {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+            Authorization: `Bearer ${apiKey}`,
+          },
+          body: JSON.stringify({
+            query,
+            search_depth: 'basic',
+            max_results: MAX_RESULTS,
+            include_answer: true,
+          }),
+        });
+
+        if (!response.ok) {
+          return { error: `Web search failed (HTTP ${response.status}).` };
+        }
+
+        const data = (await response.json()) as TavilyResponse;
+        return {
+          answer: data.answer ?? '',
+          results: (data.results ?? []).slice(0, MAX_RESULTS).map((result) => ({
+            title: result.title,
+            url: result.url,
+            snippet: result.content,
+          })),
+        };
+      } catch (error) {
+        return { error: error instanceof Error ? error.message : 'Web search failed.' };
+      }
+    },
+  });
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2c154bab6..4e75fee33 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -106,7 +106,7 @@ importers:
         version: 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@synergycodes/overflow-ui':
         specifier: 1.0.0-beta.27
-        version: 1.0.0-beta.27(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(dayjs@1.11.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+        version: 1.0.0-beta.27(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(dayjs@1.11.21)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@workflow-builder/types':
         specifier: workspace:*
         version: link:../../packages/types
@@ -116,15 +116,30 @@ importers:
       clsx:
         specifier: ^2.1.1
         version: 2.1.1
+      html-to-image:
+        specifier: 1.11.11
+        version: 1.11.11
       immer:
         specifier: ^10.1.1
         version: 10.1.1
+      mermaid:
+        specifier: ^11.15.0
+        version: 11.15.0
       react:
         specifier: ^19.1.0
         version: 19.1.0
       react-dom:
         specifier: 'catalog:'
         version: 19.1.0(react@19.1.0)
+      react-markdown:
+        specifier: ^10.1.0
+        version: 10.1.0(@types/react@19.1.8)(react@19.1.0)
+      recharts:
+        specifier: ^3.9.0
+        version: 3.9.0(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react-is@19.0.0)(react@19.1.0)(redux@5.0.1)
+      remark-gfm:
+        specifier: ^4.0.1
+        version: 4.0.1
       zustand:
         specifier: ^5.0.1
         version: 5.0.3(@types/react@19.1.8)(immer@10.1.1)(react@19.1.0)(use-sync-external-store@1.4.0(react@19.1.0))
@@ -153,6 +168,9 @@ importers:
       '@hono/node-server':
         specifier: ^1.14.0
         version: 1.19.14(hono@4.12.14)
+      '@openrouter/ai-sdk-provider':
+        specifier: ^2.8.0
+        version: 2.8.0(ai@6.0.168(zod@4.3.6))(zod@4.3.6)
       '@temporalio/client':
         specifier: ^1.11.0
         version: 1.16.0
@@ -162,6 +180,9 @@ importers:
       '@workflow-builder/types':
         specifier: workspace:*
         version: link:../../packages/types
+      ai:
+        specifier: ^6.0.168
+        version: 6.0.168(zod@4.3.6)
       dotenv:
         specifier: ^17.4.2
         version: 17.4.2
@@ -204,7 +225,7 @@ importers:
         version: 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@synergycodes/overflow-ui':
         specifier: 1.0.0-beta.27
-        version: 1.0.0-beta.27(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(dayjs@1.11.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+        version: 1.0.0-beta.27(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(dayjs@1.11.21)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@xyflow/react':
         specifier: 'catalog:'
         version: 12.10.0(@types/react@19.1.8)(immer@10.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -434,7 +455,7 @@ importers:
         version: 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@synergycodes/overflow-ui':
         specifier: 1.0.0-beta.27
-        version: 1.0.0-beta.27(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(dayjs@1.11.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+        version: 1.0.0-beta.27(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(dayjs@1.11.21)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@xyflow/react':
         specifier: ^12.0.0
         version: 12.10.0(@types/react@19.1.8)(immer@10.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -717,6 +738,9 @@ packages:
     resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
     engines: {node: '>=6.9.0'}
 
+  '@braintree/sanitize-url@7.1.2':
+    resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
+
   '@capsizecss/unpack@4.0.0':
     resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==}
     engines: {node: '>=18'}
@@ -779,6 +803,9 @@ packages:
   '@changesets/write@0.4.0':
     resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==}
 
+  '@chevrotain/types@11.1.2':
+    resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
+
   '@commitlint/cli@21.0.1':
     resolution: {integrity: sha512-8vq10krmbJwBkvzXKhbs4o4JQEVscd3pqOlWuDUaDBwbeL694/P33UC29tZQFTAgPU9fVJ2+f2m3zw16yKWxHg==}
     engines: {node: '>=22.12.0'}
@@ -1568,6 +1595,9 @@ packages:
   '@iconify/utils@2.3.0':
     resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
 
+  '@iconify/utils@3.1.3':
+    resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==}
+
   '@img/colour@1.0.0':
     resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
     engines: {node: '>=18'}
@@ -1904,6 +1934,9 @@ packages:
   '@mdx-js/mdx@3.1.1':
     resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
 
+  '@mermaid-js/parser@1.1.1':
+    resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==}
+
   '@microsoft/api-extractor-model@7.33.8':
     resolution: {integrity: sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==}
 
@@ -2192,6 +2225,17 @@ packages:
   '@protobufjs/utf8@1.1.0':
     resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
 
+  '@reduxjs/toolkit@2.12.0':
+    resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
+    peerDependencies:
+      react: ^16.9.0 || ^17.0.0 || ^18 || ^19
+      react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
+    peerDependenciesMeta:
+      react:
+        optional: true
+      react-redux:
+        optional: true
+
   '@rollup/pluginutils@5.3.0':
     resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
     engines: {node: '>=14.0.0'}
@@ -2400,6 +2444,9 @@ packages:
   '@standard-schema/spec@1.1.0':
     resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
 
+  '@standard-schema/utils@0.3.0':
+    resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
+
   '@svgr/babel-plugin-add-jsx-attribute@8.0.0':
     resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
     engines: {node: '>=14'}
@@ -2658,24 +2705,99 @@ packages:
   '@types/connect@3.4.38':
     resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
 
+  '@types/d3-array@3.2.2':
+    resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+  '@types/d3-axis@3.0.6':
+    resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
+
+  '@types/d3-brush@3.0.6':
+    resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
+
+  '@types/d3-chord@3.0.6':
+    resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
+
   '@types/d3-color@3.1.3':
     resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
 
+  '@types/d3-contour@3.0.6':
+    resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
+
+  '@types/d3-delaunay@6.0.4':
+    resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
+
+  '@types/d3-dispatch@3.0.7':
+    resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==}
+
   '@types/d3-drag@3.0.7':
     resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
 
+  '@types/d3-dsv@3.0.7':
+    resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
+
+  '@types/d3-ease@3.0.2':
+    resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+  '@types/d3-fetch@3.0.7':
+    resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
+
+  '@types/d3-force@3.0.10':
+    resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
+
+  '@types/d3-format@3.0.4':
+    resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
+
+  '@types/d3-geo@3.1.0':
+    resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==}
+
+  '@types/d3-hierarchy@3.1.7':
+    resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
+
   '@types/d3-interpolate@3.0.4':
     resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
 
+  '@types/d3-path@3.1.1':
+    resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+  '@types/d3-polygon@3.0.2':
+    resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
+
+  '@types/d3-quadtree@3.0.6':
+    resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
+
+  '@types/d3-random@3.0.3':
+    resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==}
+
+  '@types/d3-scale-chromatic@3.1.0':
+    resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
+
+  '@types/d3-scale@4.0.9':
+    resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
   '@types/d3-selection@3.0.11':
     resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
 
+  '@types/d3-shape@3.1.8':
+    resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
+
+  '@types/d3-time-format@4.0.3':
+    resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
+
+  '@types/d3-time@3.0.4':
+    resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+  '@types/d3-timer@3.0.2':
+    resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
   '@types/d3-transition@3.0.9':
     resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
 
   '@types/d3-zoom@3.0.8':
     resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
 
+  '@types/d3@7.4.3':
+    resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
+
   '@types/debug@4.1.12':
     resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
 
@@ -2700,6 +2822,9 @@ packages:
   '@types/express@5.0.5':
     resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==}
 
+  '@types/geojson@7946.0.16':
+    resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
   '@types/hast@3.0.4':
     resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
 
@@ -2791,6 +2916,9 @@ packages:
   '@types/unist@3.0.3':
     resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
 
+  '@types/use-sync-external-store@0.0.6':
+    resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
+
   '@types/validator@13.15.4':
     resolution: {integrity: sha512-LSFfpSnJJY9wbC0LQxgvfb+ynbHftFo0tMsFOl/J4wexLnYMmDSPaj2ZyDv3TkfL1UePxPrxOWJfbiRS8mQv7A==}
 
@@ -2848,6 +2976,9 @@ packages:
     resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
     deprecated: Potential CWE-502 - Update to 1.3.1 or higher
 
+  '@upsetjs/venn.js@2.0.0':
+    resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
+
   '@vercel/oidc@3.2.0':
     resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
     engines: {node: '>= 20'}
@@ -3512,6 +3643,10 @@ packages:
     resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
     engines: {node: '>= 10'}
 
+  commander@8.3.0:
+    resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+    engines: {node: '>= 12'}
+
   common-ancestor-path@1.0.1:
     resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==}
 
@@ -3583,6 +3718,12 @@ packages:
   core-js@3.46.0:
     resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==}
 
+  cose-base@1.0.3:
+    resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
+
+  cose-base@2.2.0:
+    resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
+
   cosmiconfig-typescript-loader@6.3.0:
     resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==}
     engines: {node: '>=v18'}
@@ -3664,10 +3805,51 @@ packages:
   csstype@3.1.3:
     resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
 
+  cytoscape-cose-bilkent@4.1.0:
+    resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
+    peerDependencies:
+      cytoscape: ^3.2.0
+
+  cytoscape-fcose@2.2.0:
+    resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==}
+    peerDependencies:
+      cytoscape: ^3.2.0
+
+  cytoscape@3.34.0:
+    resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==}
+    engines: {node: '>=0.10'}
+
+  d3-array@2.12.1:
+    resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==}
+
+  d3-array@3.2.4:
+    resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+    engines: {node: '>=12'}
+
+  d3-axis@3.0.0:
+    resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==}
+    engines: {node: '>=12'}
+
+  d3-brush@3.0.0:
+    resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==}
+    engines: {node: '>=12'}
+
+  d3-chord@3.0.1:
+    resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==}
+    engines: {node: '>=12'}
+
   d3-color@3.1.0:
     resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
     engines: {node: '>=12'}
 
+  d3-contour@4.0.2:
+    resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==}
+    engines: {node: '>=12'}
+
+  d3-delaunay@6.0.4:
+    resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
+    engines: {node: '>=12'}
+
   d3-dispatch@3.0.1:
     resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
     engines: {node: '>=12'}
@@ -3676,18 +3858,88 @@ packages:
     resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
     engines: {node: '>=12'}
 
+  d3-dsv@3.0.1:
+    resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==}
+    engines: {node: '>=12'}
+    hasBin: true
+
   d3-ease@3.0.1:
     resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
     engines: {node: '>=12'}
 
+  d3-fetch@3.0.1:
+    resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==}
+    engines: {node: '>=12'}
+
+  d3-force@3.0.0:
+    resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
+    engines: {node: '>=12'}
+
+  d3-format@3.1.2:
+    resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+    engines: {node: '>=12'}
+
+  d3-geo@3.1.1:
+    resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
+    engines: {node: '>=12'}
+
+  d3-hierarchy@3.1.2:
+    resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
+    engines: {node: '>=12'}
+
   d3-interpolate@3.0.1:
     resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
     engines: {node: '>=12'}
 
+  d3-path@1.0.9:
+    resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==}
+
+  d3-path@3.1.0:
+    resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+    engines: {node: '>=12'}
+
+  d3-polygon@3.0.1:
+    resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==}
+    engines: {node: '>=12'}
+
+  d3-quadtree@3.0.1:
+    resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
+    engines: {node: '>=12'}
+
+  d3-random@3.0.1:
+    resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
+    engines: {node: '>=12'}
+
+  d3-sankey@0.12.3:
+    resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==}
+
+  d3-scale-chromatic@3.1.0:
+    resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
+    engines: {node: '>=12'}
+
+  d3-scale@4.0.2:
+    resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+    engines: {node: '>=12'}
+
   d3-selection@3.0.0:
     resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
     engines: {node: '>=12'}
 
+  d3-shape@1.3.7:
+    resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==}
+
+  d3-shape@3.2.0:
+    resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+    engines: {node: '>=12'}
+
+  d3-time-format@4.1.0:
+    resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+    engines: {node: '>=12'}
+
+  d3-time@3.1.0:
+    resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+    engines: {node: '>=12'}
+
   d3-timer@3.0.1:
     resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
     engines: {node: '>=12'}
@@ -3702,6 +3954,13 @@ packages:
     resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
     engines: {node: '>=12'}
 
+  d3@7.9.0:
+    resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
+    engines: {node: '>=12'}
+
+  dagre-d3-es@7.0.14:
+    resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==}
+
   data-urls@5.0.0:
     resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
     engines: {node: '>=18'}
@@ -3721,8 +3980,8 @@ packages:
   date-fns@4.1.0:
     resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
 
-  dayjs@1.11.13:
-    resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
+  dayjs@1.11.21:
+    resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
 
   de-indent@1.0.2:
     resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
@@ -3745,6 +4004,9 @@ packages:
       supports-color:
         optional: true
 
+  decimal.js-light@2.5.1:
+    resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+
   decimal.js@10.5.0:
     resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==}
 
@@ -3769,6 +4031,9 @@ packages:
   defu@6.1.4:
     resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
 
+  delaunator@5.1.0:
+    resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
+
   delayed-stream@1.0.0:
     resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
     engines: {node: '>=0.4.0'}
@@ -3846,6 +4111,9 @@ packages:
   dompurify@3.3.0:
     resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
 
+  dompurify@3.4.11:
+    resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
+
   domutils@3.2.2:
     resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
 
@@ -4514,6 +4782,9 @@ packages:
   h3@1.15.5:
     resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==}
 
+  hachure-fill@0.5.2:
+    resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
+
   has-bigints@1.1.0:
     resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
     engines: {node: '>= 0.4'}
@@ -4629,6 +4900,9 @@ packages:
   html-to-image@1.11.11:
     resolution: {integrity: sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==}
 
+  html-url-attributes@3.0.1:
+    resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
+
   html-void-elements@3.0.0:
     resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
 
@@ -4699,6 +4973,9 @@ packages:
   immer@10.1.1:
     resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
 
+  immer@11.1.8:
+    resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
+
   import-fresh@3.3.0:
     resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
     engines: {node: '>=6'}
@@ -4732,6 +5009,13 @@ packages:
     resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
     engines: {node: '>= 0.4'}
 
+  internmap@1.0.1:
+    resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
+
+  internmap@2.0.3:
+    resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+    engines: {node: '>=12'}
+
   inversify@6.0.1:
     resolution: {integrity: sha512-B3ex30927698TJENHR++8FfEaJGqoWOgI6ZY5Ht/nLUsFCwHn6akbwtnUAPCgUepAnTpe2qHxhDNjoKLyz6rgQ==}
 
@@ -5036,9 +5320,16 @@ packages:
     resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
     engines: {node: '>=4.0'}
 
+  katex@0.16.47:
+    resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
+    hasBin: true
+
   keyv@4.5.4:
     resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
 
+  khroma@2.1.0:
+    resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
+
   kleur@3.0.3:
     resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
     engines: {node: '>=6'}
@@ -5062,6 +5353,12 @@ packages:
   kolorist@1.8.0:
     resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
 
+  layout-base@1.0.2:
+    resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==}
+
+  layout-base@2.0.1:
+    resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==}
+
   levn@0.3.0:
     resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==}
     engines: {node: '>= 0.8.0'}
@@ -5193,6 +5490,11 @@ packages:
     engines: {node: '>= 18'}
     hasBin: true
 
+  marked@16.4.2:
+    resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
+    engines: {node: '>= 20'}
+    hasBin: true
+
   math-intrinsics@1.1.0:
     resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
     engines: {node: '>= 0.4'}
@@ -5290,6 +5592,9 @@ packages:
     resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
     engines: {node: '>= 8'}
 
+  mermaid@11.15.0:
+    resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==}
+
   micromark-core-commonmark@2.0.3:
     resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
 
@@ -5710,6 +6015,9 @@ packages:
   path-browserify@1.0.1:
     resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
 
+  path-data-parser@0.1.0:
+    resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==}
+
   path-exists@4.0.0:
     resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
     engines: {node: '>=8'}
@@ -5778,6 +6086,12 @@ packages:
     resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
     engines: {node: '>=4'}
 
+  points-on-curve@0.2.0:
+    resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
+
+  points-on-path@0.2.1:
+    resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
+
   possible-typed-array-names@1.0.0:
     resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
     engines: {node: '>= 0.4'}
@@ -5928,6 +6242,12 @@ packages:
   react-is@19.0.0:
     resolution: {integrity: sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==}
 
+  react-markdown@10.1.0:
+    resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
+    peerDependencies:
+      '@types/react': '>=18'
+      react: '>=18'
+
   react-mentions-ts@5.4.7:
     resolution: {integrity: sha512-bTK6joPmyvLckVf1v7vE2xSSeqvL4ZwuzFvGZpt+IrtdDOdFZjiwTUBo5920kiE6WbH/v1PP81xAo6pQ0NQ0Pg==}
     engines: {node: '>=20'}
@@ -5944,6 +6264,18 @@ packages:
       react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
       react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
 
+  react-redux@9.3.0:
+    resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
+    peerDependencies:
+      '@types/react': ^18.2.25 || ^19
+      react: ^18.0 || ^19
+      redux: ^5.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      redux:
+        optional: true
+
   react-refresh@0.14.2:
     resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
     engines: {node: '>=0.10.0'}
@@ -6006,6 +6338,14 @@ packages:
     resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
     engines: {node: '>= 20.19.0'}
 
+  recharts@3.9.0:
+    resolution: {integrity: sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+      react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+      react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
   recma-build-jsx@1.0.0:
     resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
 
@@ -6020,6 +6360,14 @@ packages:
   recma-stringify@1.0.0:
     resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
 
+  redux-thunk@3.1.0:
+    resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
+    peerDependencies:
+      redux: ^5.0.0
+
+  redux@5.0.1:
+    resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
+
   reflect-metadata@0.1.13:
     resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
 
@@ -6117,6 +6465,9 @@ packages:
     resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
     engines: {node: '>=0.10.0'}
 
+  reselect@5.2.0:
+    resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
+
   resolve-from@4.0.0:
     resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
     engines: {node: '>=4'}
@@ -6164,11 +6515,17 @@ packages:
     resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
     engines: {node: '>= 0.8.15'}
 
+  robust-predicates@3.0.3:
+    resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
+
   rollup@4.57.1:
     resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==}
     engines: {node: '>=18.0.0', npm: '>=8.0.0'}
     hasBin: true
 
+  roughjs@4.6.6:
+    resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
+
   router@2.2.0:
     resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
     engines: {node: '>= 18'}
@@ -6179,6 +6536,9 @@ packages:
   run-parallel@1.2.0:
     resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
 
+  rw@1.3.3:
+    resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
+
   rxjs@7.8.1:
     resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
 
@@ -6477,6 +6837,9 @@ packages:
   stylis@4.2.0:
     resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
 
+  stylis@4.4.0:
+    resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
+
   suf-log@2.5.3:
     resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==}
 
@@ -6573,6 +6936,9 @@ packages:
   tiny-inflate@1.0.3:
     resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
 
+  tiny-invariant@1.3.3:
+    resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
   tinybench@2.9.0:
     resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
 
@@ -6644,6 +7010,10 @@ packages:
     peerDependencies:
       typescript: '>=4.8.4'
 
+  ts-dedent@2.3.0:
+    resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==}
+    engines: {node: '>=6.10'}
+
   tsconfck@3.1.6:
     resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
     engines: {node: ^18 || >=20}
@@ -6960,6 +7330,9 @@ packages:
   vfile@6.0.3:
     resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
 
+  victory-vendor@37.3.6:
+    resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
+
   vite-bundle-analyzer@0.17.1:
     resolution: {integrity: sha512-ubjLhkuRgOSBNck+6xBbQmjmh8SeLTG4alEM5PX2TNzyGhKLwWlyCz1YG0an3RQnscbhVzSb6kYteoHXhP///A==}
     hasBin: true
@@ -7717,6 +8090,8 @@ snapshots:
       '@babel/helper-string-parser': 7.27.1
       '@babel/helper-validator-identifier': 7.28.5
 
+  '@braintree/sanitize-url@7.1.2': {}
+
   '@capsizecss/unpack@4.0.0':
     dependencies:
       fontkitten: 1.0.2
@@ -7866,6 +8241,8 @@ snapshots:
       human-id: 4.1.3
       prettier: 2.8.8
 
+  '@chevrotain/types@11.1.2': {}
+
   '@commitlint/cli@21.0.1(@types/node@22.12.0)(conventional-commits-parser@6.4.0)(typescript@5.6.3)':
     dependencies:
       '@commitlint/format': 21.0.1
@@ -8543,6 +8920,12 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  '@iconify/utils@3.1.3':
+    dependencies:
+      '@antfu/install-pkg': 1.1.0
+      '@iconify/types': 2.0.0
+      import-meta-resolve: 4.2.0
+
   '@img/colour@1.0.0':
     optional: true
 
@@ -8837,12 +9220,12 @@ snapshots:
     transitivePeerDependencies:
       - '@types/react'
 
-  '@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(dayjs@1.11.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+  '@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(dayjs@1.11.21)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
     dependencies:
       '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@mantine/hooks': 7.17.8(react@19.1.0)
       clsx: 2.1.1
-      dayjs: 1.11.13
+      dayjs: 1.11.21
       react: 19.1.0
       react-dom: 19.1.0(react@19.1.0)
 
@@ -8896,6 +9279,10 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  '@mermaid-js/parser@1.1.1':
+    dependencies:
+      '@chevrotain/types': 11.1.2
+
   '@microsoft/api-extractor-model@7.33.8(@types/node@22.12.0)':
     dependencies:
       '@microsoft/tsdoc': 0.16.0
@@ -9156,6 +9543,18 @@ snapshots:
 
   '@protobufjs/utf8@1.1.0': {}
 
+  '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.1.8)(react@19.1.0)(redux@5.0.1))(react@19.1.0)':
+    dependencies:
+      '@standard-schema/spec': 1.1.0
+      '@standard-schema/utils': 0.3.0
+      immer: 11.1.8
+      redux: 5.0.1
+      redux-thunk: 3.1.0(redux@5.0.1)
+      reselect: 5.2.0
+    optionalDependencies:
+      react: 19.1.0
+      react-redux: 9.3.0(@types/react@19.1.8)(react@19.1.0)(redux@5.0.1)
+
   '@rollup/pluginutils@5.3.0(rollup@4.57.1)':
     dependencies:
       '@types/estree': 1.0.8
@@ -9337,6 +9736,8 @@ snapshots:
 
   '@standard-schema/spec@1.1.0': {}
 
+  '@standard-schema/utils@0.3.0': {}
+
   '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.26.7)':
     dependencies:
       '@babel/core': 7.26.7
@@ -9488,12 +9889,12 @@ snapshots:
     dependencies:
       '@swc/counter': 0.1.3
 
-  '@synergycodes/overflow-ui@1.0.0-beta.27(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(dayjs@1.11.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+  '@synergycodes/overflow-ui@1.0.0-beta.27(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(dayjs@1.11.21)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
     dependencies:
       '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react@19.1.0)
       '@floating-ui/react': 0.26.28(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
-      '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(dayjs@1.11.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(dayjs@1.11.21)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@mui/base': 5.0.0-beta.62(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@mui/material': 6.5.0(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@phosphor-icons/react': 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -9660,18 +10061,81 @@ snapshots:
     dependencies:
       '@types/node': 22.12.0
 
+  '@types/d3-array@3.2.2': {}
+
+  '@types/d3-axis@3.0.6':
+    dependencies:
+      '@types/d3-selection': 3.0.11
+
+  '@types/d3-brush@3.0.6':
+    dependencies:
+      '@types/d3-selection': 3.0.11
+
+  '@types/d3-chord@3.0.6': {}
+
   '@types/d3-color@3.1.3': {}
 
+  '@types/d3-contour@3.0.6':
+    dependencies:
+      '@types/d3-array': 3.2.2
+      '@types/geojson': 7946.0.16
+
+  '@types/d3-delaunay@6.0.4': {}
+
+  '@types/d3-dispatch@3.0.7': {}
+
   '@types/d3-drag@3.0.7':
     dependencies:
       '@types/d3-selection': 3.0.11
 
+  '@types/d3-dsv@3.0.7': {}
+
+  '@types/d3-ease@3.0.2': {}
+
+  '@types/d3-fetch@3.0.7':
+    dependencies:
+      '@types/d3-dsv': 3.0.7
+
+  '@types/d3-force@3.0.10': {}
+
+  '@types/d3-format@3.0.4': {}
+
+  '@types/d3-geo@3.1.0':
+    dependencies:
+      '@types/geojson': 7946.0.16
+
+  '@types/d3-hierarchy@3.1.7': {}
+
   '@types/d3-interpolate@3.0.4':
     dependencies:
       '@types/d3-color': 3.1.3
 
+  '@types/d3-path@3.1.1': {}
+
+  '@types/d3-polygon@3.0.2': {}
+
+  '@types/d3-quadtree@3.0.6': {}
+
+  '@types/d3-random@3.0.3': {}
+
+  '@types/d3-scale-chromatic@3.1.0': {}
+
+  '@types/d3-scale@4.0.9':
+    dependencies:
+      '@types/d3-time': 3.0.4
+
   '@types/d3-selection@3.0.11': {}
 
+  '@types/d3-shape@3.1.8':
+    dependencies:
+      '@types/d3-path': 3.1.1
+
+  '@types/d3-time-format@4.0.3': {}
+
+  '@types/d3-time@3.0.4': {}
+
+  '@types/d3-timer@3.0.2': {}
+
   '@types/d3-transition@3.0.9':
     dependencies:
       '@types/d3-selection': 3.0.11
@@ -9681,6 +10145,39 @@ snapshots:
       '@types/d3-interpolate': 3.0.4
       '@types/d3-selection': 3.0.11
 
+  '@types/d3@7.4.3':
+    dependencies:
+      '@types/d3-array': 3.2.2
+      '@types/d3-axis': 3.0.6
+      '@types/d3-brush': 3.0.6
+      '@types/d3-chord': 3.0.6
+      '@types/d3-color': 3.1.3
+      '@types/d3-contour': 3.0.6
+      '@types/d3-delaunay': 6.0.4
+      '@types/d3-dispatch': 3.0.7
+      '@types/d3-drag': 3.0.7
+      '@types/d3-dsv': 3.0.7
+      '@types/d3-ease': 3.0.2
+      '@types/d3-fetch': 3.0.7
+      '@types/d3-force': 3.0.10
+      '@types/d3-format': 3.0.4
+      '@types/d3-geo': 3.1.0
+      '@types/d3-hierarchy': 3.1.7
+      '@types/d3-interpolate': 3.0.4
+      '@types/d3-path': 3.1.1
+      '@types/d3-polygon': 3.0.2
+      '@types/d3-quadtree': 3.0.6
+      '@types/d3-random': 3.0.3
+      '@types/d3-scale': 4.0.9
+      '@types/d3-scale-chromatic': 3.1.0
+      '@types/d3-selection': 3.0.11
+      '@types/d3-shape': 3.1.8
+      '@types/d3-time': 3.0.4
+      '@types/d3-time-format': 4.0.3
+      '@types/d3-timer': 3.0.2
+      '@types/d3-transition': 3.0.9
+      '@types/d3-zoom': 3.0.8
+
   '@types/debug@4.1.12':
     dependencies:
       '@types/ms': 2.1.0
@@ -9716,6 +10213,8 @@ snapshots:
       '@types/express-serve-static-core': 5.1.0
       '@types/serve-static': 1.15.10
 
+  '@types/geojson@7946.0.16': {}
+
   '@types/hast@3.0.4':
     dependencies:
       '@types/unist': 3.0.3
@@ -9801,6 +10300,8 @@ snapshots:
 
   '@types/unist@3.0.3': {}
 
+  '@types/use-sync-external-store@0.0.6': {}
+
   '@types/validator@13.15.4': {}
 
   '@types/yauzl@2.10.3':
@@ -9887,6 +10388,11 @@ snapshots:
 
   '@ungap/structured-clone@1.3.0': {}
 
+  '@upsetjs/venn.js@2.0.0':
+    optionalDependencies:
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+
   '@vercel/oidc@3.2.0': {}
 
   '@vitejs/plugin-react@4.3.4(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4))':
@@ -10755,6 +11261,8 @@ snapshots:
 
   commander@7.2.0: {}
 
+  commander@8.3.0: {}
+
   common-ancestor-path@1.0.1: {}
 
   compare-func@2.0.0:
@@ -10818,6 +11326,14 @@ snapshots:
   core-js@3.46.0:
     optional: true
 
+  cose-base@1.0.3:
+    dependencies:
+      layout-base: 1.0.2
+
+  cose-base@2.2.0:
+    dependencies:
+      layout-base: 2.0.1
+
   cosmiconfig-typescript-loader@6.3.0(@types/node@22.12.0)(cosmiconfig@9.0.1(typescript@5.6.3))(typescript@5.6.3):
     dependencies:
       '@types/node': 22.12.0
@@ -10917,8 +11433,50 @@ snapshots:
 
   csstype@3.1.3: {}
 
+  cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0):
+    dependencies:
+      cose-base: 1.0.3
+      cytoscape: 3.34.0
+
+  cytoscape-fcose@2.2.0(cytoscape@3.34.0):
+    dependencies:
+      cose-base: 2.2.0
+      cytoscape: 3.34.0
+
+  cytoscape@3.34.0: {}
+
+  d3-array@2.12.1:
+    dependencies:
+      internmap: 1.0.1
+
+  d3-array@3.2.4:
+    dependencies:
+      internmap: 2.0.3
+
+  d3-axis@3.0.0: {}
+
+  d3-brush@3.0.0:
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+
+  d3-chord@3.0.1:
+    dependencies:
+      d3-path: 3.1.0
+
   d3-color@3.1.0: {}
 
+  d3-contour@4.0.2:
+    dependencies:
+      d3-array: 3.2.4
+
+  d3-delaunay@6.0.4:
+    dependencies:
+      delaunator: 5.1.0
+
   d3-dispatch@3.0.1: {}
 
   d3-drag@3.0.0:
@@ -10926,14 +11484,82 @@ snapshots:
       d3-dispatch: 3.0.1
       d3-selection: 3.0.0
 
+  d3-dsv@3.0.1:
+    dependencies:
+      commander: 7.2.0
+      iconv-lite: 0.6.3
+      rw: 1.3.3
+
   d3-ease@3.0.1: {}
 
+  d3-fetch@3.0.1:
+    dependencies:
+      d3-dsv: 3.0.1
+
+  d3-force@3.0.0:
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-quadtree: 3.0.1
+      d3-timer: 3.0.1
+
+  d3-format@3.1.2: {}
+
+  d3-geo@3.1.1:
+    dependencies:
+      d3-array: 3.2.4
+
+  d3-hierarchy@3.1.2: {}
+
   d3-interpolate@3.0.1:
     dependencies:
       d3-color: 3.1.0
 
+  d3-path@1.0.9: {}
+
+  d3-path@3.1.0: {}
+
+  d3-polygon@3.0.1: {}
+
+  d3-quadtree@3.0.1: {}
+
+  d3-random@3.0.1: {}
+
+  d3-sankey@0.12.3:
+    dependencies:
+      d3-array: 2.12.1
+      d3-shape: 1.3.7
+
+  d3-scale-chromatic@3.1.0:
+    dependencies:
+      d3-color: 3.1.0
+      d3-interpolate: 3.0.1
+
+  d3-scale@4.0.2:
+    dependencies:
+      d3-array: 3.2.4
+      d3-format: 3.1.2
+      d3-interpolate: 3.0.1
+      d3-time: 3.1.0
+      d3-time-format: 4.1.0
+
   d3-selection@3.0.0: {}
 
+  d3-shape@1.3.7:
+    dependencies:
+      d3-path: 1.0.9
+
+  d3-shape@3.2.0:
+    dependencies:
+      d3-path: 3.1.0
+
+  d3-time-format@4.1.0:
+    dependencies:
+      d3-time: 3.1.0
+
+  d3-time@3.1.0:
+    dependencies:
+      d3-array: 3.2.4
+
   d3-timer@3.0.1: {}
 
   d3-transition@3.0.1(d3-selection@3.0.0):
@@ -10953,6 +11579,44 @@ snapshots:
       d3-selection: 3.0.0
       d3-transition: 3.0.1(d3-selection@3.0.0)
 
+  d3@7.9.0:
+    dependencies:
+      d3-array: 3.2.4
+      d3-axis: 3.0.0
+      d3-brush: 3.0.0
+      d3-chord: 3.0.1
+      d3-color: 3.1.0
+      d3-contour: 4.0.2
+      d3-delaunay: 6.0.4
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-dsv: 3.0.1
+      d3-ease: 3.0.1
+      d3-fetch: 3.0.1
+      d3-force: 3.0.0
+      d3-format: 3.1.2
+      d3-geo: 3.1.1
+      d3-hierarchy: 3.1.2
+      d3-interpolate: 3.0.1
+      d3-path: 3.1.0
+      d3-polygon: 3.0.1
+      d3-quadtree: 3.0.1
+      d3-random: 3.0.1
+      d3-scale: 4.0.2
+      d3-scale-chromatic: 3.1.0
+      d3-selection: 3.0.0
+      d3-shape: 3.2.0
+      d3-time: 3.1.0
+      d3-time-format: 4.1.0
+      d3-timer: 3.0.1
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+      d3-zoom: 3.0.0
+
+  dagre-d3-es@7.0.14:
+    dependencies:
+      d3: 7.9.0
+      lodash-es: 4.17.21
+
   data-urls@5.0.0:
     dependencies:
       whatwg-mimetype: 4.0.0
@@ -10978,7 +11642,7 @@ snapshots:
 
   date-fns@4.1.0: {}
 
-  dayjs@1.11.13: {}
+  dayjs@1.11.21: {}
 
   de-indent@1.0.2: {}
 
@@ -10990,6 +11654,8 @@ snapshots:
     dependencies:
       ms: 2.1.3
 
+  decimal.js-light@2.5.1: {}
+
   decimal.js@10.5.0: {}
 
   decode-named-character-reference@1.3.0:
@@ -11014,6 +11680,10 @@ snapshots:
 
   defu@6.1.4: {}
 
+  delaunator@5.1.0:
+    dependencies:
+      robust-predicates: 3.0.3
+
   delayed-stream@1.0.0: {}
 
   depd@2.0.0: {}
@@ -11079,6 +11749,10 @@ snapshots:
       '@types/trusted-types': 2.0.7
     optional: true
 
+  dompurify@3.4.11:
+    optionalDependencies:
+      '@types/trusted-types': 2.0.7
+
   domutils@3.2.2:
     dependencies:
       dom-serializer: 2.0.0
@@ -11894,6 +12568,8 @@ snapshots:
       ufo: 1.6.3
       uncrypto: 0.1.3
 
+  hachure-fill@0.5.2: {}
+
   has-bigints@1.1.0: {}
 
   has-flag@4.0.0: {}
@@ -12127,6 +12803,8 @@ snapshots:
 
   html-to-image@1.11.11: {}
 
+  html-url-attributes@3.0.1: {}
+
   html-void-elements@3.0.0: {}
 
   html-whitespace-sensitive-tag-names@3.0.1: {}
@@ -12206,6 +12884,8 @@ snapshots:
 
   immer@10.1.1: {}
 
+  immer@11.1.8: {}
+
   import-fresh@3.3.0:
     dependencies:
       parent-module: 1.0.1
@@ -12231,6 +12911,10 @@ snapshots:
       hasown: 2.0.2
       side-channel: 1.1.0
 
+  internmap@1.0.1: {}
+
+  internmap@2.0.3: {}
+
   inversify@6.0.1: {}
 
   iobuffer@5.4.0: {}
@@ -12555,10 +13239,16 @@ snapshots:
       object.assign: 4.1.7
       object.values: 1.2.1
 
+  katex@0.16.47:
+    dependencies:
+      commander: 8.3.0
+
   keyv@4.5.4:
     dependencies:
       json-buffer: 3.0.1
 
+  khroma@2.1.0: {}
+
   kleur@3.0.3: {}
 
   kleur@4.1.5: {}
@@ -12585,6 +13275,10 @@ snapshots:
 
   kolorist@1.8.0: {}
 
+  layout-base@1.0.2: {}
+
+  layout-base@2.0.1: {}
+
   levn@0.3.0:
     dependencies:
       prelude-ls: 1.1.2
@@ -12720,6 +13414,8 @@ snapshots:
 
   marked@15.0.12: {}
 
+  marked@16.4.2: {}
+
   math-intrinsics@1.1.0: {}
 
   md5@2.3.0:
@@ -12946,6 +13642,30 @@ snapshots:
 
   merge2@1.4.1: {}
 
+  mermaid@11.15.0:
+    dependencies:
+      '@braintree/sanitize-url': 7.1.2
+      '@iconify/utils': 3.1.3
+      '@mermaid-js/parser': 1.1.1
+      '@types/d3': 7.4.3
+      '@upsetjs/venn.js': 2.0.0
+      cytoscape: 3.34.0
+      cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0)
+      cytoscape-fcose: 2.2.0(cytoscape@3.34.0)
+      d3: 7.9.0
+      d3-sankey: 0.12.3
+      dagre-d3-es: 7.0.14
+      dayjs: 1.11.21
+      dompurify: 3.4.11
+      es-toolkit: 1.46.1
+      katex: 0.16.47
+      khroma: 2.1.0
+      marked: 16.4.2
+      roughjs: 4.6.6
+      stylis: 4.4.0
+      ts-dedent: 2.3.0
+      uuid: 11.1.0
+
   micromark-core-commonmark@2.0.3:
     dependencies:
       decode-named-character-reference: 1.3.0
@@ -13559,6 +14279,8 @@ snapshots:
 
   path-browserify@1.0.1: {}
 
+  path-data-parser@0.1.0: {}
+
   path-exists@4.0.0: {}
 
   path-key@3.1.1: {}
@@ -13606,6 +14328,13 @@ snapshots:
 
   pluralize@8.0.0: {}
 
+  points-on-curve@0.2.0: {}
+
+  points-on-path@0.2.1:
+    dependencies:
+      path-data-parser: 0.1.0
+      points-on-curve: 0.2.0
+
   possible-typed-array-names@1.0.0: {}
 
   postcss-nested@6.2.0(postcss@8.5.6):
@@ -13764,6 +14493,24 @@ snapshots:
 
   react-is@19.0.0: {}
 
+  react-markdown@10.1.0(@types/react@19.1.8)(react@19.1.0):
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/mdast': 4.0.4
+      '@types/react': 19.1.8
+      devlop: 1.1.0
+      hast-util-to-jsx-runtime: 2.3.6
+      html-url-attributes: 3.0.1
+      mdast-util-to-hast: 13.2.1
+      react: 19.1.0
+      remark-parse: 11.0.0
+      remark-rehype: 11.1.2
+      unified: 11.0.5
+      unist-util-visit: 5.1.0
+      vfile: 6.0.3
+    transitivePeerDependencies:
+      - supports-color
+
   react-mentions-ts@5.4.7(class-variance-authority@0.7.1)(clsx@2.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwind-merge@3.5.0):
     dependencies:
       class-variance-authority: 0.7.1
@@ -13777,6 +14524,15 @@ snapshots:
       react: 19.1.0
       react-dom: 19.1.0(react@19.1.0)
 
+  react-redux@9.3.0(@types/react@19.1.8)(react@19.1.0)(redux@5.0.1):
+    dependencies:
+      '@types/use-sync-external-store': 0.0.6
+      react: 19.1.0
+      use-sync-external-store: 1.4.0(react@19.1.0)
+    optionalDependencies:
+      '@types/react': 19.1.8
+      redux: 5.0.1
+
   react-refresh@0.14.2: {}
 
   react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.0):
@@ -13837,6 +14593,26 @@ snapshots:
 
   readdirp@5.0.0: {}
 
+  recharts@3.9.0(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react-is@19.0.0)(react@19.1.0)(redux@5.0.1):
+    dependencies:
+      '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.1.8)(react@19.1.0)(redux@5.0.1))(react@19.1.0)
+      clsx: 2.1.1
+      decimal.js-light: 2.5.1
+      es-toolkit: 1.46.1
+      eventemitter3: 5.0.1
+      immer: 10.1.1
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+      react-is: 19.0.0
+      react-redux: 9.3.0(@types/react@19.1.8)(react@19.1.0)(redux@5.0.1)
+      reselect: 5.2.0
+      tiny-invariant: 1.3.3
+      use-sync-external-store: 1.4.0(react@19.1.0)
+      victory-vendor: 37.3.6
+    transitivePeerDependencies:
+      - '@types/react'
+      - redux
+
   recma-build-jsx@1.0.0:
     dependencies:
       '@types/estree': 1.0.8
@@ -13866,6 +14642,12 @@ snapshots:
       unified: 11.0.5
       vfile: 6.0.3
 
+  redux-thunk@3.1.0(redux@5.0.1):
+    dependencies:
+      redux: 5.0.1
+
+  redux@5.0.1: {}
+
   reflect-metadata@0.1.13: {}
 
   reflect.getprototypeof@1.0.10:
@@ -14029,6 +14811,8 @@ snapshots:
 
   require-from-string@2.0.2: {}
 
+  reselect@5.2.0: {}
+
   resolve-from@4.0.0: {}
 
   resolve-from@5.0.0: {}
@@ -14084,6 +14868,8 @@ snapshots:
   rgbcolor@1.0.1:
     optional: true
 
+  robust-predicates@3.0.3: {}
+
   rollup@4.57.1:
     dependencies:
       '@types/estree': 1.0.8
@@ -14115,6 +14901,13 @@ snapshots:
       '@rollup/rollup-win32-x64-msvc': 4.57.1
       fsevents: 2.3.3
 
+  roughjs@4.6.6:
+    dependencies:
+      hachure-fill: 0.5.2
+      path-data-parser: 0.1.0
+      points-on-curve: 0.2.0
+      points-on-path: 0.2.1
+
   router@2.2.0:
     dependencies:
       debug: 4.4.3
@@ -14131,6 +14924,8 @@ snapshots:
     dependencies:
       queue-microtask: 1.2.3
 
+  rw@1.3.3: {}
+
   rxjs@7.8.1:
     dependencies:
       tslib: 2.8.1
@@ -14504,6 +15299,8 @@ snapshots:
 
   stylis@4.2.0: {}
 
+  stylis@4.4.0: {}
+
   suf-log@2.5.3:
     dependencies:
       s.color: 0.0.15
@@ -14599,6 +15396,8 @@ snapshots:
 
   tiny-inflate@1.0.3: {}
 
+  tiny-invariant@1.3.3: {}
+
   tinybench@2.9.0: {}
 
   tinyexec@0.3.2: {}
@@ -14650,6 +15449,8 @@ snapshots:
     dependencies:
       typescript: 5.6.3
 
+  ts-dedent@2.3.0: {}
+
   tsconfck@3.1.6(typescript@5.6.3):
     optionalDependencies:
       typescript: 5.6.3
@@ -14939,6 +15740,23 @@ snapshots:
       '@types/unist': 3.0.3
       vfile-message: 4.0.3
 
+  victory-vendor@37.3.6:
+    dependencies:
+      '@types/d3-array': 3.2.2
+      '@types/d3-ease': 3.0.2
+      '@types/d3-interpolate': 3.0.4
+      '@types/d3-scale': 4.0.9
+      '@types/d3-shape': 3.1.8
+      '@types/d3-time': 3.0.4
+      '@types/d3-timer': 3.0.2
+      d3-array: 3.2.4
+      d3-ease: 3.0.1
+      d3-interpolate: 3.0.1
+      d3-scale: 4.0.2
+      d3-shape: 3.2.0
+      d3-time: 3.1.0
+      d3-timer: 3.0.1
+
   vite-bundle-analyzer@0.17.1: {}
 
   vite-node@3.0.4(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4):